diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 00000000..eb9709af --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,36 @@ +variables: + GIT_SUBMODULE_STRATEGY: recursive + +stages: +- buildAndDeployApkUnstable +- buildAndDeployApkStable + +buildAndDeployApkUnstable: + stage: buildAndDeployApkUnstable + script: + - sh deploy-unstable.sh + only: + - tags + except: + - ^(dev) + when: manual + +buildAndDeployApkStable: + stage: buildAndDeployApkStable + script: + - sh deploy-stable.sh + only: + - tags + except: + - branches + when: manual + +buildAndDeployApkStable: + stage: buildAndDeployApkStable + script: + - sh deploy-playstore.sh + only: + - tags + except: + - branches + when: manual diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..3c5b4994 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,63 @@ +[submodule "dep/polycentricandroid"] + path = dep/polycentricandroid + url = ../polycentricandroid.git +[submodule "app/src/playstore/assets/sources/peertube"] + path = app/src/playstore/assets/sources/peertube + url = ../plugins/peertube.git +[submodule "app/src/stable/assets/sources/kick"] + path = app/src/stable/assets/sources/kick + url = ../plugins/kick.git +[submodule "app/src/stable/assets/sources/odysee"] + path = app/src/stable/assets/sources/odysee + url = ../plugins/odysee.git +[submodule "app/src/stable/assets/sources/nebula"] + path = app/src/stable/assets/sources/nebula + url = ../plugins/nebula.git +[submodule "app/src/stable/assets/sources/patreon"] + path = app/src/stable/assets/sources/patreon + url = ../plugins/patreon.git +[submodule "app/src/stable/assets/sources/peertube"] + path = app/src/stable/assets/sources/peertube + url = ../plugins/peertube.git +[submodule "app/src/stable/assets/sources/rumble"] + path = app/src/stable/assets/sources/rumble + url = ../plugins/rumble.git +[submodule "app/src/stable/assets/sources/soundcloud"] + path = app/src/stable/assets/sources/soundcloud + url = ../plugins/soundcloud.git +[submodule "app/src/stable/assets/sources/twitch"] + path = app/src/stable/assets/sources/twitch + url = ../plugins/twitch.git +[submodule "app/src/stable/assets/sources/youtube"] + path = app/src/stable/assets/sources/youtube + url = ../plugins/youtube.git +[submodule "app/src/unstable/assets/sources/kick"] + path = app/src/unstable/assets/sources/kick + url = ../plugins/kick.git +[submodule "app/src/unstable/assets/sources/nebula"] + path = app/src/unstable/assets/sources/nebula + url = ../plugins/nebula.git +[submodule "app/src/unstable/assets/sources/odysee"] + path = app/src/unstable/assets/sources/odysee + url = ../plugins/odysee.git +[submodule "app/src/unstable/assets/sources/patreon"] + path = app/src/unstable/assets/sources/patreon + url = ../plugins/patreon.git +[submodule "app/src/unstable/assets/sources/peertube"] + path = app/src/unstable/assets/sources/peertube + url = ../plugins/peertube.git +[submodule "app/src/unstable/assets/sources/rumble"] + path = app/src/unstable/assets/sources/rumble + url = ../plugins/rumble.git +[submodule "app/src/unstable/assets/sources/soundcloud"] + path = app/src/unstable/assets/sources/soundcloud + url = ../plugins/soundcloud.git +[submodule "app/src/unstable/assets/sources/twitch"] + path = app/src/unstable/assets/sources/twitch + url = ../plugins/twitch.git +[submodule "app/src/unstable/assets/sources/youtube"] + path = app/src/unstable/assets/sources/youtube + url = ../plugins/youtube.git +[submodule "dep/futopay"] + path = dep/futopay + url = ../futopayclientlibraries.git diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md new file mode 100644 index 00000000..dd7891bc --- /dev/null +++ b/CONTRIBUTION.md @@ -0,0 +1,60 @@ +# Contribution Guidelines + +## Table of Contents + +1. [Introduction](#introduction) +2. [Contributing to Official Plugins](#contributing-to-official-plugins) +3. [Creating Your Own Plugins](#creating-your-own-plugins) +4. [Contributing to Core](#contributing-to-core) + +--- + +## Introduction + +Thank you for your interest in contributing! This document outlines how you can contribute to the official plugins and also encourages you to write your own plugins. Please read this guide carefully to understand how you can collaborate with us. + +--- + +## Contributing to Official Plugins + +### License + +The official plugins for this project are licensed under GPLv3. Any contributions you make will also fall under the GPLv3 license. + +### How to Contribute + +1. Fork the repository containing the plugin. +2. Clone your fork. +3. Make your changes. +4. Commit and push your changes. +5. Open a pull request. + +### Guidelines + +- Ensure your code adheres to the existing style. +- Include documentation and unit tests (where applicable). + +--- + +## Creating Your Own Plugins + +We encourage developers to write their own plugins. Please refer to the "Getting Started" documentation to learn how to create a plugin for the app. + +### Guidelines + +- Your plugin's license must be compatible with the core application's license. +- We encourage you to make your plugin open-source, although it's not mandatory. + +--- + +## Contributing to Core + +**We are currently not accepting contributions to the core.** + +The core is currently licensed under the FUTO Temporary License (FTL). The licensing and ownership of contributions to the core are complex topics that we are still working on. We'll update these guidelines when we have more clarity. + +--- + +Thank you for reading the contribution guidelines. Happy contributing! + + diff --git a/README.md b/README.md new file mode 100644 index 00000000..263689a2 --- /dev/null +++ b/README.md @@ -0,0 +1,205 @@ +# PlatformPlayer + +The FUTO media app endeavours creating infrastructure for creators to have their content hosted by someone else but at the same time having creators retain full ownership of their content. We want creators to feel like they are publishing to the world, and we want multiple indexers competing with each other to do a good job connecting consumers to creators and their content. + +One part of the solution is to create an application that allows users to search through all available media websites and giving creators the tools for direct monetization of their content by allowing users to directly donate to the content creator. + +FUTO is an organization dedicated to developing, both through in-house engineering and investment, +technologies that frustrate centralization and industry consolidation. + + + + + + + + + + +
VideoVideo (details)
+ +## What does the app do? + +The FUTO media app is a player that exposes multiple video websites as sources in the app. These sources can be easily configured and third-party sources can also manually be added. This is done through the sources UI. + + + + + + + + + + +
Sources (all enabled)Sources (one disabled)
+ +Additional sources can also be installed. These sources are JavaScript sources, created and maintained by the community. + + + + + + + + + + +
Install a new sourceConfigure a source
+ +Once the sources are configured, the combined results will be shown throughout the app. The core features of the app will be highlighted below. + +### Searching + +When a user enters a search term into the search bar, the query is posted to the underlying platforms and a list of results that are ranked by relevance is returned. The search functionality of the app allows users to search multiple sources at once, allowing users to discover a wider range of content that is relevant to their interests. + + + + + + + + + + +
Search (list)Search (preview)
+ +### Channels + +Channels allow users to view the creators content, read more about them or support them by donating, purchasing from their store or buying a membership. The FUTO media app only links to other stores and the app does not play an intermediate role in the actual purchase process. This way, creators can directly monetize their own content in the way they like. + +Creators are able to configure their profile using NeoPass. + + + + + + + + +
Channel
+ +### Feed + +Subscriptions are a way for users to keep up with the latest videos and content from their favorite creators. The creators you are subscribed to are shown in the creators tab. In the future we will add both creator search and suggested creators. + + + + + + + + +
Creators
+ +When you subscribe to a creator, you'll be able to find new videos uploaded by them in the subscriptions tab. + + + + + + + + + + +
Subscriptions (list)Subscriptions (preview)
+ +Additionally there is also the "Home" feed which is based purely on recommendations by the underlying platforms. Also here we hope to offer user-picked recommendation engines in the future. + +## Settings + +The app offers a lot of settings customizing how the app looks and feels. An example of this is the background behaviour, do you wish to have it use picture in picture, background play or shut off entirely. Another example configuration option is choosing between list views or video previews. + + + + + + + + +
Settings
+ +### Playlists + +Playlists allow you to make a collection of videos that you can create and customize to your liking. When you add videos to a playlist, they're grouped together in a single location, making it easy for you to find and watch all of the videos in the playlist in sequence. + + + + + + + + + + +
PlaylistsPlaylist
+ +Playlists can also be downloaded in their entirety. + +### Downloads + +Both individual videos and playlists can be downloaded for local, offline playback. You can watch downloaded videos any time, even if you do not have an active internet connection. + + + + + + + + +
Downloads
+ +### Casting + +The app can also cast to a big screen using any of the supported protocols (FastCast, ChromeCast, AirPlay). Not all casting protocols support all features. As a rule of thumb feature-wise FastCast > ChromeCast > AirPlay. + +For more information about casting please click [here](./docs/casting.md). + + + + + + + + +
Casting
+ +### Commenting and rating + +The app can also cast to comment and rate. For more information about this please click [here](./docs/polycentric.md). + +### Creator Linking + +The app can also cast to link channels together. For more information about this please click [here](./docs/linking.md). + +### Migration and recommendations + +Sources have the ability to login, allowing you to use features that require credentials like importing your playlists, importing your subscriptions or have personalized recommendations. Some platforms may require a membership to work at all. + +In the future we hope to offer users the choice of their desired recommendation engine and have multiple competing recommendation engines for different audiences. + +## Building + +1. Download a copy of the repository. +2. Open the project in Android Studio: Once the repository is cloned, you can open it in Android Studio by selecting "Open an Existing Project" from the welcome screen and navigating to the directory where you cloned the repository. +3. Build the project: With the project open in Android Studio, you can build it by selecting "Build > Make Project" from the main menu. This will compile the code and generate an APK file that you can install on your device or emulator. +4. Run the project: To run the project, select "Run > Run 'app'" from the main menu. This will launch the app on your device or emulator, allowing you to test it and make any necessary changes. + +## Contributing + +Please see [CONTRIBUTION.md](./CONTRIBUTION.md). + +## CI/CD + +Tests will always run and are required to pass before a merge request is allowed to be merged. The build/deploy CI/CD steps will only be triggered by a tag on the master branch. + +### Making a new build + +Create a tag on the master branch, incrementing the last version number by 1 (for example `25` to `26`). + +Click on the CI/CD tab, you should now see the tests and build are in progress. If the build succeeds the last step will become available. The last step is a manual action which can be triggered by clicking the run button on the action. This action will deploy the build to all users using the app through auto-update. + + +## Documentation + +The documentation can be found [here](https://gitlab.futo.org/videostreaming/documents/-/wikis/API-Overview). diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 00000000..42afabfd --- /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 00000000..40ce21c2 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,207 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.10' + id 'org.ajoberstar.grgit' version '1.7.2' + id 'com.google.protobuf' + id 'kotlin-parcelize' +} + +ext { + gitVersionName = grgit.describe() + gitVersionCode = gitVersionName.isInteger() ? gitVersionName.toInteger() : 1 +} + +println("Version Name: $gitVersionName") +println("Version Code: $gitVersionCode") + +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('/opt/key.properties') +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + +protobuf { + protoc { + artifact = 'com.google.protobuf:protoc:3.22.3' + } + generateProtoTasks { + all().each { task -> + task.builtins { + java { + option "lite" + } + } + } + } +} + +android { + namespace 'com.futo.platformplayer' + compileSdk 33 + flavorDimensions "buildType" + productFlavors { + stable { + dimension "buildType" + applicationId "com.futo.platformplayer" + buildConfigField "boolean", "IS_UNSTABLE_BUILD", "false" + buildConfigField "boolean", "IS_PLAYSTORE_BUILD", "false" + resValue "string", "app_name", "Grayjay" + resValue "string", "authority", "com.futo.platformplayer" + } + unstable { + dimension "buildType" + applicationId "com.futo.platformplayer.d" + buildConfigField "boolean", "IS_UNSTABLE_BUILD", "true" + buildConfigField "boolean", "IS_PLAYSTORE_BUILD", "false" + resValue "string", "app_name", "Grayjay Unstable" + resValue "string", "authority", "com.futo.platformplayer.d" + getIsDefault().set(true) + } + playstore { + dimension "buildType" + applicationId "com.futo.platformplayer.playstore" + buildConfigField "boolean", "IS_UNSTABLE_BUILD", "false" + buildConfigField "boolean", "IS_PLAYSTORE_BUILD", "true" + resValue "string", "app_name", "Grayjay" + resValue "string", "authority", "com.futo.platformplayer.playstore" + } + } + + android.applicationVariants.all { variant -> + if (variant.flavorName == "unstable") { + variant.preBuildProvider.configure { + doFirst { + println("UNSTABLE BUILD") + } + } + } + + if (variant.flavorName == "stable") { + variant.preBuildProvider.configure { + doFirst { + println("STABLE BUILD") + } + } + } + + if (variant.flavorName == "playstore") { + variant.preBuildProvider.configure { + doFirst { + println("PLAYSTORE BUILD") + } + } + } + } + + defaultConfig { + minSdk 29 + targetSdk 33 + versionCode gitVersionCode + versionName gitVersionName + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + signingConfigs { + release { + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null + storePassword keystoreProperties['storePassword'] + } + } + + buildTypes { + release { + signingConfig signingConfigs.release + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } + + splits { + abi { + enable true + reset() + + include "x86", "x86_64", "arm64-v8a", "armeabi-v7a" + universalApk true + } + } +} + +dependencies { + + //Core + implementation 'androidx.core:core-ktx:1.7.0' + implementation 'androidx.appcompat:appcompat:1.4.1' + implementation 'com.google.android.material:material:1.5.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.3' + + //Images + annotationProcessor 'com.github.bumptech.glide:compiler:4.15.1' + implementation 'com.github.bumptech.glide:glide:4.15.1' + + //Async + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.2" + + //HTTP + implementation "com.squareup.okhttp3:okhttp:4.10.0" + + //JSON + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1" //Used for structured json + implementation 'com.google.code.gson:gson:2.10.1' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject) + + //JS + implementation("com.caoccao.javet:javet-android:2.2.1") + + //Exoplayer + implementation 'com.google.android.exoplayer:exoplayer-core:2.18.7' + implementation 'com.google.android.exoplayer:exoplayer-dash:2.18.7' + implementation 'com.google.android.exoplayer:exoplayer-ui:2.18.7' + implementation 'com.google.android.exoplayer:exoplayer-hls:2.18.7' + implementation 'com.google.android.exoplayer:exoplayer-rtsp:2.18.7' + implementation 'com.google.android.exoplayer:exoplayer-smoothstreaming:2.18.7' + implementation 'com.google.android.exoplayer:exoplayer-transformer:2.18.7' + implementation 'androidx.navigation:navigation-fragment-ktx:2.5.3' + implementation 'androidx.navigation:navigation-ui-ktx:2.5.3' + + //Other + implementation 'org.jmdns:jmdns:3.5.1' + implementation 'org.jsoup:jsoup:1.15.3' + implementation 'com.google.android.flexbox:flexbox:3.0.0' + implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' + implementation 'com.arthenica:ffmpeg-kit-full:5.1' + implementation 'org.jetbrains.kotlin:kotlin-reflect:1.7.20' + implementation 'com.github.dhaval2404:imagepicker:2.1' + implementation 'com.google.zxing:core:3.4.1' + implementation 'com.journeyapps:zxing-android-embedded:4.2.0' + implementation 'com.caverock:androidsvg-aar:1.4' + + //Protobuf + implementation 'com.google.protobuf:protobuf-javalite:3.22.3' + + implementation 'com.polycentric.core:app:1.0' + implementation 'com.futo.futopay:app:1.0' + implementation 'androidx.work:work-runtime-ktx:2.8.1' + implementation 'androidx.concurrent:concurrent-futures-ktx:1.1.0' + + //Payment + implementation 'com.stripe:stripe-android:20.28.3' + + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.2' + testImplementation "org.jetbrains.kotlin:kotlin-test:1.8.20" + testImplementation "org.xmlunit:xmlunit-core:2.9.1" + testImplementation "org.mockito:mockito-core:5.4.0" + androidTestImplementation 'androidx.test.ext:junit:1.1.3' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /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/src/androidTest/java/com/futo/platformplayer/EncryptionProviderTests.kt b/app/src/androidTest/java/com/futo/platformplayer/EncryptionProviderTests.kt new file mode 100644 index 00000000..f4605efd --- /dev/null +++ b/app/src/androidTest/java/com/futo/platformplayer/EncryptionProviderTests.kt @@ -0,0 +1,62 @@ +package com.futo.platformplayer + +import com.futo.platformplayer.encryption.EncryptionProvider +import junit.framework.TestCase.assertEquals +import org.junit.Test + +class EncryptionProviderTests { + @Test + fun testEncryptDecrypt() { + val encryptionProvider = EncryptionProvider.instance + val plaintext = "This is a test string." + + // Encrypt the plaintext + val ciphertext = encryptionProvider.encrypt(plaintext) + + // Decrypt the ciphertext + val decrypted = encryptionProvider.decrypt(ciphertext) + + // The decrypted string should be equal to the original plaintext + assertEquals(plaintext, decrypted) + } + + + @Test + fun testEncryptDecryptBytes() { + val encryptionProvider = EncryptionProvider.instance + val bytes = "This is a test string.".toByteArray(); + + // Encrypt the plaintext + val ciphertext = encryptionProvider.encrypt(bytes) + + // Decrypt the ciphertext + val decrypted = encryptionProvider.decrypt(ciphertext) + + // The decrypted string should be equal to the original plaintext + assertArrayEquals(bytes, decrypted); + } + + @Test + fun testEncryptDecryptBytesPassword() { + val encryptionProvider = EncryptionProvider.instance + val bytes = "This is a test string.".toByteArray(); + val password = "1234".padStart(32, '9'); + + // Encrypt the plaintext + val ciphertext = encryptionProvider.encrypt(bytes, password) + + // Decrypt the ciphertext + val decrypted = encryptionProvider.decrypt(ciphertext, password) + + // The decrypted string should be equal to the original plaintext + assertArrayEquals(bytes, decrypted); + + } + + private fun assertArrayEquals(a: ByteArray, b: ByteArray) { + assertEquals(a.size, b.size); + for(i in 0 until a.size) { + assertEquals(a[i], b[i]); + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/futo/platformplayer/RequireMigrationTests.kt b/app/src/androidTest/java/com/futo/platformplayer/RequireMigrationTests.kt new file mode 100644 index 00000000..16c6da2f --- /dev/null +++ b/app/src/androidTest/java/com/futo/platformplayer/RequireMigrationTests.kt @@ -0,0 +1,35 @@ +package com.futo.platformplayer + +import android.util.Log +import androidx.test.platform.app.InstrumentationRegistry +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.Serializer +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.Thumbnail +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails +import com.futo.platformplayer.serializers.FlexibleBooleanSerializer +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.stores.FragmentedStorage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import org.junit.Assert +import org.junit.Test +import java.time.OffsetDateTime + +class RequireMigrationTests { + /* THESE TESTS SIMPLY EXIST TO WARN THE DEVELOPER THAT THEIR CHANGE WILL CAUSE A MIGRATION FOR ALL USERS */ + private val serializedSettingsString = "{\"home\":{},\"search\":{},\"subscriptions\":{\"subscriptionsFeedStyle\":0,\"subscriptionsBackgroundUpdateInterval\":3},\"playback\":{\"autoRotate\":0},\"downloads\":{},\"browsing\":{},\"casting\":{},\"logging\":{},\"autoUpdate\":{\"check\":1},\"announcementSettings\":{},\"backup\":{},\"payment\":{\"paymentStatus\": \"Paid\"},\"info\":{}}"; + + @Test + fun testSettingsDeserializing() { + val context = InstrumentationRegistry.getInstrumentation().targetContext; + StateApp.instance.setGlobalContext(context, CoroutineScope(Dispatchers.Main)); + + Assert.assertNotNull(Json { ignoreUnknownKeys = true; this.isLenient = true }.decodeFromString(serializedSettingsString)); + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/futo/platformplayer/SignatureTests.kt b/app/src/androidTest/java/com/futo/platformplayer/SignatureTests.kt new file mode 100644 index 00000000..69150715 --- /dev/null +++ b/app/src/androidTest/java/com/futo/platformplayer/SignatureTests.kt @@ -0,0 +1,68 @@ +package com.futo.platformplayer + +import android.util.Log +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import org.junit.Test + +import org.junit.Assert.* + +class SignatureTests { + @Test + fun roundtripTest() { + val keys = SignatureProvider.generateKeyPair(); + Log.i(TAG, "public key: ${keys.publicKey}\nprivate key: ${keys.privateKey}"); + + val signature = SignatureProvider.sign("test", keys.privateKey); + assertTrue(SignatureProvider.verify("test", signature, keys.publicKey)); + } + + @Test + fun decodeTest() { + assertTrue(SignatureProvider.verify( + "//this is just an empty script", + "eLdlDIcmpTQmfpCumB5NQwFa0ZDNU8hkRB12/Lg+CdTwPrfTIylGeN6jpTmJrEivyLjj" + + "5qHWZeNmrHP++9XFwfwzcaXNspKU9YrL3+Bsy2WNnXfQDeB2t4AkzWYAEfm8/kEcK0Ov8dzy0KW" + + "lJsxmW+Oj3mFNVP6PV5ZQY1Gju6W8Jw0sGCxnbuhswtRDPwBKnZQUhlZEXPvbrcblW1q5fCESnf" + + "oiJ2MHR5epgHfAuMsoY9EAHVXuyrLvmbWADeVwC5jvWLAkJKw68rQmARqV5BBWkpqFEBQcg50CR" + + "vTXtPr8IDjW7yiJ6x9nTG3nokTJn3fj2D3hBEHttEG+KhTMlQ==", + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAucY14D6cl0fK5fHOTUfKMz1iQmfJMg" + + "Q+c4MfqlArGCv7YDTvazeQL9dsrCqlYx+o+AlYzbohGXYsYsJO474+Ia5VEcpCnMm6YPRhV0H+8bke" + + "lgE2vMB2MB54zIxVRVEA1CBPBrWle8qlBMmqXI8ndjQjIYZNZD0CN0ckOgLO3OX8+P6f+zYHbRINCXi" + + "T1L7DstJ4FacqE7b2+aNKiMogUoaq7H3dXxJXj32HMFZevrs8ZFxTvbIP4KkazRrdfnZPdWKXk9pv" + + "P8EI21RNKKr2NtVNJyRPxI1uWlvYtGeSLcNUioHshNRQ4SxRSG8p1VTBmUpS0cJoZSCmO/0W9doyzwI" + + "DAQAB")); + } + + @Test + fun testSignature() { + val somePlugin = SourcePluginConfig( + "Script", + "A plugin that adds Script as a source", + "FUTO", + "https://futo.org", + "https://futo.org", + "./Script.js", + 1, + "./script.png", + "394dba51-fa0c-450c-8f17-6a00df362218", + "eLdlDIcmpTQmfpCumB5NQwFa0ZDNU8hkRB12/Lg+CdTwPrfTIylGeN6jpTmJrEivyLjj" + + "5qHWZeNmrHP++9XFwfwzcaXNspKU9YrL3+Bsy2WNnXfQDeB2t4AkzWYAEfm8/kEcK0Ov8dzy0KW" + + "lJsxmW+Oj3mFNVP6PV5ZQY1Gju6W8Jw0sGCxnbuhswtRDPwBKnZQUhlZEXPvbrcblW1q5fCESnf" + + "oiJ2MHR5epgHfAuMsoY9EAHVXuyrLvmbWADeVwC5jvWLAkJKw68rQmARqV5BBWkpqFEBQcg50CR" + + "vTXtPr8IDjW7yiJ6x9nTG3nokTJn3fj2D3hBEHttEG+KhTMlQ==", + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAucY14D6cl0fK5fHOTUfKM" + + "z1iQmfJMgQ+c4MfqlArGCv7YDTvazeQL9dsrCqlYx+o+AlYzbohGXYsYsJO474+Ia5VEcpC" + + "nMm6YPRhV0H+8bkelgE2vMB2MB54zIxVRVEA1CBPBrWle8qlBMmqXI8ndjQjIYZNZD0CN0c" + + "kOgLO3OX8+P6f+zYHbRINCXiT1L7DstJ4FacqE7b2+aNKiMogUoaq7H3dXxJXj32HMFZevrs8ZF" + + "xTvbIP4KkazRrdfnZPdWKXk9pvP8EI21RNKKr2NtVNJyRPxI1uWlvYtGeSLcNUioHshNRQ4SxRSG8p1VTBmUpS0cJoZSCmO/0W9doyzwIDAQAB" + ); + val script = "//this is just an empty script"; + + assert(somePlugin.validate(script), { "Invalid signature" }); + + } + + companion object { + private const val TAG = "SignatureTests"; + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/futo/platformplayer/StatePlatformTests.kt b/app/src/androidTest/java/com/futo/platformplayer/StatePlatformTests.kt new file mode 100644 index 00000000..a006581a --- /dev/null +++ b/app/src/androidTest/java/com/futo/platformplayer/StatePlatformTests.kt @@ -0,0 +1,79 @@ +package com.futo.platformplayer + +//import androidx.test.platform.app.InstrumentationRegistry +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StateSubscriptions +import kotlinx.coroutines.runBlocking +import org.junit.Test +import kotlin.system.measureTimeMillis + +class StatePlatformTests { + + /* + @Test + fun testPlatformStateGetVideo(){ + runBlocking { + StatePlatform.instance.updateAvailableClients(InstrumentationRegistry.getInstrumentation().targetContext); + //StatePlatform.instance.selectClients(YoutubeClient.ID, OdyseeClient.ID); + var youtubeStreamVideoTask = StatePlatform.instance.getContentDetails("https://www.youtube.com/watch?v=bDgolMkLREA"); + val odyseeStreamVideoTask = StatePlatform.instance.getContentDetails("lbry://ads-and-tracking-is-getting-worse-on#e2d1a7334869dfb531c80823064debbb2e75dac5"); + val odyseeStreamVideoTask2 = StatePlatform.instance.getContentDetails("lbry://ads-and-tracking-is-getting-worse-on#e2d1a7334869dfb531c80823064debbb2e75dac5"); + + //Assert batching + assert(odyseeStreamVideoTask == odyseeStreamVideoTask2); + + val youtubeStreamVideo = youtubeStreamVideoTask.await(); + val odyseeStreamVideo = odyseeStreamVideoTask.await(); + + assert(youtubeStreamVideo.id.value == "bDgolMkLREA") + assert(odyseeStreamVideo.id.value == "bMPjhiYTR1GCPKaSGQkFyiVv1juJaY4PaG") + } + }*/ + + //TODO: Re-enable once getChannel requests are batched for non-subscribed channels. + /* + @Test + fun testPlatformStateGetChannelVideos(){ + val expectedChannelUrl = "https://www.youtube.com/channel/UCL81YHgzH8tcrFfOJJwlSQw"; + val expectedVideoId = "up2TjMuan6o" + val expectedVideoName= "bag cat"; + + runBlocking { + var youtubeChannelVideosTask = PlatformState.instance.getChannel(expectedChannelUrl); + var youtubeChannelVideosTask2 = PlatformState.instance.getChannel(expectedChannelUrl); + + //Assert batching + assert(youtubeChannelVideosTask == youtubeChannelVideosTask2); + + val youtubeStreamVideo = youtubeChannelVideosTask.await(); + + val page1Results = youtubeStreamVideo.videos.getResults(); + assert(page1Results.size > 0); + assert(page1Results.any { it.id.value == expectedVideoId }); + } + } + + @Test + fun testPlatformStateSubscription(){ + runBlocking { + StatePlatform.instance.updateAvailableClients(InstrumentationRegistry.getInstrumentation().targetContext); + //StatePlatform.instance.selectClients(YoutubeClient.ID, OdyseeClient.ID); + } + val expectedChannelUrl = "https://www.youtube.com/channel/UCL81YHgzH8tcrFfOJJwlSQw"; + val expectedVideoId = "up2TjMuan6o" + val expectedVideoName= "bag cat"; + + val channel = runBlocking { StatePlatform.instance.getChannel(expectedChannelUrl).await() }; + + val timeExplicit = measureTimeMillis { + runBlocking { + val stateExplicit = StateSubscriptions(); + stateExplicit.addSubscription(channel); + val channel = stateExplicit.getSubscription(expectedChannelUrl); + + assert(channel != null); + } + }; + System.out.println("Explicit Subscription update $timeExplicit ms"); + }*/ +} \ 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 00000000..2abba88c --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/devportal/dependencies/FutoMainLogo.svg b/app/src/main/assets/devportal/dependencies/FutoMainLogo.svg new file mode 100644 index 00000000..f7685471 --- /dev/null +++ b/app/src/main/assets/devportal/dependencies/FutoMainLogo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/assets/devportal/dev_bridge.js b/app/src/main/assets/devportal/dev_bridge.js new file mode 100644 index 00000000..57f0f276 --- /dev/null +++ b/app/src/main/assets/devportal/dev_bridge.js @@ -0,0 +1,337 @@ + +//These calls are purposely synchronized to emulate behavior within V8 +function syncGET(url, headers) { + if(!headers) headers = {}; + const req = new XMLHttpRequest(); + req.open("GET", url, false); + for (const [key, value] of Object.entries(headers)) + req.setRequestHeader(key, value); + req.send(null); + + if(req.status >= 200 && req.status < 300) + return req.response; + else + throw "Request [" + req.status + "]\n" + req.response; +} +function syncPOST(url, headers, body) { + if(!headers) headers = {}; + const req = new XMLHttpRequest(); + req.open("POST", url, false); + for (const [key, value] of Object.entries(headers)) + req.setRequestHeader(key, value); + req.send(body); + + if(req.status >= 200 && req.status < 300) + return req.response; + else + throw "Request [" + req.status + "]\n" + req.response; +} + + +class RemoteObject { + constructor(remoteObj) { + Object.assign(this, remoteObj); + + if(this.__methods) { + const me = this; + for(let i = 0; i < this.__methods.length; i++) { + const methodName = this.__methods[i]; + + this[methodName] = function() { + try{ + return remoteCall(me.__id, methodName, Array.from(arguments)); + } + catch(ex) { + if(ex.indexOf("[400]") > 0 && ex.indexOf("does not exist") > 0 && ex.indexOf(me.__id) > 0) { + deletePackage(me.__id); + } + else throw ex; + } + }; + } + } + if(this.__props) { + const me = this; + for(let i = 0; i < this.__props.length; i++) { + const propName = this.__props[i]; + + Object.defineProperty(this, propName, { + get() { + try{ + return remoteProp(me.__id, propName); + } + catch(ex) { + if(ex.indexOf("[400]") > 0 && ex.indexOf("does not exist") > 0 && ex.indexOf(me.__id) > 0) { + deletePackage(me.__id); + } + else throw ex; + } + } + }); + } + } + } +} +const excludedCallsFromLogs = ["isLoggedIn"]; +function remoteCall(objID, methodName, args) { + for(let i = 0; i < args.length; i++) { + let arg = args[i]; + if(typeof(arg) == "object") { + switch(arg.constructor.name) { + case "Uint8Array": + args[i] = [...arg] + break; + } + } + } + + + if(excludedCallsFromLogs.indexOf(methodName) < 0) + console.log("Remote Call on [" + objID + "]." + methodName + "(...)", args); + const result = pluginRemoteCall(objID, methodName, args); + return wrapRemoteObject(result); +} +function remoteProp(objID, propName) { + console.log("Remote Prop on [" + objID + "]." + propName); + const result = pluginRemoteProp(objID, propName); + return wrapRemoteObject(result); +} +function wrapRemoteObject(result) { + if(Array.isArray(result)) { + if(result.length == 0) + return []; + const firstItem = result[0]; + if(typeof firstItem === "object") + return result.map(x=>new RemoteObject(x)); + else + return result; + } + else if(typeof result === "object") + return new RemoteObject(result); + return result; +} + +//These override implementations by packages if enabled +var packageOverrides = { + domParser() { + return { + parseFromString(str) { + return new DOMParser().parseFromString(str, "text/html"); + } + } + } +}; +var packageOverridesEnabled = {}; +for(override in packageOverrides) + packageOverridesEnabled[override] = false; + + +var _loadedPackages = { + +}; +function clearPackages() { + _loadedPackages = {}; +} +function deletePackage(id) { + for(let key in _loadedPackages) { + if(_loadedPackages[key]?.__id == id) + _loadedPackages[key] = undefined; + } +} +function applyPackages(packages) { + _loadedPackages = {}; + for(let i = 0; i < packages.length; i++) { + const package = packages[i]; + delete window[package]; + Object.defineProperty(window, package, { + configurable: true, + get() { + if(!_loadedPackages[package]) { + if(packageOverridesEnabled[package]) { + _loadedPackages[package] = packageOverrides[package](); + console.log("LOADED EMULATED PACKAGE [" + package + "]", _loadedPackages[package]); + } + else { + _loadedPackages[package] = new RemoteObject(pluginGetPackage(package)); + console.log("LOADED REMOTE PACKAGE [" + package + "]", _loadedPackages[package]); + applyAdditionalOverrides(package, _loadedPackages[package]); + } + } + return _loadedPackages[package]; + } + }); + } +} +function applyAdditionalOverrides(packageName, package) { + switch(packageName) { + case "http": + console.log("Http override for socket"); + package.socket = (url, headers, auth) => { + console.warn("This uses an emulated socket connection directly from browser. Remoting websocket is not yet supported."); + if(auth) + throw "Socket override does not support auth yet (should work in-app)"; + + const obj = {}; + + obj.connect = function(listeners) { + obj.socket = new WebSocket(url); + obj.socket.addEventListener("open", (event) => { + obj.isOpen = true; + listeners.open && listeners.open(); + }); + obj.socket.addEventListener("message", (event) => listeners.message && listeners.message(event.data)); + obj.socket.addEventListener("error", (event) => listeners.failure && listeners.failure()); + obj.socket.addEventListener("closed", (event) => { + obj.isOpen = false; + listeners.closed && listeners.closed(event.code, event.reason); + }); + }; + obj.send = function(msg) { + if(obj.socket != null) + obj.socket.send(msg); + } + obj.close = function(code, reason) { + if(obj.socket != null) + obj.socket.close(code, reason); + } + return obj; + }; + break; + + } +} + +function reloadPackages() { + const packages = Object.keys(_loadedPackages); + applyPackages(packages); +} + +function httpGETBypass(url, headers, ct) { + return JSON.parse(syncPOST("/get?CT=" + ct, {}, JSON.stringify({ + url: url, + headers: headers + }))); +} +function pluginUpdateTestPlugin(config) { + return JSON.parse(syncPOST("/plugin/updateTestPlugin", {}, JSON.stringify(config))); +} +function pluginLoginTestPlugin() { + return syncGET("/plugin/loginTestPlugin", {}); +} +function pluginLogoutTestPlugin() { + return syncGET("/plugin/logoutTestPlugin", {}); +} +function pluginGetPackage(packageName) { + return JSON.parse(syncGET("/plugin/packageGet?variable=" + packageName, {})); +} +function pluginRemoteProp(objID, propName) { + return JSON.parse(syncGET("/plugin/remoteProp?id=" + objID + "&prop=" + propName, {})); +} +function pluginRemoteCall(objID, methodName, args) { + return JSON.parse(syncPOST("/plugin/remoteCall?id=" + objID + "&method=" + methodName, {}, JSON.stringify(args))); +} + +function pluginIsLoggedIn(cb, err) { + fetch("/plugin/isLoggedIn", { + timeout: 1000 + }) + .then(x => x.json()) + .then(x => cb(x)) + .catch(y => err && err(y)); +} + +function pluginGetWarnings(config) { + return JSON.parse(syncPOST("/plugin/getWarnings", {}, JSON.stringify(config))); +} + +function uploadDevPlugin(config) { + return JSON.parse(syncPOST("/plugin/loadDevPlugin", {}, JSON.stringify(config))); +} +function getDevLogs(lastIndex, cb) { + if(!lastIndex) + lastIndex = 0; + fetch("/plugin/getDevLogs?index=" + lastIndex, { + timeout: 1000 + }) + .then(x=>x.json()) + .then(y=> cb && cb(y)); +} +function sendFakeDevLog(devId, msg) { + return syncGET("/plugin/fakeDevLog?devId=" + devId + "&msg=" + msg, {}); +} + +var __DEV_SETTINGS = {}; +function setDevSettings(obj) { + __DEV_SETTINGS = obj; +} + +var liveChatIntervalId = null; +function testLiveChat(url, interval, verbose) { + if(!interval) + interval = 4000; + if(liveChatIntervalId) + clearInterval(liveChatIntervalId); + + let live = source.getLiveEvents(url); + liveChatIntervalId = setInterval(()=>{ + if(!live.hasMorePagers()) { + clearInterval(liveChatIntervalId); + console.log("END OF CHAT"); + } + live.nextPage(); + for(let event of live.results) { + if(verbose) { + if(event.type == 1) + console.log("Live Chat: [" + event.name + "]:" + event.message, event); + else if(event.type == 5) + console.log("Live Chat: DONATION (" + event.amount + ") [" + event.name + "]: " + event.message, event); + else if(event.type == 6) + console.log("Live Chat: MEMBER (" + event.amount + ") [" + event.name + "]: " + event.message, event); + else console.log("Live Chat: Ev", event); + } + else { + if(event.type == 1) + console.log("Live Chat: [" + event.name + "]:" + event.message); + else if(event.type == 5) + console.log("Live Chat: DONATION (" + event.amount + ") [" + event.name + "]: " + event.message); + else if(event.type == 6) + console.log("Live Chat: MEMBER (" + event.amount + ") [" + event.name + "]: " + event.message); + else console.log("Live Chat: Ev", event); + } + } + }, interval); +} + +function testPlaybackTracker(url, seconds, iterations, pauseAfter) { + let lastTime = (new Date()).getTime(); + const tracker = source.getPlaybackTracker(url); + if(!tracker) { + console.warn("No tracker available (null)"); + return; + } + + if(tracker.onInit) + tracker.onInit(seconds); + + let iteration = undefined; + iteration = function(itt) { + const diff = (new Date()).getTime() - lastTime; + const secCurrent = seconds + (diff / 1000); + + tracker.onProgress(secCurrent, true); + + if(itt > 0) + setTimeout(()=>{ + iteration(itt - 1); + }, tracker.nextRequest); + else + setTimeout(()=> { + const diff = (new Date()).getTime() - lastTime; + const secCurrent = seconds + (diff / 1000); + tracker.onProgress(secCurrent, false); + }, 850); + } + setTimeout(()=> { + iteration(iterations - 1); + }, tracker.nextRequest); +} \ No newline at end of file diff --git a/app/src/main/assets/devportal/dev_test.html b/app/src/main/assets/devportal/dev_test.html new file mode 100644 index 00000000..7105ad65 --- /dev/null +++ b/app/src/main/assets/devportal/dev_test.html @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/devportal/index.html b/app/src/main/assets/devportal/index.html new file mode 100644 index 00000000..17bce7d5 --- /dev/null +++ b/app/src/main/assets/devportal/index.html @@ -0,0 +1,897 @@ + + + + + + + + + + + + + + +
+ + +
+
+ +
+ + + + +
+ + Login + + + Logout + +
+ +
+
+ {{Plugin.currentPlugin.name}} +
+
+ Last updated: {{Plugin.lastLoadTime}} +
+
+ + mdi-refresh + +
+
+
+
+
+ + +
+ +
+ +
+ Loading via script tag might give issues reloading script, as it makes the script part of DOM, but does allow debugging via dev console. +
+
+
+
+ + + Load Plugin + +
+ + + Package Overrides + + +
+
+ Enabling a package override replaces the package with a browser implementation. + This generally improves speed, at the cost of test accuracy. +
+
+ +
+
+
+
+
+ + +
+

Past Plugins

+
+ {{pastPluginUrl}} +
+
+
+ + + +
+

Errors in Plugin

+ {{Plugin.currentPluginError}} +
+
+
+ + +
+

Your plugin is loaded

+ You can now use testing methods available on the webapp.

+ The information and warnings the user will see when installing the app can be viewed below. +
+
+
+ + +
+
+ +
+ Last updated: {{Plugin.lastLoadTime}} +
+
+

{{Plugin.currentPlugin.name}}

+
+ By + {{Plugin.currentPlugin.author}} + + + {{Plugin.currentPlugin.author}} + + +
+
+
+
+ {{Plugin.currentPlugin.description}} +
+
+
+
+ Version +
+
+ {{Plugin.currentPlugin.version}} +
+
+ + +
+
+
+ + + + Ref.js + + + Plugin.d.ts + + Reload + +
+
+

Warnings

+
+ These are the warnings a user will see when they attempt to install this plugin +
+ + +
+
+ security +
+
+
+ {{warning.first}} +
+
+ {{warning.second}} +
+
+
+
+
+
+
+
+ + + No Plugin Loaded + + + +
+ Load a plugin before doing testing. +
+
+
+
+ + + +
+ (Optional) + {{req.title}} +
+ +
+ {{req.description}} +
+
+ {{req.code}} +
+
+
+
+ {{parameter.name}} +
+
+ {{parameter.description}} +
+ +
+
+
+ + + + Test + + +
+
+
+

Results

+
+ Copy +
+
+ No test done yet +
+
{{Testing.lastResult}}
+
{{Testing.lastResultError}}
+
+
+ +
+ + + No Plugin Loaded + + + +
+ Load a plugin before doing integration testing. +
+
+
+ + +
+

Errors in Plugin

+ Its best to fix errors before doing any integration testing +
+
+
+ + + Integration Testing + + + +
+
+ Integration testing allows you to upload your loaded plugin onto your phone, and get logs below to find any exceptions in actual usage. +
+
+ Last Injected: {{Integration.lastInjectTime}}
+ Click Inject Plugin again to update to last version. +
+
+ Plugin is not yet injected. Click "Inject Plugin" to load the plugin on your phone. +
+
+
+ + + Inject Plugin + +
+ + + Device Logs + + + +
+
+
+ [{{line.type}}] +
+
{{line.log}}
+
+
+
+ + + Clear + +
+
+ +
+ + + Settings + + + +
+
+ +
+
+ > +
+
+
+ + + Save + +
+
+ +
+
+
+
+ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/devportal/plugin.d.ts b/app/src/main/assets/devportal/plugin.d.ts new file mode 100644 index 00000000..75327d0b --- /dev/null +++ b/app/src/main/assets/devportal/plugin.d.ts @@ -0,0 +1,364 @@ + +declare class ScriptException extends Error { + constructor(type: string, msg: string); +} +declare class TimeoutException extends ScriptException { + constructor(msg: string); +} +declare class UnavailableException extends ScriptException { + constructor(msg: string); +} +declare class ScriptImplementationException extends ScriptException { + constructor(msg: string); +} + +declare class Thumbnails { + constructor(thumbnails: Thumbnail[]) +} +declare class Thumbnail { + constructor(url, quality) { + this.url = url ?? ""; //string + this.quality = quality ?? 0; //integer + } +} + +declare class PlatformID { + constructor(platform: string, id: string, pluginId: string, claimType: int = 0, claimFieldType: integer = -1); +} + +declare class ResultCapabilities { + constructor(types: string[], sorts: string[], filters: FilterGroup[]) +} +declare class FilterGroup { + constructor(name: string, filters: string[], isMultiSelect: boolean, id: string); +} +declare class FilterCapability { + constructor(name: string, value: string, id: string); +} + + +declare class PlatformAuthorLink { + constructor(id: PlatformID, name: string, url: string, thumbnail: string, subscribers: integer?); +} + +declare interface PlatformContentDef { + id: PlatformID, + name: string, + author: PlatformAuthorLink, + datetime: integer, + url: string +} +declare interface PlatformNestedMediaContentDef extends PlatformContentDef { + contentUrl: string, + contentName: string?, + contentDescription: string?, + contentProvider: string?, + contentThumbnails: Thumbnails +} +declare class PlatformNestedMediaContent { + constructor(obj: PlatformNestedMediaContentDef); +} + +declare interface PlatformVideoDef extends PlatformContentDef { + thumbnails: Thumbnails, + author: PlatformAuthorLink, + + duration: int, + viewCount: long, + isLive: boolean +} +declare interface PlatformContent {} + +declare class PlatformVideo implements PlatformContent { + constructor(obj: PlatformVideoDef); +} + + +declare interface PlatformVideoDetailsDef extends PlatformVideoDef { + description: string, + video: VideoSourceDescriptor, + live: SubtitleSource[], + rating: IRating +} +declare class PlatformVideoDetails extends PlatformVideo { + constructor(obj: PlatformVideoDetailsDef); +} + +declare class PlatformPostDef extends PlatformContentDef { + thumbnails: string[], + images: string[], + description: string +} +declare class PlatformPost extends PlatformContent { + constructor(obj: PlatformPostDef) +} + +declare class PlatformPostDetailsDef extends PlatformPostDef { + rating: IRating, + textType: int, + content: String +} +declare class PlatformPostDetails extends PlatformPost { + constructor(obj: PlatformPostDetailsDef); +} + + +//Sources +declare interface IVideoSourceDescriptor {} + +declare interface MuxVideoSourceDescriptorDef { + isUnMuxed: boolean, + videoSources: VideoSource[] +} +declare class MuxVideoSourceDescriptor implements IVideoSourceDescriptor { + constructor(obj: VideoSourceDescriptorDef); +} + +declare interface UnMuxVideoSourceDescriptorDef { + isUnMuxed: boolean, + videoSources: VideoSource[] +} +class UnMuxVideoSourceDescriptor implements IVideoSourceDescriptor { + constructor(videoSourcesOrObj: VideoSource[], audioSources: AudioSource[]); + constructor(videoSourcesOrObj: UnMuxVideoSourceDescriptorDef); +} + +declare interface IVideoSource { + +} +declare interface IAudioSource { + +} +interface VideoUrlSourceDef implements IVideoSource { + width: integer, + height: integer, + container: string, + codec: string, + name: string, + bitrate: integer, + duration: integer, + url: string +} +class VideoUrlSource { + constructor(obj: VideoUrlSourceDef); + + getRequestModifier(): RequestModifier?; +} +interface VideoUrlRangeSourceDef extends VideoUrlSource { + itagId: integer, + initStart: integer, + initEnd: integer, + indexStart: integer, + indexEnd: integer, +} +class VideoUrlRangeSource extends VideoUrlSource { + constructor(obj: YTVideoSourceDef); +} +interface AudioUrlSourceDef { + name: string, + bitrate: integer, + container: string, + codecs: string, + duration: integer, + url: string, + language: string +} +class AudioUrlSource implements IAudioSource { + constructor(obj: AudioUrlSourceDef); + + getRequestModifier(): RequestModifier?; +} +interface IRequest { + url: string, + headers: Map +} +interface IRequestModifierDef { + allowByteSkip: boolean +} +class RequestModifier { + constructor(obj: IRequestModifierDef) { } + + modifyRequest(url: string, headers: Map): IRequest; +} +interface AudioUrlRangeSourceDef extends AudioUrlSource { + itagId: integer, + initStart: integer, + initEnd: integer, + indexStart: integer, + indexEnd: integer, + audioChannels: integer +} +class AudioUrlRangeSource extends AudioUrlSource { + constructor(obj: AudioUrlRangeSourceDef); +} +interface HLSSourceDef { + name: string, + duration: integer, + url: string +} +class HLSSource implements IVideoSource { + constructor(obj: HLSSourceDef); +} +interface DashSourceDef { + name: string, + duration: integer, + url: string +} +class DashSource implements IVideoSource { + constructor(obj: DashSourceDef) +} + +//Channel +interface PlatformChannelDef { + id: PlatformID, + name: string, + thumbnail: string, + banner: string, + subscribers: integer, + description: string, + url: string, + links: Map? +} +class PlatformChannel { + constructor(obj: PlatformChannelDef); +} + +//Ratings +interface IRating { + type: integer +} +declare class RatingLikes implements IRating { + constructor(likes: integer); +} +declare class RatingLikesDislikes implements IRating { + constructor(likes: integer, dislikes: integer); +} +declare class RatingScaler implements IRating { + constructor(value: double); +} + +declare interface CommentDef { + contextUrl: string, + author: PlatformAuthorLink, + message: string, + rating: IRating, + date: long, + replyCount: int, + context: any +} +declare class PlatformComment { + constructor(obj: CommentDef); +} + + + +declare class LiveEventPager { + nextRequest = 4000; + + constructor(results: LiveEvent[], hasMore: boolean, context: any); + + hasMorePagers(): boolean + nextPage(): LiveEventPager; //Could be self +} + +class LiveEvent { + type: String +} +declare class LiveEventComment extends LiveEvent { + constructor(name: string, message: string, thumbnail: string?, colorName: string?, badges: string[]); +} +declare class LiveEventEmojis extends LiveEvent { + constructor(name: Map); +} +declare class LiveEventDonation extends LiveEvent { + constructor(amount: integer, name: string, message: string, thumbnail: string?, expire: Int, colorDonation: string?); +} +declare class LiveEventViewCount extends LiveEvent { + constructor(viewCount: integer); +} +declare class LiveEventRaid extends LiveEvent { + constructor(targetUrl: string, targetName: string, targetThumbnail: string); +} + + + +//Pagers +declare class ContentPager { + constructor(results: PlatformContent[], hasMore: boolean); + + hasMorePagers(): boolean + nextPage(): VideoPager; //Could be self +} +declare class VideoPager { + constructor(results: PlatformVideo[], hasMore: boolean); + + hasMorePagers(): boolean + nextPage(): VideoPager; //Could be self +} +declare class ChannelPager { + constructor(results: PlatformChannel[], hasMore: boolean); + + hasMorePagers(): boolean; + nextPage(): ChannelPager; //Could be self +} +declare class CommentPager { + constructor(results: PlatformComment[], hasMore: boolean); + + hasMorePagers(): boolean + nextPage(): CommentPager; //Could be self +} + +interface Map { + [Key: string]: T; +} + +//To override by plugin + +interface Source { + getHome(): VideoPager; + + enable(config: SourceConfig, settings: Any, savedState: string?); + disable(); + + saveState(): string; + + searchSuggestions(query: string): string[]; + search(query: string, type: string, order: string, filters): ContentPager; + getSearchCapabilities(): ResultCapabilities + + //Optional + searchChannelContents(channelUrl: string, query: string, type: string, order: string, filters): ContentPager; + //Optional + getSearchChannelContentsCapabilities(): ResultCapabilities; + + //Optional + getChannelUrlByClaim(claimType: int, values: Map) + + isChannelUrl(url: string): boolean; + getChannel(url: string): PlatformChannel; + + getChannelContents(url: string, type: string, order: string, filters): ContentPager; + getChannelCapabilities(): ResultCapabilities; + + isContentDetailsUrl(url: string): boolean; + getContentDetails(url: string): PlatformVideoDetails; + + getLiveEvents(url: string): LiveEventPager; + + //Optional + getComments(url: string): CommentPager; + //Optional + getSubComments(comment: PlatformComment): CommentPager; + + //Optional + getUserSubscriptions(): string[]; + //Optional + getUserPlaylists(): string[]; + + //Optional + isPlaylistUrl(url: string): boolean; + //Optional + getPlaylist(url): string[]; +} + +const source: Source; diff --git a/app/src/main/assets/scripts/polyfil.js b/app/src/main/assets/scripts/polyfil.js new file mode 100644 index 00000000..5536c29b --- /dev/null +++ b/app/src/main/assets/scripts/polyfil.js @@ -0,0 +1,177 @@ + +class URL { + constructor(url, base) { + let baseParts; + try { + baseParts = URL.parse(base); + } + catch (e) { + throw new Error('Invalid base URL'); + } + let urlParts = URL.parse(url); + if (urlParts.protocol) { + this._parts = { ...urlParts }; + } + else { + this._parts = { + protocol: baseParts.protocol, + username: baseParts.username, + password: baseParts.password, + hostname: baseParts.hostname, + port: baseParts.port, + path: urlParts.path || baseParts.path, + query: urlParts.query || baseParts.query, + hash: urlParts.hash, + }; + } + } + static init() { + this.URLRegExp = new RegExp('^' + this.patterns.protocol + '?' + this.patterns.authority + '?' + this.patterns.path + this.patterns.query + '?' + this.patterns.hash + '?'); + this.AuthorityRegExp = new RegExp('^' + this.patterns.authentication + '?' + this.patterns.hostname + this.patterns.port + '?$'); + } + static parse(url) { + const urlMatch = this.URLRegExp.exec(url); + if (urlMatch !== null) { + const authorityMatch = urlMatch[2] ? this.AuthorityRegExp.exec(urlMatch[2]) : [null, null, null, null, null]; + if (authorityMatch !== null) { + return { + protocol: urlMatch[1] || '', + username: authorityMatch[1] || '', + password: authorityMatch[2] || '', + hostname: authorityMatch[3] || '', + port: authorityMatch[4] || '', + path: urlMatch[3] || '', + query: urlMatch[4] || '', + hash: urlMatch[5] || '', + }; + } + } + throw new Error('Invalid URL'); + } + get hash() { + return this._parts.hash; + } + set hash(value) { + value = value.toString(); + if (value.length === 0) { + this._parts.hash = ''; + } + else { + if (value.charAt(0) !== '#') + value = '#' + value; + this._parts.hash = encodeURIComponent(value); + } + } + get host() { + return this.hostname + (this.port ? (':' + this.port) : ''); + } + set host(value) { + value = value.toString(); + const url = new URL('http://' + value); + this._parts.hostname = url.hostname; + this._parts.port = url.port; + } + get hostname() { + return this._parts.hostname; + } + set hostname(value) { + value = value.toString(); + this._parts.hostname = encodeURIComponent(value); + } + get href() { + const authentication = (this.username || this.password) ? (this.username + (this.password ? (':' + this.password) : '') + '@') : ''; + return this.protocol + '//' + authentication + this.host + this.pathname + this.search + this.hash; + } + set href(value) { + value = value.toString(); + const url = new URL(value); + this._parts = { ...url._parts }; + } + get origin() { + return this.protocol + '//' + this.host; + } + get password() { + return this._parts.password; + } + set password(value) { + value = value.toString(); + this._parts.password = encodeURIComponent(value); + } + get pathname() { + return this._parts.path ? this._parts.path : '/'; + } + set pathname(value) { + let chunks = value.toString().split('/').map(encodePathSegment); + if (chunks[0]) { + // ensure joined string starts with slash. + chunks.unshift(''); + } + this._parts.path = chunks.join('/'); + } + get port() { + return this._parts.port; + } + set port(value) { + let port = parseInt(value); + if (isNaN(port)) { + this._parts.port = '0'; + } + else { + this._parts.port = Math.max(0, port % (2 ** 16)).toString(); + } + } + get protocol() { + return this._parts.protocol + ':'; + } + set protocol(value) { + value = value.toString(); + if (value.length !== 0) { + if (value.charAt(value.length - 1) === ':') { + value = value.slice(0, -1); + } + this._parts.protocol = encodeURIComponent(value); + } + } + get search() { + return this._parts.query; + } + set search(value) { + value = value.toString(); + if (value.charAt(0) !== '?') + value = '?' + value; + this._parts.query = value; + } + get username() { + return this._parts.username; + } + set username(value) { + value = value.toString(); + this._parts.username = encodeURIComponent(value); + } + get searchParams() { + const searchParams = new URLSearchParams(this.search); + ['append', 'delete', 'set'].forEach((methodName) => { + const method = searchParams[methodName]; + searchParams[methodName] = (...args) => { + method.apply(searchParams, args); + this.search = searchParams.toString(); + }; + }); + return searchParams; + } + toString() { + return this.href; + } +} + +URL.patterns = { + protocol: '(?:([^:/?#]+):)', + authority: '(?://([^/?#]*))', + path: '([^?#]*)', + query: '(\\?[^#]*)', + hash: '(#.*)', + authentication: '(?:([^:]*)(?::([^@]*))?@)', + hostname: '([^:]+)', + port: '(?::(\\d+))', +}; +URL.init(); \ No newline at end of file diff --git a/app/src/main/assets/scripts/source.js b/app/src/main/assets/scripts/source.js new file mode 100644 index 00000000..901da475 --- /dev/null +++ b/app/src/main/assets/scripts/source.js @@ -0,0 +1,691 @@ +var IS_TESTING = false; + +let Type = { + Source: { + Dash: "DASH", + HLS: "HLS", + STATIC: "Static" + }, + Feed: { + Videos: "VIDEOS", + Streams: "STREAMS", + Mixed: "MIXED", + Live: "LIVE" + }, + Order: { + Chronological: "CHRONOLOGICAL" + }, + Date: { + LastHour: "LAST_HOUR", + Today: "TODAY", + LastWeek: "LAST_WEEK", + LastMonth: "LAST_MONTH", + LastYear: "LAST_YEAR" + }, + Duration: { + Short: "SHORT", + Medium: "MEDIUM", + Long: "LONG" + }, + Text: { + RAW: 0, + HTML: 1, + MARKUP: 2 + } +}; + +let Language = { + UNKNOWN: "Unknown", + ARABIC: "Arabic", + SPANISH: "Spanish", + FRENCH: "French", + HINDI: "Hindi", + INDONESIAN: "Indonesian", + KOREAN: "Korean", + PORTBRAZIL: "Portuguese Brazilian", + RUSSIAN: "Russian", + THAI: "Thai", + TURKISH: "Turkish", + VIETNAMESE: "Vietnamese", + ENGLISH: "English" +} + +class ScriptException extends Error { + constructor(type, msg) { + if(arguments.length == 1) { + super(arguments[0]); + this.plugin_type = "ScriptException"; + this.message = arguments[0]; + } + else { + super(msg); + this.plugin_type = type ?? ""; //string + this.msg = msg ?? ""; //string + } + } +} +class UnavailableException extends ScriptException { + constructor(msg) { + super("UnavailableException", msg); + } +} +class AgeException extends ScriptException { + constructor(msg) { + super("AgeException", msg); + } +} +class TimeoutException extends ScriptException { + constructor(msg) { + super(msg); + this.plugin_type = "ScriptTimeoutException"; + } +} +class ScriptImplementationException extends ScriptException { + constructor(msg) { + super(msg); + this.plugin_type = "ScriptImplementationException"; + } +} + +class Thumbnails { + constructor(thumbnails) { + this.sources = thumbnails ?? []; // Thumbnail[] + } +} +class Thumbnail { + constructor(url, quality) { + this.url = url ?? ""; //string + this.quality = quality ?? 0; //integer + } +} + +class PlatformID { + constructor(platform, id, pluginId, claimType, claimFieldType) { + this.platform = platform ?? ""; //string + this.pluginId = pluginId; //string + this.value = id; //string + this.claimType = claimType ?? 0; //int + this.claimFieldType = claimFieldType ?? -1; //int + } +} + +class ResultCapabilities { + constructor(types, sorts, filters) { + this.types = types ?? []; + this.sorts = sorts ?? []; + this.filters = filters ?? []; + } +} +class FilterGroup { + constructor(name, filters, isMultiSelect, id) { + if(!name) throw new ScriptException("No name for filter group"); + if(!filters) throw new ScriptException("No filter provided"); + + this.name = name + this.filters = filters + this.isMultiSelect = isMultiSelect; + this.id = id; + } +} +class FilterCapability { + constructor(name, value, id) { + if(!name) throw new ScriptException("No name for filter"); + if(!value) throw new ScriptException("No filter value"); + + this.name = name; + this.value = value; + this.id = id; + } +} + + +class PlatformAuthorLink { + constructor(id, name, url, thumbnail, subscribers) { + this.id = id ?? PlatformID(); //PlatformID + this.name = name ?? ""; //string + this.url = url ?? ""; //string + this.thumbnail = thumbnail; //string + if(subscribers) + this.subscribers = subscribers; + } +} +class PlatformContent { + constructor(obj, type) { + this.contentType = type; + obj = obj ?? {}; + this.id = obj.id ?? PlatformID(); //PlatformID + this.name = obj.name ?? ""; //string + this.thumbnails = obj.thumbnails; //Thumbnail[] + this.author = obj.author; //PlatformAuthorLink + this.datetime = obj.datetime ?? obj.uploadDate ?? 0; //OffsetDateTime (Long) + this.url = obj.url ?? ""; //String + } +} +class PlatformContentDetails { + constructor(type) { + this.contentType = type; + } +} +class PlatformNestedMediaContent extends PlatformContent { + constructor(obj) { + super(obj, 11); + obj = obj ?? {}; + this.contentUrl = obj.contentUrl ?? ""; + this.contentName = obj.contentName; + this.contentDescription = obj.contentDescription; + this.contentProvider = obj.contentProvider; + this.contentThumbnails = obj.contentThumbnails ?? new Thumbnails(); + } +} +class PlatformVideo extends PlatformContent { + constructor(obj) { + super(obj, 1); + obj = obj ?? {}; + this.plugin_type = "PlatformVideo"; + this.shareUrl = obj.shareUrl; + + this.duration = obj.duration ?? -1; //Long + this.viewCount = obj.viewCount ?? -1; //Long + + this.isLive = obj.isLive ?? false; //Boolean + } +} +class PlatformVideoDetails extends PlatformVideo { + constructor(obj) { + super(obj); + obj = obj ?? {}; + this.plugin_type = "PlatformVideoDetails"; + + this.description = obj.description ?? "";//String + this.video = obj.video ?? {}; //VideoSourceDescriptor + this.dash = obj.dash ?? null; //DashSource + this.hls = obj.hls ?? null; //HLSSource + this.live = obj.live ?? null; //VideoSource + + this.rating = obj.rating ?? null; //IRating + this.subtitles = obj.subtitles ?? []; + } +} + +class PlatformPost extends PlatformContent { + constructor(obj) { + super(obj, 2); + obj = obj ?? {}; + this.plugin_type = "PlatformPost"; + this.thumbnails = obj.thumbnails ?? []; + this.images = obj.images ?? []; + this.description = obj.description ?? ""; + } +} +class PlatformPostDetails extends PlatformPost { + constructor(obj) { + super(obj); + obj = obj ?? {}; + this.plugin_type = "PlatformPostDetails"; + this.rating = obj.rating ?? RatingLikes(-1); + this.textType = obj.textType ?? 0; + this.content = obj.content ?? ""; + } +} + +//Sources +class VideoSourceDescriptor { + constructor(obj) { + obj = obj ?? {}; + this.plugin_type = "MuxVideoSourceDescriptor"; + this.isUnMuxed = false; + + if(obj.constructor === Array) + this.videoSources = obj; + else + this.videoSources = obj.videoSources ?? []; + } +} +class UnMuxVideoSourceDescriptor { + constructor(videoSourcesOrObj, audioSources) { + videoSourcesOrObj = videoSourcesOrObj ?? {}; + this.plugin_type = "UnMuxVideoSourceDescriptor"; + this.isUnMuxed = true; + + if(videoSourcesOrObj.constructor === Array) { + this.videoSources = videoSourcesOrObj; + this.audioSources = audioSources; + } + else { + this.videoSources = videoSourcesOrObj.videoSources ?? []; + this.audioSources = videoSourcesOrObj.audioSources ?? []; + } + } +} + +class VideoUrlSource { + constructor(obj) { + obj = obj ?? {}; + this.plugin_type = "VideoUrlSource"; + this.width = obj.width ?? 0; + this.height = obj.height ?? 0; + this.container = obj.container ?? ""; + this.codec = obj.codec ?? ""; + this.name = obj.name ?? ""; + this.bitrate = obj.bitrate ?? 0; + this.duration = obj.duration ?? 0; + this.url = obj.url; + } +} +class VideoUrlRangeSource extends VideoUrlSource { + constructor(obj) { + super(obj); + this.plugin_type = "VideoUrlRangeSource"; + + this.itagId = obj.itagId ?? null; + this.initStart = obj.initStart ?? null; + this.initEnd = obj.initEnd ?? null; + this.indexStart = obj.indexStart ?? null; + this.indexEnd = obj.indexEnd ?? null; + } +} +class AudioUrlSource { + constructor(obj) { + obj = obj ?? {}; + this.plugin_type = "AudioUrlSource"; + this.name = obj.name ?? ""; + this.bitrate = obj.bitrate ?? 0; + this.container = obj.container ?? ""; + this.codec = obj.codec ?? ""; + this.duration = obj.duration ?? 0; + this.url = obj.url; + this.language = obj.language ?? Language.UNKNOWN; + } +} +class AudioUrlRangeSource extends AudioUrlSource { + constructor(obj) { + super(obj); + this.plugin_type = "AudioUrlRangeSource"; + + this.itagId = obj.itagId ?? null; + this.initStart = obj.initStart ?? null; + this.initEnd = obj.initEnd ?? null; + this.indexStart = obj.indexStart ?? null; + this.indexEnd = obj.indexEnd ?? null; + this.audioChannels = obj.audioChannels ?? 2; + } +} +class HLSSource { + constructor(obj) { + obj = obj ?? {}; + this.plugin_type = "HLSSource"; + this.name = obj.name ?? "HLS"; + this.duration = obj.duration ?? 0; + this.url = obj.url; + this.priority = obj.priority ?? false; + if(obj.language) + this.language = obj.language; + } +} +class DashSource { + constructor(obj) { + obj = obj ?? {}; + this.plugin_type = "DashSource"; + this.name = obj.name ?? "Dash"; + this.duration = obj.duration ?? 0; + this.url = obj.url; + if(obj.language) + this.language = obj.language; + } +} + +class RequestModifier { + constructor(obj) { + obj = obj ?? {}; + this.allowByteSkip = obj.allowByteSkip; + } +} + +//Channel +class PlatformChannel { + constructor(obj) { + obj = obj ?? {}; + this.plugin_type = "PlatformChannel"; + this.id = obj.id ?? ""; //string + this.name = obj.name ?? ""; //string + this.thumbnail = obj.thumbnail; //string + this.banner = obj.banner; //string + this.subscribers = obj.subscribers ?? 0; //integer + this.description = obj.description; //string + this.url = obj.url ?? ""; //string + this.urlAlternatives = obj.urlAlternatives ?? []; + this.links = obj.links ?? { } //Map + } +} + +//Playlist +class PlatformPlaylist extends PlatformContent { + constructor(obj) { + super(obj, 4); + this.plugin_type = "PlatformPlaylist"; + this.videoCount = obj.videoCount ?? 0; + this.thumbnail = obj.thumbnail; + } +} +class PlatformPlaylistDetails extends PlatformPlaylist { + constructor(obj) { + super(obj); + this.plugin_type = "PlatformPlaylistDetails"; + this.contents = obj.contents; + } +} + + +//Ratings +class RatingLikes { + constructor(likes) { + this.type = 1; + this.likes = likes; + } +} +class RatingLikesDislikes { + constructor(likes,dislikes) { + this.type = 2; + this.likes = likes; + this.dislikes = dislikes; + } +} +class RatingScaler { + constructor(value) { + this.type = 3; + this.value = value; + } +} + +class PlatformComment { + constructor(obj) { + this.plugin_type = "Comment"; + this.contextUrl = obj.contextUrl ?? ""; + this.author = obj.author ?? new PlatformAuthorLink(null, "", "", null); + this.message = obj.message ?? ""; + this.rating = obj.rating ?? new RatingLikes(0); + this.date = obj.date ?? 0; + this.replyCount = obj.replyCount ?? 0; + this.context = obj.context ?? {}; + } +} + +//Temporary backwards compat +class Comment extends PlatformComment { + constructor(obj) { + super(obj); + } +} + +class PlaybackTracker { + constructor(interval) { + this.nextRequest = interval ?? 10*1000; + } + setProgress(seconds) { + throw new ScriptImplementationException("Missing required setProgress(seconds) on PlaybackTracker"); + } +} + +class LiveEventPager { + constructor(results, hasMore, context) { + this.plugin_type = "LiveEventPager"; + this.results = results ?? []; + this.hasMore = hasMore ?? false; + this.context = context ?? {}; + this.nextRequest = 4000; + } + + hasMorePagers() { return this.hasMore; } + nextPage() { return new Pager([], false, this.context) } +} + +class LiveEvent { + constructor(type) { + this.type = type; + } +} +class LiveEventComment extends LiveEvent { + constructor(name, message, thumbnail, colorName, badges) { + super(1); + this.name = name; + this.message = message; + this.thumbnail = thumbnail; + this.colorName = colorName; + this.badges = badges; + } +} +class LiveEventEmojis extends LiveEvent { + constructor(emojis) { + super(4); + this.emojis = emojis; + } +} +class LiveEventDonation extends LiveEvent { + constructor(amount, name, message, thumbnail, expire, colorDonation) { + super(5); + this.amount = amount; + this.name = name; + this.message = message ?? ""; + this.thumbnail = thumbnail; + this.expire = expire; + this.colorDonation = colorDonation; + } +} +class LiveEventViewCount extends LiveEvent { + constructor(viewCount) { + super(10); + this.viewCount = viewCount; + } +} +class LiveEventRaid extends LiveEvent { + constructor(targetUrl, targetName, targetThumbnail) { + super(100); + this.targetUrl = targetUrl; + this.targetName = targetName; + this.targetThumbnail = targetThumbnail; + } +} + +//Pagers +class ContentPager { + constructor(results, hasMore, context) { + this.plugin_type = "ContentPager"; + this.results = results ?? []; + this.hasMore = hasMore ?? false; + this.context = context ?? {}; + } + + hasMorePagers() { return this.hasMore; } + nextPage() { return new ContentPager([], false, this.context) } +} +class VideoPager { + constructor(results, hasMore, context) { + this.plugin_type = "VideoPager"; + this.results = results ?? []; + this.hasMore = hasMore ?? false; + this.context = context ?? {}; + } + + hasMorePagers() { return this.hasMore; } + nextPage() { return new VideoPager([], false, this.context) } +} +class ChannelPager { + constructor(results, hasMore, context) { + this.plugin_type = "ChannelPager"; + this.results = results ?? []; + this.hasMore = hasMore ?? false; + this.context = context ?? {}; + } + + hasMorePagers() { return this.hasMore; } + nextPage() { return new Pager([], false, this.context) } +} +class PlaylistPager { + constructor(results, hasMore, context) { + this.plugin_type = "PlaylistPager"; + this.results = results ?? []; + this.hasMore = hasMore ?? false; + this.context = context ?? {}; + } + + hasMorePagers() { return this.hasMore; } + nextPage() { return new Pager([], false, this.context) } +} +class CommentPager { + constructor(results, hasMore, context) { + this.plugin_type = "CommentPager"; + this.results = results ?? []; + this.hasMore = hasMore ?? false; + this.context = context ?? {}; + } + + hasMorePagers() { return this.hasMore; } + nextPage() { return new Pager([], false, this.context) } +} + +function throwException(type, message) { + throw new Error("V8EXCEPTION:" + type + "-" + message); +} + +let plugin = { + config: {}, + settings: {} +}; + +//To override by plugin +const source = { + getHome() { return new ContentPager([], false, {}); }, + + enable(config){ }, + disable() {}, + + searchSuggestions(query){ return []; }, + getSearchCapabilities(){ return { types: [], sorts: [] }; }, + search(query, type, order, filters){ return new ContentPager([], false, {}); }, //TODO + //OPTIONAL getSearchChannelContentsCapabilities(){ return { types: [], sorts: [] }; }, + //OPTIONAL searchChannelContents(channelUrl, query, type, order, filters){ return new Pager([], false, {}); }, //TODO + + isChannelUrl(url){ return false; }, + getChannel(url){ return null; }, + getChannelCapabilities(){ return { types: [], sorts: [] }; }, + getChannelContents(url, type, order, filters) { return new ContentPager([], false, {}); }, + + isContentDetailsUrl(url){ return false; }, + getContentDetails(url){ }, //TODO + + //OPTIONAL getComments(url){ return new Pager([], false, {}); }, //TODO + //OPTIONAL getSubComments(comment){ return new Pager([], false, {}); }, //TODO + + //OPTIONAL getSubscriptionsUser(){ return []; }, + //OPTIONAL getPlaylistsUser(){ return []; } +}; + +function parseSettings(settings) { + if(!settings) + return {}; + let newSettings = {}; + for(let key in settings) { + if(typeof settings[key] == "string") + newSettings[key] = JSON.parse(settings[key]); + else + newSettings[key] = settings[key]; + } + return newSettings; +} + +function log(str) { + if(str) { + console.log(str); + if(typeof str == "string") + bridge.log(str); + else + bridge.log(JSON.stringify(str, null, 4)); + } +} + +function encodePathSegment(segment) { + return encodeURIComponent(segment).replace(/[!'()*]/g, function (c) { + return '%' + c.charCodeAt(0).toString(16); + }); +} + +class URLSearchParams { + constructor(init) { + this._entries = {}; + if (typeof init === 'string') { + if (init !== '') { + init = init.replace(/^\?/, ''); + const attributes = init.split('&'); + let attribute; + for (let i = 0; i < attributes.length; i++) { + attribute = attributes[i].split('='); + this.append(decodeURIComponent(attribute[0]), (attribute.length > 1) ? decodeURIComponent(attribute[1]) : ''); + } + } + } + else if (init instanceof URLSearchParams) { + init.forEach((value, name) => { + this.append(value, name); + }); + } + } + append(name, value) { + value = value.toString(); + if (name in this._entries) { + this._entries[name].push(value); + } + else { + this._entries[name] = [value]; + } + } + delete(name) { + delete this._entries[name]; + } + get(name) { + return (name in this._entries) ? this._entries[name][0] : null; + } + getAll(name) { + return (name in this._entries) ? this._entries[name].slice(0) : []; + } + has(name) { + return (name in this._entries); + } + set(name, value) { + this._entries[name] = [value.toString()]; + } + forEach(callback) { + let entries; + for (let name in this._entries) { + if (this._entries.hasOwnProperty(name)) { + entries = this._entries[name]; + for (let i = 0; i < entries.length; i++) { + callback.call(this, entries[i], name, this); + } + } + } + } + keys() { + const items = []; + this.forEach((value, name) => { items.push(name); }); + return createIterator(items); + } + values() { + const items = []; + this.forEach((value) => { items.push(value); }); + return createIterator(items); + } + entries() { + const items = []; + this.forEach((value, name) => { items.push([value, name]); }); + return createIterator(items); + } + toString() { + let searchString = ''; + this.forEach((value, name) => { + if (searchString.length > 0) + searchString += '&'; + searchString += encodeURIComponent(name) + '=' + encodeURIComponent(value); + }); + return searchString; + } +} diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 00000000..33de5798 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Coroutines.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Coroutines.kt new file mode 100644 index 00000000..cb711565 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Coroutines.kt @@ -0,0 +1,62 @@ +package com.futo.platformplayer + +import android.util.Log +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.selects.select +import java.lang.IllegalArgumentException + +//Syntax sugaring +suspend inline fun Collection>.awaitFirst(): T?{ + val tasks = this; + if (tasks.isEmpty()) { + return null; + } + + var result: T? = null; + select { + tasks.forEach { def -> + def.onAwait { + result = it; + true; + } + } + } + return result; +} +suspend inline fun Collection>.awaitFirstDeferred(): Pair, T> { + if (isEmpty()) { + throw IllegalArgumentException("Cannot be called on empty list"); + } + + return select { + this@awaitFirstDeferred.onEach { deferred -> + deferred.onAwait { result -> + Pair(deferred, result) + } + } + } +} + +suspend inline fun Collection>.awaitFirstNotNullDeferred(): Pair, T>? { + if (isEmpty()) { + return null; + } + + val toAwait = this.toMutableList(); + while(toAwait.isNotEmpty()) { + val result = select { + toAwait.onEach { deferred -> + deferred.onAwait { result -> + Pair(deferred, result) + } + } + } + + if(result.second != null) { + return Pair(result.first, result.second!!); + } + + toAwait.remove(result.first); + } + return null; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt new file mode 100644 index 00000000..f32c86d3 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt @@ -0,0 +1,327 @@ +package com.futo.platformplayer + +import android.text.Html +import android.text.Spanned +import androidx.core.text.HtmlCompat +import org.jsoup.Jsoup +import org.jsoup.nodes.Attributes +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode +import org.jsoup.parser.Tag +import java.text.DecimalFormat +import java.time.OffsetDateTime +import java.time.temporal.ChronoUnit +import kotlin.math.abs +import kotlin.time.toDuration + + +//Long +val countInKilo = 1000; +val countInMillion = countInKilo * 1000; +val countInBillion = countInMillion * 1000; + +fun Long.toHumanNumber(): String { + val v = Math.abs(this); + if(v >= countInBillion) + return "${Math.floor((this / countInBillion).toDouble()).toLong()}B" + if(v >= countInMillion) + return "${"%.2f".format((this.toDouble() / countInMillion)).trim('0').trim('.')}M" + if(v >= countInKilo) + return "${"%.2f".format((this.toDouble() / countInKilo)).trim('0').trim('.')}K" + + return "${this}"; +} + +val decimalDigits2 = DecimalFormat("#.##"); + +val countInKbit = 1000; +val countInMbit = countInKbit * 1000; +val countInGbit = countInMbit * 1000; + +fun Int.toHumanBitrate() = this.toLong().toHumanBitrate(); +fun Long.toHumanBitrate(): String{ + val v = Math.abs(this); + if(v >= countInGbit) + return "${this / countInGbit}gbps"; + else if(v >= countInMbit) + return "${this / countInMbit}mbps"; + else if(v >= countInKbit) + return "${this / countInKbit}kbps"; + + return "${this}bps"; +} +fun Int.toHumanBytesSpeed() = this.toLong().toHumanBytesSpeed(); +fun Long.toHumanBytesSpeed(): String{ + val v = Math.abs(this); + if(v >= countInGbit) + return "${decimalDigits2.format(this / countInGbit.toDouble())}GB/s"; + else if(v >= countInMbit) + return "${decimalDigits2.format(this / countInMbit.toDouble())}MB/s"; + else if(v >= countInKbit) + return "${decimalDigits2.format(this / countInKbit.toDouble())}KB/s"; + + return "${this}B/s"; +} + +fun Int.toHumanBytesSize() = this.toLong().toHumanBytesSize(); +fun Long.toHumanBytesSize(withDecimal: Boolean = true): String{ + val v = Math.abs(this); + if(withDecimal) { + if(v >= countInGbit) + return "${decimalDigits2.format(this / countInGbit.toDouble())}GB"; + else if(v >= countInMbit) + return "${decimalDigits2.format(this / countInMbit.toDouble())}MB"; + else if(v >= countInKbit) + return "${decimalDigits2.format(this / countInKbit.toDouble())}KB"; + + return "${this}B"; + } + else { + if(v >= countInGbit) + return "${(this / countInGbit.toDouble()).toInt()}GB"; + else if(v >= countInMbit) + return "${(this / countInMbit.toDouble()).toInt()}MB"; + else if(v >= countInKbit) + return "${(this / countInKbit.toDouble()).toInt()}KB"; + + return "${this}B"; + } +} + + +//OffestDateTime +val secondsInMinute = 60; +val secondsInHour = secondsInMinute * 60; +val secondsInDay = secondsInHour * 24; +val secondsInWeek = secondsInDay * 7; +val secondsInMonth = secondsInDay * 30; //Roughly +val secondsInYear = secondsInDay * 365; + +fun OffsetDateTime.getNowDiffMiliseconds(): Long { + return ChronoUnit.MILLIS.between(this, OffsetDateTime.now()); +} +fun OffsetDateTime.getNowDiffSeconds(): Long { + return ChronoUnit.SECONDS.between(this, OffsetDateTime.now()); +} +fun OffsetDateTime.getNowDiffMinutes(): Long { + return ChronoUnit.MINUTES.between(this, OffsetDateTime.now()); +} +fun OffsetDateTime.getNowDiffHours(): Long { + return ChronoUnit.HOURS.between(this, OffsetDateTime.now()); +} +fun OffsetDateTime.getNowDiffDays(): Long { + return ChronoUnit.DAYS.between(this, OffsetDateTime.now()); +} +fun OffsetDateTime.getNowDiffWeeks(): Long { + return ChronoUnit.WEEKS.between(this, OffsetDateTime.now()); +} +fun OffsetDateTime.getNowDiffMonths(): Long { + return ChronoUnit.MONTHS.between(this, OffsetDateTime.now()); +} +fun OffsetDateTime.getNowDiffYears(): Long { + return ChronoUnit.YEARS.between(this, OffsetDateTime.now()); +} + +fun OffsetDateTime.getDiffDays(otherDate: OffsetDateTime): Long { + return ChronoUnit.WEEKS.between(this, otherDate); +} + +fun OffsetDateTime.toHumanNowDiffStringMinDay(abs: Boolean = false) : String { + var value = getNowDiffSeconds(); + + if(abs) value = abs(value); + if (value >= 2 * secondsInDay) { + return "${toHumanNowDiffString(abs)} ago"; + } + + if (value >= 1 * secondsInDay) { + return "Yesterday"; + } + + return "Today"; +}; + +fun OffsetDateTime.toHumanNowDiffString(abs: Boolean = false) : String { + var value = getNowDiffSeconds(); + + var unit = "second"; + + if(abs) value = abs(value); + if(value >= secondsInYear) { + value = getNowDiffYears(); + if(abs) value = abs(value); + unit = "year"; + } + else if(value >= secondsInMonth) { + value = getNowDiffMonths(); + if(abs) value = abs(value); + value = Math.max(1, value); + unit = "month"; + } + else if(value >= secondsInWeek) { + value = getNowDiffWeeks(); + if(abs) value = abs(value); + unit = "week"; + } + else if(value >= secondsInDay) { + value = getNowDiffDays(); + if(abs) value = abs(value); + unit = "day"; + } + else if(value >= secondsInHour) { + value = getNowDiffHours(); + if(abs) value = abs(value); + unit = "hour"; + } + else if(value >= secondsInMinute) { + value = getNowDiffMinutes(); + if(abs) value = abs(value); + unit = "minute"; + } + + if(value != 1L) + unit += "s"; + + return "${value} ${unit}"; +}; + +fun Long.toHumanTime(isMs: Boolean): String { + var scaler = 1; + if(isMs) + scaler = 1000; + val v = Math.abs(this); + val hours = Math.max(v/(secondsInHour*scaler), 0); + val mins = Math.max((v % (secondsInHour*scaler)) / (secondsInMinute * scaler), 0); + val minsStr = mins.toString(); + val seconds = Math.max(((v % (secondsInHour*scaler)) % (secondsInMinute * scaler))/scaler, 0); + val secsStr = seconds.toString().padStart(2, '0'); + val prefix = if (this < 0) { "-" } else { "" }; + + if(hours > 0) + return "${prefix}${hours}:${minsStr.padStart(2, '0')}:${secsStr}" + else + return "${prefix}${minsStr}:${secsStr}" +} + +//TODO: Determine if below stuff should have its own proper class, seems a bit too complex for a utility method +fun String.fixHtmlWhitespace(): Spanned { + return Html.fromHtml(replace("\n", "
"), HtmlCompat.FROM_HTML_MODE_LEGACY); +} + +fun String.fixHtmlLinks(): Spanned { + //TODO: Properly fix whitespace handling. + val doc = Jsoup.parse(replace("\n", "
")); + for (n in doc.body().childNodes()) { + replaceLinks(n); + } + for (n in doc.body().childNodes()) { + replaceTimestamps(n); + } + + val modifiedDoc = doc.body().toString(); + return HtmlCompat.fromHtml(modifiedDoc, HtmlCompat.FROM_HTML_MODE_LEGACY); +} + +val timestampRegex = Regex("\\d+:\\d+(?::\\d+)?"); +private val urlRegex = Regex("https?://\\S+"); +private val linkTag = Tag.valueOf("a"); +private fun replaceTimestamps(node: Node) { + for (n in node.childNodes()) { + replaceTimestamps(n); + } + + if (node is TextNode) { + val text = node.text(); + var lastOffset = 0; + var lastNode = node; + + val matches = timestampRegex.findAll(text).toList(); + for (i in matches.indices) { + val match = matches[i]; + + val textBeforeNode = TextNode(text.substring(lastOffset, match.range.first)); + lastNode.after(textBeforeNode); + lastNode = textBeforeNode; + + val attributes = Attributes(); + attributes.add("href", match.value); + val linkNode = Element(linkTag, null, attributes); + linkNode.text(match.value); + lastNode.after(linkNode); + lastNode = linkNode; + + lastOffset = match.range.last + 1; + } + + if (lastOffset > 0) { + if (lastOffset < text.length) { + lastNode.after(TextNode(text.substring(lastOffset))); + } + + node.remove(); + } + } +} +private fun replaceLinks(node: Node) { + for (n in node.childNodes()) { + replaceLinks(n); + } + + if (node is Element && node.tag() == linkTag) { + node.text(node.text().trim()); + } + + if (node is TextNode) { + val text = node.text(); + var lastOffset = 0; + var lastNode = node; + + val matches = urlRegex.findAll(text).toList(); + for (i in matches.indices) { + val match = matches[i]; + + val textBeforeNode = TextNode(text.substring(lastOffset, match.range.first)); + lastNode.after(textBeforeNode); + lastNode = textBeforeNode; + + val attributes = Attributes(); + attributes.add("href", match.value); + val linkNode = Element(linkTag, null, attributes); + linkNode.text(match.value); + lastNode.after(linkNode); + lastNode = linkNode; + + lastOffset = match.range.last + 1; + } + + if (lastOffset > 0) { + if (lastOffset < text.length) { + lastNode.after(TextNode(text.substring(lastOffset))); + } + + node.remove(); + } + } +} + +fun ByteArray.toHexString(): String { + return this.joinToString("") { "%02x".format(it) } +} + +fun ByteArray.toHexString(size: Int): String { + return this.sliceArray(IntRange(0, size)).toHexString(); +} + +private val safeCharacters = HashSet(('a'..'z') + ('A'..'Z') + ('0'..'9') + listOf('-', '_')); +fun String.toSafeFileName(): String { + return this.map { if (it in safeCharacters) it else '_' }.joinToString(separator = "") +} + +fun String.matchesDomain(queryDomain: String): Boolean { + if(queryDomain.startsWith(".")) + //TODO: Should be safe, but double verify if can't be exploited + return this.endsWith(queryDomain) || this == queryDomain.trimStart('.') + else + return this == queryDomain; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt new file mode 100644 index 00000000..f2310bfe --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt @@ -0,0 +1,275 @@ +package com.futo.platformplayer + +import com.google.common.base.CharMatcher +import java.net.Inet4Address +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import java.nio.ByteBuffer + + +private const val IPV4_PART_COUNT = 4; +private const val IPV6_PART_COUNT = 8; +private const val IPV4_DELIMITER = '.'; +private const val IPV6_DELIMITER = ':'; +private val IPV4_DELIMITER_MATCHER = CharMatcher.`is`(IPV4_DELIMITER); +private val IPV6_DELIMITER_MATCHER = CharMatcher.`is`(IPV6_DELIMITER); +private val LOOPBACK4: Inet4Address? = "127.0.0.1".toInetAddress() as Inet4Address?; +private val ANY4: Inet4Address? = "0.0.0.0".toInetAddress() as Inet4Address?; + +fun String.toInetAddress(): InetAddress? { + val addr = ipStringToBytes(this) ?: return null; + return addr.toInetAddress(); +} + +private fun ipStringToBytes(ipStringParam: String): ByteArray? { + var ipString: String? = ipStringParam; + var hasColon = false; + var hasDot = false; + var percentIndex = -1; + + for (i in 0 until ipString!!.length) { + val c = ipString[i]; + if (c == '.') { + hasDot = true; + } else if (c == ':') { + if (hasDot) { + return null; + } + + hasColon = true; + } else if (c == '%') { + percentIndex = i; + break; + } else if (c.digitToIntOrNull(16) ?: -1 == -1) { + return null; + } + } + + // Now decide which address family to parse. + if (hasColon) { + if (hasDot) { + ipString = convertDottedQuadToHex(ipString) + if (ipString == null) { + return null; + } + } + if (percentIndex != -1) { + ipString = ipString.substring(0, percentIndex); + } + return textToNumericFormatV6(ipString); + } else if (hasDot) { + return if (percentIndex != -1) { + null // Scope IDs are not supported for IPV4 + } else textToNumericFormatV4(ipString); + } + return null +} + +private fun textToNumericFormatV4(ipString: String): ByteArray? { + if (IPV4_DELIMITER_MATCHER.countIn(ipString) + 1 != IPV4_PART_COUNT) { + return null; // Wrong number of parts + } + val bytes = ByteArray(IPV4_PART_COUNT); + var start = 0; + // Iterate through the parts of the ip string. + // Invariant: start is always the beginning of an octet. + for (i in 0 until IPV4_PART_COUNT) { + var end = ipString.indexOf(IPV4_DELIMITER, start); + if (end == -1) { + end = ipString.length; + } + try { + bytes[i] = parseOctet(ipString, start, end); + } catch (ex: java.lang.NumberFormatException) { + return null; + } + start = end + 1; + } + return bytes; +} + +private fun textToNumericFormatV6(ipString: String): ByteArray? { + // An address can have [2..8] colons. + val delimiterCount: Int = IPV6_DELIMITER_MATCHER.countIn(ipString); + if (delimiterCount < 2 || delimiterCount > IPV6_PART_COUNT) { + return null; + } + var partsSkipped: Int = IPV6_PART_COUNT - (delimiterCount + 1); // estimate; may be modified later + var hasSkip = false; + // Scan for the appearance of ::, to mark a skip-format IPV6 string and adjust the partsSkipped + // estimate. + for (i in 0 until ipString.length - 1) { + if (ipString[i] == IPV6_DELIMITER && ipString[i + 1] == IPV6_DELIMITER) { + if (hasSkip) { + return null; // Can't have more than one :: + } + hasSkip = true; + partsSkipped++; // :: means we skipped an extra part in between the two delimiters. + if (i == 0) { + partsSkipped++; // Begins with ::, so we skipped the part preceding the first : + } + if (i == ipString.length - 2) { + partsSkipped++; // Ends with ::, so we skipped the part after the last : + } + } + } + if (ipString[0] == IPV6_DELIMITER && ipString[1] != IPV6_DELIMITER) { + return null; // ^: requires ^:: + } + if (ipString[ipString.length - 1] == IPV6_DELIMITER && ipString[ipString.length - 2] != IPV6_DELIMITER) { + return null; // :$ requires ::$ + } + if (hasSkip && partsSkipped <= 0) { + return null // :: must expand to at least one '0' + } + if (!hasSkip && delimiterCount + 1 != IPV6_PART_COUNT) { + return null // Incorrect number of parts + } + val rawBytes: ByteBuffer = ByteBuffer.allocate(2 * IPV6_PART_COUNT) + try { + // Iterate through the parts of the ip string. + // Invariant: start is always the beginning of a hextet, or the second ':' of the skip + // sequence "::" + var start = 0 + if (ipString[0] == IPV6_DELIMITER) { + start = 1 + } + while (start < ipString.length) { + var end: Int = ipString.indexOf(IPV6_DELIMITER, start) + if (end == -1) { + end = ipString.length + } + if (ipString[start] == IPV6_DELIMITER) { + // expand zeroes + for (i in 0 until partsSkipped) { + rawBytes.putShort(0.toShort()) + } + } else { + rawBytes.putShort(parseHextet(ipString, start, end)) + } + start = end + 1 + } + } catch (ex: NumberFormatException) { + return null + } + return rawBytes.array() +} + +private fun parseHextet(ipString: String, start: Int, end: Int): Short { + // Note: we already verified that this string contains only hex digits. + val length = end - start + if (length <= 0 || length > 4) { + throw java.lang.NumberFormatException() + } + var hextet = 0 + for (i in start until end) { + hextet = hextet shl 4 + hextet = hextet or ipString[i].digitToIntOrNull(16)!! ?: -1 + } + return hextet.toShort() +} + +private fun parseOctet(ipString: String, start: Int, end: Int): Byte { + // Note: we already verified that this string contains only hex digits, but the string may still + // contain non-decimal characters. + val length = end - start + if (length <= 0 || length > 3) { + throw java.lang.NumberFormatException() + } + // Disallow leading zeroes, because no clear standard exists on + // whether these should be interpreted as decimal or octal. + if (length > 1 && ipString[start] == '0') { + throw java.lang.NumberFormatException() + } + var octet = 0 + for (i in start until end) { + octet *= 10 + val digit = ipString[i].digitToIntOrNull() ?: -1 + if (digit < 0) { + throw java.lang.NumberFormatException() + } + octet += digit + } + if (octet > 255) { + throw java.lang.NumberFormatException() + } + return octet.toByte() +} + +fun convertDottedQuadToHex(ipString: String): String? { + val lastColon = ipString.lastIndexOf(':'); + val initialPart = ipString.substring(0, lastColon + 1); + val dottedQuad = ipString.substring(lastColon + 1); + val quad: ByteArray = textToNumericFormatV4(dottedQuad) ?: return null; + val penultimate = Integer.toHexString(quad[0].toInt() and 0xff shl 8 or (quad[1].toInt() and 0xff)); + val ultimate = Integer.toHexString(quad[2].toInt() and 0xff shl 8 or (quad[3].toInt() and 0xff)); + return "$initialPart$penultimate:$ultimate"; +} + +private fun ByteArray.toInetAddress(): InetAddress { + return InetAddress.getByAddress(this); +} + +fun getConnectedSocket(addresses: List, port: Int): Socket? { + if (addresses.isEmpty()) { + return null; + } + + if (addresses.size == 1) { + try { + return Socket(addresses[0], port); + } catch (e: Throwable) { + //Ignored. + } + + return null; + } + + val sockets: ArrayList = arrayListOf(); + for (i in addresses.indices) { + sockets.add(Socket()); + } + + val syncObject = Object(); + var connectedSocket: Socket? = null; + val threads: ArrayList = arrayListOf(); + for (i in 0 until sockets.size) { + val address = addresses[i]; + val socket = sockets[i]; + val thread = Thread { + try { + synchronized(syncObject) { + if (connectedSocket != null) { + return@Thread; + } + } + + socket.connect(InetSocketAddress(address, port)); + + synchronized(syncObject) { + if (connectedSocket == null) { + connectedSocket = socket; + + for (j in 0 until sockets.size) { + if (i != j) { + sockets[j].close(); + } + } + } + } + } catch (e: Throwable) { + //Ignore + } + }; + + thread.start(); + threads.add(thread); + } + + for (thread in threads) { + thread.join(); + } + + return connectedSocket; +} diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Polycentric.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Polycentric.kt new file mode 100644 index 00000000..6d6c076e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Polycentric.kt @@ -0,0 +1,38 @@ +package com.futo.platformplayer + +import com.futo.platformplayer.states.StatePlatform +import userpackage.Protocol +import kotlin.math.abs +import kotlin.math.min + +fun Protocol.ImageBundle?.selectBestImage(desiredPixelCount: Int): Protocol.ImageManifest? { + if (this == null) { + return null + } + + val maximumFileSize = min(10 * desiredPixelCount, 5 * 1024 * 1024) + return imageManifestsList.mapNotNull { if (it.byteCount > maximumFileSize) null else it } + .minByOrNull { abs(it.width * it.height - desiredPixelCount) } +} + +fun Protocol.ImageBundle?.selectLowestResolutionImage(): Protocol.ImageManifest? { + if (this == null) { + return null + } + + val maximumFileSize = 5 * 1024 * 1024; + return imageManifestsList.filter { it.byteCount < maximumFileSize }.minByOrNull { abs(it.width * it.height) } +} + +fun Protocol.ImageBundle?.selectHighestResolutionImage(): Protocol.ImageManifest? { + if (this == null) { + return null + } + + val maximumFileSize = 5 * 1024 * 1024; + return imageManifestsList.filter { it.byteCount < maximumFileSize }.maxByOrNull { abs(it.width * it.height) } +} + +fun Protocol.Claim.resolveChannelUrl(): String? { + return StatePlatform.instance.resolveChannelUrlByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) }) +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt new file mode 100644 index 00000000..41d1822e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt @@ -0,0 +1,15 @@ +package com.futo.platformplayer + +//Syntax sugaring +inline fun Any.assume(): T?{ + if(this is T) + return this; + else + return null; +} +inline fun Any.assume(cb: (T) -> R): R? { + val result = this.assume(); + if(result != null) + return cb(result); + return null; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt b/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt new file mode 100644 index 00000000..e1b643a4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt @@ -0,0 +1,131 @@ +package com.futo.platformplayer + +import com.caoccao.javet.values.V8Value +import com.caoccao.javet.values.primitive.* +import com.caoccao.javet.values.reference.V8ValueArray +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.engine.exceptions.ScriptImplementationException + + +//V8 +fun V8Value?.orNull(handler: (V8Value)->R) : R? { + if(this == null) + return null; + if(this is V8ValueNull || this is V8ValueUndefined || (this is V8ValueDouble && this.isNaN)) + return null; + else + return handler(this); +} +fun V8Value?.orDefault(default: R, handler: (V8Value)->R): R { + if(this == null || this is V8ValueNull || this is V8ValueUndefined) + return default; + else + return handler(this); +} + +inline fun V8Value.expectOrThrow(config: IV8PluginConfig, contextName: String): T { + if(this !is T) + throw ScriptImplementationException(config, "Expected ${contextName} to be of type ${T::class.simpleName}, but found ${this::class.simpleName}"); + return this as T; +} + +//Singles +inline fun V8ValueObject.getOrThrowNullable(config: IV8PluginConfig, key: String, contextName: String): T? = getOrThrow(config, key, contextName, true); +inline fun V8ValueObject.getOrThrow(config: IV8PluginConfig, key: String, contextName: String, nullable: Boolean = false): T { + val value = this.get(key); + if(nullable) + return value.orNull { value.expectV8Variant(config, "${contextName}.${key}") } as T + else + return value.expectV8Variant(config, "${contextName}.${key}"); +} +inline fun V8ValueObject.getOrNull(config: IV8PluginConfig, key: String, contextName: String): T? { + val value = this.get(key); + return value.orNull { value.expectV8Variant(config, "${contextName}.${key}") }; +} +inline fun V8ValueObject.getOrDefault(config: IV8PluginConfig, key: String, contextName: String, default: T?): T? { + val value = this.get(key); + return value.orNull { value.expectV8Variant(config, "${contextName}.${key}") } ?: default; +} + +//Lists + +inline fun V8ValueObject.getOrThrowNullableList(config: IV8PluginConfig, key: String, contextName: String): List? = getOrThrowList(config, key, contextName, true); +inline fun V8ValueObject.getOrThrowList(config: IV8PluginConfig, key: String, contextName: String, nullable: Boolean = false): List { + val value = this.get(key); + val array = if(nullable) + value.orNull { value.expectV8Variant(config, "${contextName}.${key}") } + else + value.expectV8Variant(config, "${contextName}.${key}"); + if(array == null) + return listOf(); + + return array.expectV8Variants(config, contextName, false); +} +inline fun V8ValueObject.getOrNullList(config: IV8PluginConfig, key: String, contextName: String): List? { + val value = this.get(key); + val array = value.orNull { value.expectV8Variant(config, "${contextName}.${key}") } + ?: return null; + + return array.expectV8Variants(config, contextName, true); +} +inline fun V8ValueObject.getOrDefaultList(config: IV8PluginConfig, key: String, contextName: String, default: List?): List? { + val value = this.get(key); + val array = value.orNull { value.expectV8Variant(config, "${contextName}.${key}") } + ?: return default; + + return array.expectV8Variants(config, contextName, true); +} + +inline fun V8ValueArray.expectV8Variants(config: IV8PluginConfig, contextName: String, nullable: Boolean): List { + val array = this; + if(nullable) + return array.keys + .map { Pair(it, array.get(it)) } + .map { kv-> kv.second.orNull { it.expectV8Variant(config, contextName + "[${kv.first}]", ) } as T }; + else + return array.keys + .map { Pair(it, array.get(it)) } + .map { kv-> kv.second.orNull { it.expectV8Variant(config, contextName + "[${kv.first}]", ) } as T }; +} + +inline fun V8Value.expectV8Variant(config: IV8PluginConfig, contextName: String): T { + return when(T::class) { + String::class -> this.expectOrThrow(config, contextName).value as T; + Int::class -> { + if(this is V8ValueDouble) + return this.value.toInt() as T; + else if(this is V8ValueInteger) + return this.value.toInt() as T; + else if(this is V8ValueLong) + return this.value.toInt() as T; + else this.expectOrThrow(config, contextName).value as T + }; + Long::class -> { + if(this is V8ValueDouble) + return this.value.toLong() as T; + else if(this is V8ValueInteger) + return this.value.toLong() as T; + else + return this.expectOrThrow(config, contextName).value.toLong() as T + }; + V8ValueObject::class -> this.expectOrThrow(config, contextName) as T + V8ValueArray::class -> this.expectOrThrow(config, contextName) as T; + Boolean::class -> this.expectOrThrow(config, contextName).value as T; + Float::class -> this.expectOrThrow(config, contextName).value.toFloat() as T; + Double::class -> this.expectOrThrow(config, contextName).value as T; + HashMap::class -> this.expectOrThrow(config, contextName).let { V8ObjectToHashMap(it) } as T; + Map::class -> this.expectOrThrow(config, contextName).let { V8ObjectToHashMap(it) } as T; + List::class -> this.expectOrThrow(config, contextName).let { V8ArrayToStringList(it) } as T; + else -> throw NotImplementedError("Type ${T::class.simpleName} not implemented conversion"); + } +} +fun V8ArrayToStringList(obj: V8ValueArray): List = obj.keys.map { obj.getString(it) }; +fun V8ObjectToHashMap(obj: V8ValueObject?): HashMap { + if(obj == null) + return hashMapOf(); + val map = hashMapOf(); + for(prop in obj.ownPropertyNames.keys.map { obj.ownPropertyNames.get(it).toString() }) + map.put(prop, obj.getString(prop)); + return map; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt new file mode 100644 index 00000000..ea4e8384 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -0,0 +1,606 @@ +package com.futo.platformplayer + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.webkit.CookieManager +import androidx.lifecycle.lifecycleScope +import com.futo.platformplayer.activities.* +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.serializers.FlexibleBooleanSerializer +import com.futo.platformplayer.serializers.OffsetDateTimeSerializer +import com.futo.platformplayer.states.* +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.FragmentedStorageFileJson +import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.fields.DropdownFieldOptionsId +import com.futo.platformplayer.views.fields.FormField +import com.futo.platformplayer.views.fields.FieldForm +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.* +import kotlinx.serialization.json.* +import java.io.File +import java.time.OffsetDateTime + +@Serializable +data class MenuBottomBarSetting(val id: Int, var enabled: Boolean); + +@Serializable() +class Settings : FragmentedStorageFileJson() { + var didFirstStart: Boolean = false; + + @Serializable + val tabs: MutableList = MenuBottomBarFragment.buttonDefinitions.map { MenuBottomBarSetting(it.id, true) }.toMutableList() + + @Transient + val onTabsChanged = Event0(); + + @FormField( + "Manage Polycentric identity", FieldForm.BUTTON, + "Manage your Polycentric identity", -2 + ) + fun managePolycentricIdentity() { + SettingsActivity.getActivity()?.let { + if (StatePolycentric.instance.processHandle != null) { + it.startActivity(Intent(it, PolycentricProfileActivity::class.java)); + } else { + it.startActivity(Intent(it, PolycentricHomeActivity::class.java)); + } + } + } + + @FormField( + "Submit feedback", FieldForm.BUTTON, + "Give feedback on the application", -1 + ) + fun submitFeedback() { + try { + val i = Intent(Intent.ACTION_VIEW); + val subject = "Feedback Grayjay"; + val body = "Hey,\n\nI have some feedback on the Grayjay app.\nVersion information (version_name = ${BuildConfig.VERSION_NAME}, version_code = ${BuildConfig.VERSION_CODE}, flavor = ${BuildConfig.FLAVOR}, build_type = ${BuildConfig.BUILD_TYPE}})\n\n"; + val data = Uri.parse("mailto:grayjay@futo.org?subject=" + Uri.encode(subject) + "&body=" + Uri.encode(body)); + i.data = data; + + StateApp.withContext { it.startActivity(i); }; + } catch (e: Throwable) { + //Ignored + } + } + + @FormField( + "Manage Tabs", FieldForm.BUTTON, + "Change tabs visible on the home screen", -1 + ) + fun manageTabs() { + try { + SettingsActivity.getActivity()?.let { + it.startActivity(Intent(it, ManageTabsActivity::class.java)); + } + } catch (e: Throwable) { + //Ignored + } + } + + @FormField("Home", "group", "Configure how your Home tab works and feels", 1) + var home = HomeSettings(); + @Serializable + class HomeSettings { + @FormField("Feed Style", FieldForm.DROPDOWN, "", 5) + @DropdownFieldOptionsId(R.array.feed_style) + var homeFeedStyle: Int = 1; + + fun getHomeFeedStyle(): FeedStyle { + if(homeFeedStyle == 0) + return FeedStyle.PREVIEW; + else + return FeedStyle.THUMBNAIL; + } + } + + @FormField("Search", "group", "", 2) + var search = SearchSettings(); + @Serializable + class SearchSettings { + @FormField("Search History", FieldForm.TOGGLE, "", 4) + @Serializable(with = FlexibleBooleanSerializer::class) + var searchHistory: Boolean = true; + + + @FormField("Feed Style", FieldForm.DROPDOWN, "", 5) + @DropdownFieldOptionsId(R.array.feed_style) + var searchFeedStyle: Int = 1; + + + fun getSearchFeedStyle(): FeedStyle { + if(searchFeedStyle == 0) + return FeedStyle.PREVIEW; + else + return FeedStyle.THUMBNAIL; + } + } + + @FormField("Subscriptions", "group", "Configure how your Subscriptions works and feels", 3) + var subscriptions = SubscriptionsSettings(); + @Serializable + class SubscriptionsSettings { + @FormField("Feed Style", FieldForm.DROPDOWN, "", 5) + @DropdownFieldOptionsId(R.array.feed_style) + var subscriptionsFeedStyle: Int = 1; + + fun getSubscriptionsFeedStyle(): FeedStyle { + if(subscriptionsFeedStyle == 0) + return FeedStyle.PREVIEW; + else + return FeedStyle.THUMBNAIL; + } + + @FormField("Background Update", FieldForm.DROPDOWN, "Experimental background update for subscriptions cache (requires restart)", 6) + @DropdownFieldOptionsId(R.array.background_interval) + var subscriptionsBackgroundUpdateInterval: Int = 0; + + fun getSubscriptionsBackgroundIntervalMinutes(): Int = when(subscriptionsBackgroundUpdateInterval) { + 0 -> 0; + 1 -> 15; + 2 -> 60; + 3 -> 60 * 3; + 4 -> 60 * 6; + 5 -> 60 * 12; + 6 -> 60 * 24; + else -> 0 + }; + + + @FormField("Subscription Concurrency", FieldForm.DROPDOWN, "Specify how many threads are used to fetch channels (requires restart)", 7) + @DropdownFieldOptionsId(R.array.thread_count) + var subscriptionConcurrency: Int = 3; + + fun getSubscriptionsConcurrency() : Int { + return threadIndexToCount(subscriptionConcurrency); + } + } + + @FormField("Player", "group", "Change behavior of the player", 4) + var playback = PlaybackSettings(); + @Serializable + class PlaybackSettings { + @FormField("Primary Language", FieldForm.DROPDOWN, "", 0) + @DropdownFieldOptionsId(R.array.languages) + var primaryLanguage: Int = 0; + + fun getPrimaryLanguage(context: Context) = context.resources.getStringArray(R.array.languages)[primaryLanguage]; + + @FormField("Default Playback Speed", FieldForm.DROPDOWN, "", 1) + @DropdownFieldOptionsId(R.array.playback_speeds) + var defaultPlaybackSpeed: Int = 3; + fun getDefaultPlaybackSpeed(): Float = when(defaultPlaybackSpeed) { + 0 -> 0.25f; + 1 -> 0.5f; + 2 -> 0.75f; + 3 -> 1.0f; + 4 -> 1.25f; + 5 -> 1.5f; + 6 -> 1.75f; + 7 -> 2.0f; + 8 -> 2.25f; + else -> 1.0f; + }; + + @FormField("Preferred Quality", FieldForm.DROPDOWN, "", 2) + @DropdownFieldOptionsId(R.array.preferred_quality_array) + var preferredQuality: Int = 0; + + @FormField("Preferred Metered Quality", FieldForm.DROPDOWN, "", 2) + @DropdownFieldOptionsId(R.array.preferred_quality_array) + var preferredMeteredQuality: Int = 0; + fun getPreferredQualityPixelCount(): Int = preferedQualityToPixels(preferredQuality); + fun getPreferredMeteredQualityPixelCount(): Int = preferedQualityToPixels(preferredMeteredQuality); + fun getCurrentPreferredQualityPixelCount(): Int = if(!StateApp.instance.isCurrentMetered()) getPreferredQualityPixelCount() else getPreferredMeteredQualityPixelCount(); + + @FormField("Preferred Preview Quality", FieldForm.DROPDOWN, "", 3) + @DropdownFieldOptionsId(R.array.preferred_quality_array) + var preferredPreviewQuality: Int = 5; + fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality); + + @FormField("Auto-Rotate", FieldForm.DROPDOWN, "", 4) + @DropdownFieldOptionsId(R.array.system_enabled_disabled_array) + var autoRotate: Int = 2; + + fun isAutoRotate() = autoRotate == 1 || (autoRotate == 2 && StateApp.instance.getCurrentSystemAutoRotate()); + + @FormField("Background Behavior", FieldForm.DROPDOWN, "", 5) + @DropdownFieldOptionsId(R.array.player_background_behavior) + var backgroundPlay: Int = 2; + + fun isBackgroundContinue() = backgroundPlay == 1; + fun isBackgroundPictureInPicture() = backgroundPlay == 2; + + @FormField("Resume After Preview", FieldForm.DROPDOWN, "When watching a video in preview mode, resume at the position when opening the video", 4) + @DropdownFieldOptionsId(R.array.resume_after_preview) + var resumeAfterPreview: Int = 1; + + + @FormField("Live Chat Webview", FieldForm.TOGGLE, "Use the live chat web window when available over native implementation.", 5) + var useLiveChatWindow: Boolean = true; + + fun shouldResumePreview(previewedPosition: Long): Boolean{ + if(resumeAfterPreview == 2) + return true; + if(resumeAfterPreview == 1 && previewedPosition > 10) + return true; + return false; + } + } + + @FormField("Downloads", "group", "Configure downloading of videos", 5) + var downloads = Downloads(); + @Serializable + class Downloads { + + @FormField("Download when", FieldForm.DROPDOWN, "Configure when videos should be downloaded", 0) + @DropdownFieldOptionsId(R.array.when_download) + var whenDownload: Int = 0; + + fun shouldDownload(): Boolean { + return when (whenDownload) { + 0 -> !StateApp.instance.isCurrentMetered(); + 1 -> StateApp.instance.isNetworkState(StateApp.NetworkState.WIFI, StateApp.NetworkState.ETHERNET); + 2 -> true; + else -> false; + } + } + + @FormField("Default Video Quality", FieldForm.DROPDOWN, "", 2) + @DropdownFieldOptionsId(R.array.preferred_video_download) + var preferredVideoQuality: Int = 4; + fun getDefaultVideoQualityPixels(): Int = preferedQualityToPixels(preferredVideoQuality); + + @FormField("Default Audio Quality", FieldForm.DROPDOWN, "", 3) + @DropdownFieldOptionsId(R.array.preferred_audio_download) + var preferredAudioQuality: Int = 1; + fun isHighBitrateDefault(): Boolean = preferredAudioQuality > 0; + + @FormField("ByteRange Download", FieldForm.TOGGLE, "Attempt to utilize byte ranges, this can be combined with concurrency to bypass throttling", 4) + @Serializable(with = FlexibleBooleanSerializer::class) + var byteRangeDownload: Boolean = true; + + @FormField("ByteRange Concurrency", FieldForm.DROPDOWN, "Number of concurrent threads to multiply download speeds from throttled sources", 5) + @DropdownFieldOptionsId(R.array.thread_count) + var byteRangeConcurrency: Int = 3; + fun getByteRangeThreadCount(): Int { + return threadIndexToCount(byteRangeConcurrency); + } + } + + @FormField("Browsing", "group", "Configure browsing behavior", 6) + var browsing = Browsing(); + @Serializable + class Browsing { + @FormField("Enable Video Cache", FieldForm.TOGGLE, "A cache to quickly load previously fetched videos", 0) + @Serializable(with = FlexibleBooleanSerializer::class) + var videoCache: Boolean = true; + } + + @FormField("Casting", "group", "Configure casting", 7) + var casting = Casting(); + @Serializable + class Casting { + @FormField("Enabled", FieldForm.TOGGLE, "Enable casting", 0) + @Serializable(with = FlexibleBooleanSerializer::class) + var enabled: Boolean = true; + + + /*TODO: Should we have a different casting quality? + @FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3) + @DropdownFieldOptionsId(R.array.preferred_quality_array) + var preferredQuality: Int = 4; + fun getPreferredQualityPixelCount(): Int { + when (preferredQuality) { + 0 -> return 1280 * 720; + 1 -> return 3840 * 2160; + 2 -> return 1920 * 1080; + 3 -> return 1280 * 720; + 4 -> return 640 * 480; + else -> return 0; + } + }*/ + } + + + @FormField("Logging", FieldForm.GROUP, "", 8) + var logging = Logging(); + @Serializable + class Logging { + @FormField("Log Level", FieldForm.DROPDOWN, "", 0) + @DropdownFieldOptionsId(R.array.log_levels) + var logLevel: Int = 0; + + @FormField( + "Submit logs", FieldForm.BUTTON, + "Submit logs to help us narrow down issues", 1 + ) + fun submitLogs() { + StateApp.instance.scopeGetter().launch(Dispatchers.IO) { + try { + if (!Logger.submitLogs()) { + withContext(Dispatchers.Main) { + SettingsActivity.getActivity()?.let { UIDialogs.toast(it, "Please enable logging to submit logs") } + } + } + } catch (e: Throwable) { + Logger.e("Settings", "Failed to submit logs.", e); + } + } + } + } + + + + @FormField("Announcement", FieldForm.GROUP, "", 10) + var announcementSettings = AnnouncementSettings(); + @Serializable + class AnnouncementSettings { + @FormField( + "Reset announcements", FieldForm.BUTTON, + "Reset hidden announcements", 1 + ) + fun resetAnnouncements() { + StateAnnouncement.instance.resetAnnouncements(); + UIDialogs.toast("Announcements reset."); + } + } + + @FormField("Plugins", FieldForm.GROUP, "", 11) + @Transient + var plugins = Plugins(); + @Serializable + class Plugins { + + @FormField("Clear Cookies on Logout", FieldForm.TOGGLE, "Clears cookies when you log out, allowing you to change account.", 0) + var clearCookiesOnLogout: Boolean = true; + + @FormField( + "Clear Cookies", FieldForm.BUTTON, + "Clears in-app browser cookies, especially useful for fully logging out of plugins.", 1 + ) + fun clearCookies() { + val cookieManager: CookieManager = CookieManager.getInstance(); + cookieManager.removeAllCookies(null); + } + @FormField( + "Reinstall Embedded Plugins", FieldForm.BUTTON, + "Also removes any data related plugin like login or settings (may not clear browser cache)", 1 + ) + fun reinstallEmbedded() { + StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) { + try { + StatePlugins.instance.reinstallEmbeddedPlugins(StateApp.instance.context); + + withContext(Dispatchers.Main) { + StateApp.instance.contextOrNull?.let { + UIDialogs.toast(it, "Embedded plugins reinstalled, a reboot is recommended"); + }; + } + } catch (ex: Exception) { + withContext(Dispatchers.Main) { + StateApp.withContext { + UIDialogs.toast(it, "Failed: " + ex.message); + }; + } + } + } + } + } + + + @FormField("Auto Update", "group", "Configure the auto updater", 12) + var autoUpdate = AutoUpdate(); + @Serializable + class AutoUpdate { + @FormField("Check", FieldForm.DROPDOWN, "", 0) + @DropdownFieldOptionsId(R.array.auto_update_when_array) + var check: Int = 0; + + @FormField("Background download", FieldForm.DROPDOWN, "Configure if background download should be used", 1) + @DropdownFieldOptionsId(R.array.background_download) + var backgroundDownload: Int = 0; + + @FormField("Download when", FieldForm.DROPDOWN, "Configure when updates should be downloaded", 2) + @DropdownFieldOptionsId(R.array.when_download) + var whenDownload: Int = 0; + + fun shouldDownload(): Boolean { + return when (whenDownload) { + 0 -> !StateApp.instance.isCurrentMetered(); + 1 -> StateApp.instance.isNetworkState(StateApp.NetworkState.WIFI, StateApp.NetworkState.ETHERNET); + 2 -> true; + else -> false; + } + } + + fun isAutoUpdateEnabled(): Boolean { + return check == 0 && !BuildConfig.IS_PLAYSTORE_BUILD; + } + + @FormField( + "Manual check", FieldForm.BUTTON, + "Manually check for updates", 3 + ) + fun manualCheck() { + if (!BuildConfig.IS_PLAYSTORE_BUILD) { + SettingsActivity.getActivity()?.let { + StateUpdate.instance.checkForUpdates(it, true); + } + } else { + SettingsActivity.getActivity()?.let { + try { + it.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${it.packageName}"))) + } catch (e: ActivityNotFoundException) { + UIDialogs.toast(it, "Failed to show store."); + } + } + } + } + + @FormField( + "View changelog", FieldForm.BUTTON, + "Review the current and past changelogs", 4 + ) + fun viewChangelog() { + UIDialogs.toast("Retrieving changelog"); + SettingsActivity.getActivity()?.let { + StateApp.instance.scopeGetter().launch(Dispatchers.IO) { + try { + val version = StateUpdate.instance.downloadVersionCode(ManagedHttpClient()) ?: return@launch; + Logger.i(TAG, "Version retrieved $version"); + + withContext(Dispatchers.Main) { + UIDialogs.showChangelogDialog(it, version); + } + } catch (e: Throwable) { + Logger.e("Settings", "Failed to submit logs.", e); + } + } + }; + } + + @FormField( + "Remove Cached Version", FieldForm.BUTTON, + "Remove the last downloaded version", 5 + ) + fun removeCachedVersion() { + StateApp.withContext { + val outputDirectory = File(it.filesDir, "autoupdate"); + if (!outputDirectory.exists()) { + UIDialogs.toast("Directory does not exist"); + return@withContext; + } + + File(outputDirectory, "last_version.apk").delete(); + File(outputDirectory, "last_version.txt").delete(); + UIDialogs.toast("Removed downloaded version"); + } + } + } + + @FormField("Backup", FieldForm.GROUP, "", 13) + var backup = Backup(); + @Serializable + class Backup { + @Serializable(with = OffsetDateTimeSerializer::class) + var lastAutoBackupTime: OffsetDateTime = OffsetDateTime.MIN; + var didAskAutoBackup: Boolean = false; + var autoBackupPassword: String? = null; + fun shouldAutomaticBackup() = autoBackupPassword != null; + + @FormField("Automatic Backup", FieldForm.READONLYTEXT, "", 0) + val automaticBackupText get() = if(!shouldAutomaticBackup()) "None" else "Every Day"; + + @FormField("Set Automatic Backup", FieldForm.BUTTON, "Configure daily backup in case of catastrophic failure. (Written to the external Grayjay directory)", 1) + fun configureAutomaticBackup() { + UIDialogs.showAutomaticBackupDialog(SettingsActivity.getActivity()!!); + } + @FormField("Restore Automatic Backup", FieldForm.BUTTON, "Restore a previous automatic backup", 2) + fun restoreAutomaticBackup() { + val activity = SettingsActivity.getActivity()!! + + if(!StateBackup.hasAutomaticBackup()) + UIDialogs.toast(activity, "You don't have any automatic backups", false); + else + UIDialogs.showAutomaticRestoreDialog(activity, activity.lifecycleScope); + } + + + @FormField("Export Data", FieldForm.BUTTON, "Creates a zip file with your data which can be imported by opening it with Grayjay", 3) + fun export() { + StateBackup.startExternalBackup(); + } + } + + @FormField("Payment", FieldForm.GROUP, "", 14) + var payment = Payment(); + @Serializable + class Payment { + @FormField("Payment Status", FieldForm.READONLYTEXT, "", 1) + val paymentStatus: String get() = if (StatePayment.instance.hasPaid) "Paid" else "Not Paid"; + + @FormField("Clear Payment", FieldForm.BUTTON, "Deletes license keys from app", 2) + fun clearPayment() { + StatePayment.instance.clearLicenses(); + SettingsActivity.getActivity()?.let { + UIDialogs.toast(it, "Licenses cleared, might require app restart"); + } + } + } + + @FormField("Info", FieldForm.GROUP, "", 15) + var info = Info(); + @Serializable + class Info { + @FormField("Version Code", FieldForm.READONLYTEXT, "", 1, "code") + var versionCode = BuildConfig.VERSION_CODE; + @FormField("Version Name", FieldForm.READONLYTEXT, "", 2) + var versionName = BuildConfig.VERSION_NAME; + @FormField("Version Type", FieldForm.READONLYTEXT, "", 3) + var versionType = BuildConfig.BUILD_TYPE; + } + + //region BOILERPLATE + override fun encode(): String { + return Json.encodeToString(this); + } + + companion object { + private const val TAG = "Settings"; + + private var _isFirst = true; + + val instance: Settings get() { + if(_isFirst) { + Logger.i(TAG, "Initial Settings fetch"); + _isFirst = false; + } + return FragmentedStorage.get(); + } + + fun replace(text: String) { + FragmentedStorage.replace(text, true); + } + + + private fun preferedQualityToPixels(q: Int): Int { + when (q) { + 0 -> return 1280 * 720; + 1 -> return 3840 * 2160; + 2 -> return 2560 * 1440; + 3 -> return 1920 * 1080; + 4 -> return 1280 * 720; + 5 -> return 854 * 480; + 6 -> return 640 * 360; + 7 -> return 426 * 240; + 8 -> return 256 * 144; + else -> return 0; + } + } + + + private fun threadIndexToCount(index: Int): Int { + return when(index) { + 0 -> 1; + 1 -> 2; + 2 -> 4; + 3 -> 6; + 4 -> 8; + 5 -> 10; + 6 -> 15; + else -> 1 + } + } + } + //endregion +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/SettingsDev.kt b/app/src/main/java/com/futo/platformplayer/SettingsDev.kt new file mode 100644 index 00000000..826a9c0f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/SettingsDev.kt @@ -0,0 +1,334 @@ +package com.futo.platformplayer + +import android.content.Context +import android.webkit.CookieManager +import com.caoccao.javet.values.primitive.V8ValueInteger +import com.caoccao.javet.values.primitive.V8ValueString +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.engine.V8Plugin +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.serializers.FlexibleBooleanSerializer +import com.futo.platformplayer.states.StateAnnouncement +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateDeveloper +import com.futo.platformplayer.states.StateDownloads +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.FragmentedStorageFileJson +import com.futo.platformplayer.views.fields.FieldForm +import com.futo.platformplayer.views.fields.FormField +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.* +import kotlinx.serialization.json.* +import java.util.stream.IntStream.range +import kotlin.system.measureTimeMillis + +@Serializable() +class SettingsDev : FragmentedStorageFileJson() { + + @FormField("Developer Mode", FieldForm.TOGGLE, "", 0) + @Serializable(with = FlexibleBooleanSerializer::class) + var developerMode: Boolean = false; + + @FormField("Development Server", FieldForm.GROUP, + "Settings related to development server, be careful as it may open your phone to security vulnerabilities", 1) + val devServerSettings: DeveloperServerFields = DeveloperServerFields(); + @Serializable + class DeveloperServerFields { + + @FormField("Start Server on boot", FieldForm.TOGGLE, "", 0) + @Serializable(with = FlexibleBooleanSerializer::class) + var devServerOnBoot: Boolean = false; + + @FormField("Start Server", FieldForm.BUTTON, + "Starts a DevServer on port 11337, may expose vulnerabilities.", 1) + fun startServer() { + StateDeveloper.instance.runServer(); + StateApp.instance.contextOrNull?.let { + UIDialogs.toast(it, "Dev Started", false); + }; + } + } + + @FormField("Experimental", FieldForm.GROUP, + "Settings related to development server, be careful as it may open your phone to security vulnerabilities", 2) + val experimentalSettings: ExperimentalFields = ExperimentalFields(); + @Serializable + class ExperimentalFields { + + @FormField("Background Subscription Testing", FieldForm.TOGGLE, "", 0) + @Serializable(with = FlexibleBooleanSerializer::class) + var backgroundSubscriptionFetching: Boolean = false; + } + + @FormField("Crash Me", FieldForm.BUTTON, + "Crashes the application on purpose", 2) + fun crashMe() { + throw java.lang.IllegalStateException("This is an uncaught exception triggered on purpose!"); + } + + @FormField("Delete Announcements", FieldForm.BUTTON, + "Delete all announcements", 2) + fun deleteAnnouncements() { + StateAnnouncement.instance.deleteAllAnnouncements(); + } + + @FormField("Clear Cookies", FieldForm.BUTTON, + "Clear all cook from the CookieManager", 2) + fun clearCookies() { + val cookieManager: CookieManager = CookieManager.getInstance() + cookieManager.removeAllCookies(null); + } + + @Contextual + @Transient + @FormField("V8 Benchmarks", FieldForm.GROUP, + "Various benchmarks using the integrated V8 engine", 3) + val v8Benchmarks: V8Benchmarks = V8Benchmarks(); + class V8Benchmarks { + @FormField( + "Test V8 Creation speed", FieldForm.BUTTON, + "Tests V8 creation times and running", 1 + ) + fun testV8Creation() { + var plugin: V8Plugin? = null; + StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) { + try { + val count = 1000; + val timeStart = System.currentTimeMillis(); + for (i in range(0, count)) { + val v8 = V8Plugin( + StateApp.instance.context, + SourcePluginConfig("Test", "", "", "", "", ""), + "var i = 0; function test() { i = i + 1; return i; }" + ); + + v8.start(); + if (v8.executeTyped("test()").value != 1) + throw java.lang.IllegalStateException("Test didn't properly respond"); + v8.stop(); + } + val timeEnd = System.currentTimeMillis(); + val resp = "Restarted V8 ${count} times in ${(timeEnd - timeStart)}ms, ${(timeEnd - timeStart) / count}ms per instance\n(initializing, calling function with value, destroying)" + Logger.i("SettingsDev", resp); + + withContext(Dispatchers.Main) { + StateApp.instance.contextOrNull?.let { + UIDialogs.toast(it, resp); + }; + } + } catch (ex: Exception) { + withContext(Dispatchers.Main) { + StateApp.withContext { + UIDialogs.toast(it, "Failed: " + ex.message); + }; + } + } finally { + plugin?.stop(); + } + } + } + + @FormField( + "Test V8 Communication speed", FieldForm.BUTTON, + "Tests V8 communication speeds", 2 + ) + fun testV8RunSpeeds() { + var plugin: V8Plugin? = null; + StateApp.instance.scope.launch(Dispatchers.IO) { + try { + val count = 10000; + var str = "012346789012346789012346789012346789012346789"; + val v8 = V8Plugin( + StateApp.instance.context, + SourcePluginConfig("Test"), + "function test(str) { return str; }" + ); + v8.start(); + val timeStart = System.currentTimeMillis(); + for (i in range(0, count)) { + if (v8.executeTyped("test(\"" + str + "\")").value != str) + throw java.lang.IllegalStateException("Test didn't properly respond"); + } + val timeEnd = System.currentTimeMillis(); + v8.stop(); + + val resp = "Ran V8 ${count} times in ${(timeEnd - timeStart)}ms, ${(timeEnd - timeStart) / count}ms per instance\n(passing a string[50] back and forth)"; + Logger.i("SettingsDev", resp); + withContext(Dispatchers.Main) { + StateApp.withContext { + UIDialogs.toast(it, resp); + }; + } + } catch (ex: Exception) { + withContext(Dispatchers.Main) { + StateApp.withContext { + UIDialogs.toast(it, "Failed: " + ex.message); + }; + } + } finally { + plugin?.stop(); + } + } + } + } + + @Contextual + @Transient + @FormField("V8 Script Testing", FieldForm.GROUP, "Various tests against a custom source", 4) + val v8ScriptTests: V8ScriptTests = V8ScriptTests(); + class V8ScriptTests { + @Contextual + private var _currentPlugin : JSClient? = null; + @FormField("Inject", FieldForm.BUTTON, "Injects a test source config (local) into V8", 1) + fun testV8Init() { + StateApp.instance.scope.launch(Dispatchers.IO) { + try { + _currentPlugin = + getTestPlugin("http://192.168.1.132/Public/FUTO/TestConfig.json"); + + withContext(Dispatchers.Main) { + UIDialogs.toast(StateApp.instance.context, "TestPlugin injected"); + } + } + catch(ex: Exception) { + toast(ex.message ?: ""); + } + } + } + @FormField("getHome", FieldForm.BUTTON, "Attempts to fetch 2 pages from getHome", 2) + fun testV8Home() { + runTestPlugin(_currentPlugin) { + var home: IPager? = null; + var resultPage1: String = ""; + var resultPage2: String = ""; + val page1Time = measureTimeMillis { + home = it.getHome(); + val results = home!!.getResults(); + resultPage1 = "Page1 Results=[${results.size}] HasMore=${home!!.hasMorePages()}\nResult[0]=${results.firstOrNull()?.name}"; + } + toast(resultPage1); + val page2Time = measureTimeMillis { + home!!.nextPage(); + val results = home!!.getResults(); + resultPage2 = "Page2 Results=[${results.size}] HasMore=${home!!.hasMorePages()}\nResult[0]=${results.firstOrNull()?.name}"; + } + toast(resultPage2); + toast("Page1: ${page1Time}ms, Page2: ${page2Time}ms"); + } + } + + private fun toast(str: String, isLong: Boolean = false) { + StateApp.instance.scope.launch(Dispatchers.Main) { + try { + UIDialogs.toast(StateApp.instance.context, str, isLong); + } catch (e: Throwable) { + Logger.e("SettingsDev", "Failed to show toast", e) + } + } + } + private fun runTestPlugin(plugin: JSClient?, handler: (JSClient) -> Unit) { + StateApp.instance.scope.launch(Dispatchers.IO) { + try { + if (plugin == null) + throw IllegalStateException("Test plugin not loaded, inject first"); + else + handler(plugin); + } catch (ex: Exception) { + Logger.e("ScriptTesting", ex.message ?: "", ex); + toast("Failed: " + ex.message, true); + } + } + } + private fun getTestPlugin(configUrl: String) : JSClient { + val configResp = + ManagedHttpClient().get(configUrl); + if (!configResp.isOk || configResp.body == null) + throw IllegalStateException("Failed to load config"); + val config = Json.decodeFromString(configResp.body.string()); + + val scriptResp = ManagedHttpClient().get(config.absoluteScriptUrl); + if (!scriptResp.isOk || scriptResp.body == null) + throw IllegalStateException("Failed to load script"); + val script = scriptResp.body.string(); + + val client = JSClient(StateApp.instance.context, SourcePluginDescriptor(config), null, script); + client.initialize(); + + return client; + } + } + + + @Contextual + @Transient + @FormField("Other", FieldForm.GROUP, "Others...", 5) + val otherTests: OtherTests = OtherTests(); + class OtherTests { + @FormField("Clear Downloads", FieldForm.BUTTON, "Deletes all ongoing downloads", 1) + fun clearDownloads() { + StateDownloads.instance.getDownloading().forEach { + StateDownloads.instance.removeDownload(it); + }; + } + @FormField("Clear All Downloaded", FieldForm.BUTTON, "Deletes all downloaded videos and related files", 2) + fun clearDownloaded() { + StateDownloads.instance.getDownloadedVideos().forEach { + StateDownloads.instance.deleteCachedVideo(it.id); + }; + } + @FormField("Delete Unresolved", FieldForm.BUTTON, "Deletes all unresolved source files", 3) + fun cleanupDownloads() { + StateDownloads.instance.cleanupDownloads(); + } + + @FormField("Fill storage till error", FieldForm.BUTTON, "Writes to disk till no space is left", 4) + fun fillStorage(context: Context, scope: CoroutineScope?) { + val gigabuffer = ByteArray(1024 * 1024 * 128); + var count: Long = 0; + + UIDialogs.toast("Starting filling up space.."); + + scope?.launch(Dispatchers.IO) { + try { + do { + Logger.i("Developer", "Total: ${count}, Storage: ${(count * gigabuffer.size).toHumanBytesSize()}") + val tempFile = StateApp.instance.getTempFile(); + tempFile.writeBytes(gigabuffer); + count++; + + if(count % 50 == 0L) { + StateApp.instance.scopeOrNull?.launch (Dispatchers.Main) { + UIDialogs.toast(context, "Filled up ${(count * gigabuffer.size).toHumanBytesSize()}"); + } + } + } while (true); + } catch (ex: Throwable) { + withContext(Dispatchers.Main) { + UIDialogs.toast("Total: ${count}, Storage: ${(count * gigabuffer.size).toHumanBytesSize()}\nError: ${ex.message}"); + UIDialogs.showGeneralErrorDialog(context, ex.message ?: "", ex); + } + } + } + } + } + + //region BOILERPLATE + override fun encode(): String { + return Json.encodeToString(this); + } + + companion object { + val instance: SettingsDev get() { + return FragmentedStorage.get(); + } + } + //endregion +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/SignatureProvider.kt b/app/src/main/java/com/futo/platformplayer/SignatureProvider.kt new file mode 100644 index 00000000..0c0c257f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/SignatureProvider.kt @@ -0,0 +1,56 @@ +package com.futo.platformplayer + +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.Signature +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec +import java.util.Base64 + +data class KeyPair(val privateKey: String, val publicKey: String); + +class SignatureProvider { + companion object { + fun sign(text: String, privateKey: String): String { + val privateKeyBytes = Base64.getDecoder().decode(privateKey); + val keySpec = PKCS8EncodedKeySpec(privateKeyBytes); + val keyFactory = KeyFactory.getInstance("RSA"); + val privateKeyObject = keyFactory.generatePrivate(keySpec); + + val signature = Signature.getInstance("SHA512withRSA"); + signature.initSign(privateKeyObject); + signature.update(text.toByteArray()); + val signatureBytes = signature.sign(); + + return Base64.getEncoder().encodeToString(signatureBytes); + } + + fun verify(text: String, signature: String, publicKey: String): Boolean { + val publicKeyBytes = Base64.getDecoder().decode(publicKey); + val keySpec = X509EncodedKeySpec(publicKeyBytes); + val keyFactory = KeyFactory.getInstance("RSA"); + val publicKeyObject = keyFactory.generatePublic(keySpec); + + val signatureBytes = Base64.getDecoder().decode(signature); + val verifySignature = Signature.getInstance("SHA512withRSA"); + verifySignature.initVerify(publicKeyObject); + verifySignature.update(text.toByteArray()); + + return verifySignature.verify(signatureBytes); + } + + fun generateKeyPair(): KeyPair { + val keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + val keyPair = keyPairGenerator.generateKeyPair(); + + val privateKeyBytes = keyPair.private.encoded; + val privateKey = Base64.getEncoder().encodeToString(privateKeyBytes); + + val publicKeyBytes = keyPair.public.encoded; + val publicKey = Base64.getEncoder().encodeToString(publicKeyBytes); + + return KeyPair(privateKey, publicKey); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt new file mode 100644 index 00000000..0015cafb --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt @@ -0,0 +1,356 @@ +package com.futo.platformplayer + +import android.app.AlertDialog +import android.content.Context +import android.graphics.Color +import android.util.TypedValue +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.* +import androidx.core.content.ContextCompat +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.casting.StateCasting +import com.futo.platformplayer.dialogs.* +import com.futo.platformplayer.engine.exceptions.PluginException +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.stores.v2.ManagedStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import userpackage.Protocol +import java.io.File + +class UIDialogs { + companion object { + private val TAG = "Dialogs" + + private val _openDialogs = arrayListOf(); + + private fun registerDialogOpened(dialog: AlertDialog) { + _openDialogs.add(dialog); + } + + private fun registerDialogClosed(dialog: AlertDialog) { + _openDialogs.remove(dialog); + } + + fun dismissAllDialogs() { + for (openDialog in _openDialogs) { + openDialog.dismiss(); + } + + _openDialogs.clear(); + } + + fun showDialogProgress(context: Context, handler: ((ProgressDialog)->Unit)) { + val dialog = ProgressDialog(context, handler); + registerDialogOpened(dialog); + dialog.setOnDismissListener { registerDialogClosed(dialog) }; + dialog.show(); + } + + fun showDialogOk(context: Context, icon: Int, text: String, handler: (()->Unit)? = null) { + showDialog(context, icon, text, null, null, 0, Action("Ok", { handler?.invoke(); }, ActionStyle.PRIMARY)); + } + + fun multiShowDialog(context: Context, finally: (() -> Unit)?, vararg dialogDescriptor: Descriptor?) = multiShowDialog(context, dialogDescriptor.toList(), finally); + fun multiShowDialog(context: Context, vararg dialogDescriptor: Descriptor?) = multiShowDialog(context, dialogDescriptor.toList()); + fun multiShowDialog(context: Context, dialogDescriptor: List, finally: (()->Unit)? = null) { + if(dialogDescriptor.isEmpty()) { + if (finally != null) { + finally() + }; + return; + } + if(dialogDescriptor[0] == null) { + multiShowDialog(context, dialogDescriptor.drop(1), finally); + return; + } + val currentDialog = dialogDescriptor[0]!!; + if(!currentDialog.shouldShow()) { + multiShowDialog(context, dialogDescriptor.drop(1), finally); + return; + } + + showDialog(context, + currentDialog.icon, + currentDialog.text, + currentDialog.textDetails, + currentDialog.code, + currentDialog.defaultCloseAction, + *currentDialog.actions.map { + return@map Action(it.text, { + it.action(); + multiShowDialog(context, dialogDescriptor.drop(1), finally); + }, it.style); + }.toTypedArray()); + } + + + fun showAutomaticBackupDialog(context: Context) { + val dialog = AutomaticBackupDialog(context); + registerDialogOpened(dialog); + dialog.setOnDismissListener { registerDialogClosed(dialog) }; + dialog.show(); + } + fun showAutomaticRestoreDialog(context: Context, scope: CoroutineScope) { + val dialog = AutomaticRestoreDialog(context, scope); + registerDialogOpened(dialog); + dialog.setOnDismissListener { registerDialogClosed(dialog) }; + dialog.show(); + } + + fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action) { + val builder = AlertDialog.Builder(context); + val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null); + builder.setView(view); + + val dialog = builder.create(); + registerDialogOpened(dialog); + + view.findViewById(R.id.dialog_icon).apply { + this.setImageResource(icon); + } + view.findViewById(R.id.dialog_text).apply { + this.text = text; + }; + view.findViewById(R.id.dialog_text_details).apply { + if(textDetails == null) + this.visibility = View.GONE; + else + this.text = textDetails; + }; + view.findViewById(R.id.dialog_text_code).apply { + if(code == null) + this.visibility = View.GONE; + else + this.text = code; + }; + view.findViewById(R.id.dialog_buttons).apply { + val buttons = actions.map { act -> + val buttonView = TextView(context); + val dp10 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10f, resources.displayMetrics).toInt(); + val dp28 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 28f, resources.displayMetrics).toInt(); + val dp14 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14.0f, resources.displayMetrics); + buttonView.layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { + if(actions.size > 1) + this.marginEnd = dp28; + }; + buttonView.setTextColor(Color.WHITE); + buttonView.textSize = 14f; + buttonView.typeface = resources.getFont(R.font.inter_regular); + buttonView.text = act.text; + buttonView.setOnClickListener { act.action(); dialog.dismiss(); }; + when(act.style) { + ActionStyle.PRIMARY -> buttonView.setBackgroundResource(R.drawable.background_button_primary); + ActionStyle.ACCENT -> buttonView.setBackgroundResource(R.drawable.background_button_accent); + ActionStyle.DANGEROUS -> buttonView.setBackgroundResource(R.drawable.background_button_pred); + ActionStyle.DANGEROUS_TEXT -> buttonView.setTextColor(ContextCompat.getColor(context, R.color.pastel_red)) + else -> buttonView.setTextColor(ContextCompat.getColor(context, R.color.colorPrimary)) + } + if(act.style != ActionStyle.NONE && act.style != ActionStyle.DANGEROUS_TEXT) + buttonView.setPadding(dp28, dp10, dp28, dp10); + else + buttonView.setPadding(dp10, dp10, dp10, dp10); + + return@map buttonView; + }; + if(actions.size <= 1) + this.gravity = Gravity.CENTER; + else + this.gravity = Gravity.END; + for(button in buttons) + this.addView(button); + }; + dialog.setOnCancelListener { + if(defaultCloseAction >= 0 && defaultCloseAction < actions.size) + actions[defaultCloseAction].action(); + } + dialog.setOnDismissListener { + registerDialogClosed(dialog); + } + dialog.show(); + } + + fun showGeneralErrorDialog(context: Context, msg: String, ex: Throwable? = null, button: String = "Ok", onOk: (()->Unit)? = null) { + showDialog(context, + R.drawable.ic_error_pred, + msg, (if(ex != null ) "${ex.message}" else ""), if(ex is PluginException) ex.code else null, + 0, + UIDialogs.Action(button, { + onOk?.invoke(); + }, UIDialogs.ActionStyle.PRIMARY) + ); + } + fun showGeneralRetryErrorDialog(context: Context, msg: String, ex: Throwable? = null, retryAction: (() -> Unit)? = null, closeAction: (() -> Unit)? = null) { + val pluginInfo = if(ex is PluginException) + "\nPlugin [${ex.config.name}]" else ""; + showDialog(context, + R.drawable.ic_error_pred, + "${msg}${pluginInfo}", (if(ex != null ) "${ex.message}" else ""), if(ex is PluginException) ex.code else null, + 0, + UIDialogs.Action("Retry", { + retryAction?.invoke(); + }, UIDialogs.ActionStyle.PRIMARY), + UIDialogs.Action("Close", { + closeAction?.invoke() + }, UIDialogs.ActionStyle.NONE) + ); + } + + fun showSingleButtonDialog(context: Context, icon: Int, text: String, buttonText: String, action: (() -> Unit)) { + val singleButtonAction = Action(buttonText, action) + showDialog(context, icon, text, null, null, -1, singleButtonAction) + } + + fun showDataRetryDialog(context: Context, reason: String? = null, retryAction: (() -> Unit)? = null, closeAction: (() -> Unit)? = null) { + val retryButtonAction = Action("Retry", retryAction ?: {}, ActionStyle.PRIMARY) + val closeButtonAction = Action("Close", closeAction ?: {}, ActionStyle.ACCENT) + showDialog(context, R.drawable.ic_no_internet_86dp, "Data Retry", reason, null, 0, retryButtonAction, closeButtonAction) + } + + + fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null) { + val confirmButtonAction = Action("Confirm", action, ActionStyle.PRIMARY) + val cancelButtonAction = Action("Cancel", cancelAction ?: {}, ActionStyle.ACCENT) + showDialog(context, R.drawable.ic_error, text, null, null, 1, cancelButtonAction, confirmButtonAction) + } + + fun showUpdateAvailableDialog(context: Context, lastVersion: Int) { + val dialog = AutoUpdateDialog(context); + registerDialogOpened(dialog); + dialog.setOnDismissListener { registerDialogClosed(dialog) }; + dialog.show(); + dialog.setMaxVersion(lastVersion); + } + + fun showChangelogDialog(context: Context, lastVersion: Int) { + val dialog = ChangelogDialog(context); + registerDialogOpened(dialog); + dialog.setOnDismissListener { registerDialogClosed(dialog) }; + dialog.show(); + dialog.setMaxVersion(lastVersion); + } + + fun showInstallDownloadedUpdateDialog(context: Context, apkFile: File) { + val dialog = AutoUpdateDialog(context); + registerDialogOpened(dialog); + dialog.setOnDismissListener { registerDialogClosed(dialog) }; + dialog.showPredownloaded(apkFile); + } + + fun showMigrateDialog(context: Context, store: ManagedStore<*>, onConcluded: ()->Unit) { + if(!store.hasMissingReconstructions()) + onConcluded(); + else + { + val dialog = MigrateDialog(context, store, onConcluded); + registerDialogOpened(dialog); + dialog.setOnDismissListener { registerDialogClosed(dialog) }; + dialog.show(); + } + } + + fun showImportDialog(context: Context, store: ManagedStore<*>, name: String, reconstructions: List, onConcluded: () -> Unit) { + val dialog = ImportDialog(context, store, name, reconstructions, onConcluded); + registerDialogOpened(dialog); + dialog.setOnDismissListener { registerDialogClosed(dialog) }; + dialog.show(); + } + + + fun showCastingDialog(context: Context) { + val d = StateCasting.instance.activeDevice; + if (d != null) { + val dialog = ConnectedCastingDialog(context); + registerDialogOpened(dialog); + dialog.setOnDismissListener { registerDialogClosed(dialog) }; + dialog.show(); + } else { + val dialog = ConnectCastingDialog(context); + registerDialogOpened(dialog); + dialog.setOnDismissListener { registerDialogClosed(dialog) }; + dialog.show(); + } + } + + fun showCastingAddDialog(context: Context) { + val dialog = CastingAddDialog(context); + registerDialogOpened(dialog); + dialog.setOnDismissListener { registerDialogClosed(dialog) }; + dialog.show(); + } + + fun toast(context : Context, text : String, long : Boolean = false) { + Toast.makeText(context, text, if(long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT).show(); + } + fun toast(text : String, long : Boolean = false) { + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { + try { + StateApp.withContext { + Toast.makeText(it, text, if (long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT).show(); + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to show toast.", e); + } + } + } + + fun showClickableToast(context: Context, text: String, onClick: () -> Unit, isLongDuration: Boolean = false) { + //TODO: Is not actually clickable... + val toastDuration = if (isLongDuration) Toast.LENGTH_LONG else Toast.LENGTH_SHORT + val toast = Toast(context) + + val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater + val customView = inflater.inflate(R.layout.toast_clickable, null) + val toastTextView: TextView = customView.findViewById(R.id.toast_text) + toastTextView.text = text + customView.setOnClickListener { + onClick() + } + + toast.view = customView + toast.duration = toastDuration + toast.show() + } + + fun showCommentDialog(context: Context, contextUrl: String, ref: Protocol.Reference, onCommentAdded: (comment: IPlatformComment) -> Unit) { + val dialog = CommentDialog(context, contextUrl, ref); + registerDialogOpened(dialog); + dialog.setOnDismissListener { registerDialogClosed(dialog) }; + dialog.onCommentAdded.subscribe { onCommentAdded(it); }; + dialog.show() + } + } + + class Descriptor(val icon: Int, val text: String, val textDetails: String? = null, val code: String? = null, val defaultCloseAction: Int, vararg acts: Action) { + var shouldShow: ()->Boolean = {true}; + val actions: List = acts.toList(); + + fun withCondition(shouldShow: () -> Boolean): Descriptor { + this.shouldShow = shouldShow; + return this; + } + } + class Action { + val text: String; + val action: ()->Unit; + val style: ActionStyle; + + constructor(text: String, action: ()->Unit, style: ActionStyle = ActionStyle.NONE) { + this.text = text; + this.action = action; + this.style = style; + } + } + enum class ActionStyle { + NONE, + PRIMARY, + ACCENT, + DANGEROUS, + DANGEROUS_TEXT + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt new file mode 100644 index 00000000..77b537a4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -0,0 +1,419 @@ +package com.futo.platformplayer + +import android.content.ContentResolver +import android.view.View +import android.view.ViewGroup +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler +import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource +import com.futo.platformplayer.api.media.models.streams.sources.SubtitleRawSource +import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import com.futo.platformplayer.downloads.VideoLocal +import com.futo.platformplayer.helpers.VideoHelper +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.Playlist +import com.futo.platformplayer.states.* +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay +import com.futo.platformplayer.views.pills.RoundButton +import com.futo.platformplayer.views.pills.RoundButtonGroup +import com.futo.platformplayer.views.overlays.slideup.* +import com.futo.platformplayer.views.video.FutoVideoPlayerBase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +class UISlideOverlays { + companion object { + private const val TAG = "UISlideOverlays"; + + fun showOverlay(container: ViewGroup, title: String, okButton: String?, onOk: ()->Unit, vararg views: View) { + var menu = SlideUpMenuOverlay(container.context, container, title, okButton, true, *views); + + menu.onOK.subscribe { + menu.hide(); + onOk.invoke(); + }; + menu.show(); + } + + fun showDownloadVideoOverlay(contentResolver: ContentResolver, video: IPlatformVideoDetails, container: ViewGroup): SlideUpMenuOverlay? { + val items = arrayListOf(); + var menu: SlideUpMenuOverlay? = null; + + var descriptor = video.video; + if(video is VideoLocal) + descriptor = video.videoSerialized.video; + + + val requiresAudio = descriptor is VideoUnMuxedSourceDescriptor; + var selectedVideo: IVideoUrlSource? = null; + var selectedAudio: IAudioUrlSource? = null; + var selectedSubtitle: ISubtitleSource? = null; + + val videoSources = descriptor.videoSources; + val audioSources = if(descriptor is VideoUnMuxedSourceDescriptor) descriptor.audioSources else null; + val subtitleSources = video.subtitles; + + if(videoSources.size == 0 && (audioSources?.size ?: 0) == 0) { + UIDialogs.toast("No downloads available", false); + return null; + } + + items.add(SlideUpMenuGroup(container.context, "Video", videoSources, + listOf(listOf(SlideUpMenuItem(container.context, R.drawable.ic_movie, "None", "Audio Only", "none", { + selectedVideo = null; + menu?.selectOption(videoSources, "none"); + if(selectedAudio != null || !requiresAudio) + menu?.setOk("Download"); + }, false)) + + videoSources + .filter { it is IVideoUrlSource } + .map { + SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, { + selectedVideo = it as IVideoUrlSource; + menu?.selectOption(videoSources, it); + if(selectedAudio != null || !requiresAudio) + menu?.setOk("Download"); + }, false) + }).flatten().toList() + )); + + if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.size > 0) + selectedVideo = VideoHelper.selectBestVideoSource(videoSources.filter { it is IVideoUrlSource }.asIterable(), + Settings.instance.downloads.getDefaultVideoQualityPixels(), + FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) as IVideoUrlSource; + + + audioSources?.let { audioSources -> + items.add(SlideUpMenuGroup(container.context, "Audio", audioSources, audioSources + .filter { it is IAudioUrlSource } + .map { + SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, { + selectedAudio = it as IAudioUrlSource; + menu?.selectOption(audioSources, it); + menu?.setOk("Download"); + }, false); + })); + val asources = audioSources; + val preferredAudioSource = VideoHelper.selectBestAudioSource(asources.asIterable(), + FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS, + Settings.instance.playback.getPrimaryLanguage(container.context), + if(Settings.instance.downloads.isHighBitrateDefault()) 99999999 else 1); + menu?.selectOption(asources, preferredAudioSource); + + + selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it is IAudioUrlSource }.asIterable(), + FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS, + Settings.instance.playback.getPrimaryLanguage(container.context), + if(Settings.instance.downloads.isHighBitrateDefault()) 9999999 else 1) as IAudioUrlSource?; + } + + items.add(SlideUpMenuGroup(container.context, "Subtitles", subtitleSources, subtitleSources + .map { + SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, { + if (selectedSubtitle == it) { + selectedSubtitle = null; + menu?.selectOption(subtitleSources, null); + } else { + selectedSubtitle = it; + menu?.selectOption(subtitleSources, it); + } + }, false); + })); + + menu = SlideUpMenuOverlay(container.context, container, "Download Video", null, true, items); + + if(selectedVideo != null) { + menu.selectOption(videoSources, selectedVideo); + } + if(selectedAudio != null) { + audioSources?.let { audioSources -> menu.selectOption(audioSources, selectedAudio); }; + } + if(selectedAudio != null || (!requiresAudio && selectedVideo != null)) { + menu.setOk("Download"); + } + + menu.onOK.subscribe { + menu.hide(); + val subtitleToDownload = selectedSubtitle; + if(selectedAudio != null || !requiresAudio) { + if (subtitleToDownload == null) { + StateDownloads.instance.download(video, selectedVideo, selectedAudio, null); + } else { + //TODO: Clean this up somewhere else, maybe pre-fetch instead of dup calls + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + try { + val subtitleUri = subtitleToDownload.getSubtitlesURI(); + if (subtitleUri != null) { + var subtitles: String? = null; + if ("file" == subtitleUri.scheme) { + val inputStream = contentResolver.openInputStream(subtitleUri); + inputStream?.use { stream -> + val reader = stream.bufferedReader(); + subtitles = reader.use { it.readText() }; + } + } else if ("http" == subtitleUri.scheme || "https" == subtitleUri.scheme) { + val client = ManagedHttpClient(); + val subtitleResponse = client.get(subtitleUri.toString()); + if (!subtitleResponse.isOk) { + throw Exception("Cannot fetch subtitles from source '${subtitleUri}': ${subtitleResponse.code}"); + } + + subtitles = subtitleResponse.body?.toString() + ?: throw Exception("Subtitles are invalid '${subtitleUri}': ${subtitleResponse.code}"); + } else { + throw Exception("Unsuported scheme"); + } + + withContext(Dispatchers.Main) { + StateDownloads.instance.download(video, selectedVideo, selectedAudio, if (subtitles != null) SubtitleRawSource(subtitleToDownload.name, subtitleToDownload.format, subtitles!!) else null); + } + } else { + withContext(Dispatchers.Main) { + StateDownloads.instance.download(video, selectedVideo, selectedAudio, null); + } + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed download subtitles.", e); + } + } + } + } + }; + return menu.apply { show() }; + } + fun showDownloadVideoOverlay(video: IPlatformVideo, container: ViewGroup) { + showUnknownVideoDownload("Video", container) { px, bitrate -> + StateDownloads.instance.download(video, px, bitrate) + }; + } + fun showDownloadPlaylistOverlay(playlist: Playlist, container: ViewGroup) { + showUnknownVideoDownload("Video", container) { px, bitrate -> + StateDownloads.instance.download(playlist, px, bitrate); + }; + } + private fun showUnknownVideoDownload(toDownload: String, container: ViewGroup, cb: (Long?, Long?)->Unit) { + val items = arrayListOf(); + var menu: SlideUpMenuOverlay? = null; + + var targetPxSize: Long = 0; + var targetBitrate: Long = 0; + + val resolutions = listOf( + Triple("None", "None", -1), + Triple("480P", "720x480", 720*480), + Triple("720P", "1280x720", 1280*720), + Triple("1080P", "1920x1080", 1920*1080), + Triple("1440P", "2560x1440", 2560*1440), + Triple("2160P", "3840x2160", 3840*2160) + ); + + items.add(SlideUpMenuGroup(container.context, "Target Resolution", "Video", resolutions.map { + SlideUpMenuItem(container.context, R.drawable.ic_movie, it.first, it.second, it.third, { + targetPxSize = it.third; + menu?.selectOption("Video", it.third); + }, false) + })); + + items.add(SlideUpMenuGroup(container.context, "Target Bitrate", "Bitrate", listOf( + SlideUpMenuItem(container.context, R.drawable.ic_movie, "Low Bitrate", "", 1, { + targetBitrate = 1; + menu?.selectOption("Bitrate", 1); + menu?.setOk("Download"); + }, false), + SlideUpMenuItem(container.context, R.drawable.ic_movie, "High Bitrate", "", 9999999, { + targetBitrate = 9999999; + menu?.selectOption("Bitrate", 9999999); + menu?.setOk("Download"); + }, false) + ))); + + + menu = SlideUpMenuOverlay(container.context, container, "Download " + toDownload, null, true, items); + + if(Settings.instance.downloads.getDefaultVideoQualityPixels() != 0) { + val defTarget = Settings.instance.downloads.getDefaultVideoQualityPixels(); + if(defTarget == -1) { + targetPxSize = -1; + menu.selectOption("Video", (-1).toLong()); + } + else { + targetPxSize = resolutions.drop(1).minBy { Math.abs(defTarget - it.third) }.third; + menu.selectOption("Video", targetPxSize); + } + } + if(Settings.instance.downloads.isHighBitrateDefault()) { + targetBitrate = 9999999; + menu.selectOption("Bitrate", 9999999); + menu.setOk("Download"); + } + else { + targetBitrate = 1; + menu.selectOption("Bitrate", 1); + menu.setOk("Download"); + } + + menu.onOK.subscribe { + menu.hide(); + cb(if(targetPxSize > 0) targetPxSize else null, if(targetBitrate > 0) targetBitrate else null); + }; + menu.show(); + } + + fun showVideoOptionsOverlay(video: IPlatformVideo, container: ViewGroup, onVideoHidden: (()->Unit)? = null): SlideUpMenuOverlay { + val items = arrayListOf(); + val lastUpdated = StatePlaylists.instance.getLastUpdatedPlaylist(); + + if (lastUpdated != null) { + items.add( + SlideUpMenuGroup(container.context, "Recently Used Playlist", "recentlyusedplaylist", + SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, lastUpdated.name, "${lastUpdated.videos.size} videos", "", + { + StatePlaylists.instance.addToPlaylist(lastUpdated.id, video); + StateDownloads.instance.checkForOutdatedPlaylists(); + })) + ); + } + + val allPlaylists = StatePlaylists.instance.getPlaylists(); + val queue = StatePlayer.instance.getQueue(); + val watchLater = StatePlaylists.instance.getWatchLater(); + items.add(SlideUpMenuGroup(container.context, "Actions", "actions", + SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, "Hide", "Hide from Home", "hide", + { StateMeta.instance.addHiddenVideo(video.url); onVideoHidden?.invoke() }), + SlideUpMenuItem(container.context, R.drawable.ic_download, "Download", "Download the video", "download", + { showDownloadVideoOverlay(video, container); }, false) + )) + items.add( + SlideUpMenuGroup(container.context, "Add To", "addto", + SlideUpMenuItem(container.context, R.drawable.ic_queue_add, "Add to Queue", "${queue.size} videos", "queue", + { StatePlayer.instance.addToQueue(video); }), + SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, "Add to " + StatePlayer.TYPE_WATCHLATER + "", "${watchLater.size} videos", "watch later", + { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }) + )); + + val playlistItems = arrayListOf(); + for (playlist in allPlaylists) { + playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, "Add to " + playlist.name + "", "${playlist.videos.size} videos", "", + { + StatePlaylists.instance.addToPlaylist(playlist.id, video); + StateDownloads.instance.checkForOutdatedPlaylists(); + })); + } + + if(playlistItems.size > 0) + items.add(SlideUpMenuGroup(container.context, "Playlists", "", playlistItems)); + + return SlideUpMenuOverlay(container.context, container, "Video Options", null, true, items).apply { show() }; + } + + + fun showAddToOverlay(video: IPlatformVideo, container: ViewGroup): SlideUpMenuOverlay { + + val items = arrayListOf(); + + val lastUpdated = StatePlaylists.instance.getLastUpdatedPlaylist(); + + if (lastUpdated != null) { + items.add( + SlideUpMenuGroup(container.context, "Recently Used Playlist", "recentlyusedplaylist", + SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, lastUpdated.name, "${lastUpdated.videos.size} videos", "", + { + StatePlaylists.instance.addToPlaylist(lastUpdated.id, video); + StateDownloads.instance.checkForOutdatedPlaylists(); + })) + ); + } + + val allPlaylists = StatePlaylists.instance.getPlaylists().sortedByDescending { maxOf(it.datePlayed, it.dateUpdate, it.dateCreation) }; + val queue = StatePlayer.instance.getQueue(); + val watchLater = StatePlaylists.instance.getWatchLater(); + items.add( + SlideUpMenuGroup(container.context, "Other", "other", + SlideUpMenuItem(container.context, R.drawable.ic_queue_add, "Queue", "${queue.size} videos", "queue", + { StatePlayer.instance.addToQueue(video); }), + SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, StatePlayer.TYPE_WATCHLATER, "${watchLater.size} videos", "watch later", + { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }), + SlideUpMenuItem(container.context, R.drawable.ic_download, "Download", "Download the video", "download", + { showDownloadVideoOverlay(video, container); }, false)) + ); + + val playlistItems = arrayListOf(); + for (playlist in allPlaylists) { + playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, playlist.name, "${playlist.videos.size} videos", "", + { + StatePlaylists.instance.addToPlaylist(playlist.id, video); + StateDownloads.instance.checkForOutdatedPlaylists(); + })); + } + + if(playlistItems.size > 0) + items.add(SlideUpMenuGroup(container.context, "Playlists", "", playlistItems)); + + return SlideUpMenuOverlay(container.context, container, "Add to", null, true, items).apply { show() }; + } + + fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List, filterValues: HashMap>): SlideUpMenuFilters { + val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues); + overlay.show(); + return overlay; + } + + + fun showMoreButtonOverlay(container: ViewGroup, buttonGroup: RoundButtonGroup, ignoreTags: List = listOf(), onPinnedbuttons: ((List)->Unit)? = null): SlideUpMenuOverlay { + val visible = buttonGroup.getVisibleButtons().filter { !ignoreTags.contains(it.tagRef) }; + val hidden = buttonGroup.getInvisibleButtons().filter { !ignoreTags.contains(it.tagRef) }; + + val views = arrayOf(hidden + .map { btn -> SlideUpMenuItem(container.context, btn.iconResource, btn.text.text.toString(), "", "", { + btn.handler?.invoke(btn); + }, true) as View }.toTypedArray() ?: arrayOf(), + arrayOf(SlideUpMenuItem(container.context, R.drawable.ic_pin, "Change Pins", "Decide which buttons should be pinned", "", { + showOrderOverlay(container, "Select your pins in order", (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) { + val selected = it + .map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } } + .filter { it != null } + .map { it!! } + .toList(); + + onPinnedbuttons?.invoke(selected + (visible + hidden).filter { !selected.contains(it) }); + } + }, false)) + ).flatten().toTypedArray(); + + return SlideUpMenuOverlay(container.context, container, "More Options", null, true, *views).apply { show() }; + } + + fun showOrderOverlay(container: ViewGroup, title: String, options: List>, onOrdered: (List)->Unit) { + val selection: MutableList = mutableListOf(); + + var overlay: SlideUpMenuOverlay? = null; + + overlay = SlideUpMenuOverlay(container.context, container, title, "Save", true, + options.map { SlideUpMenuItem(container.context, R.drawable.ic_move_up, it.first, "", it.second, { + if(overlay!!.selectOption(null, it.second, true, true)) { + if(!selection.contains(it.second)) + selection.add(it.second); + } + else + selection.remove(it.second); + }, false) + }); + overlay.onOK.subscribe { + onOrdered.invoke(selection); + overlay.hide(); + }; + + overlay.show(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/Utility.kt b/app/src/main/java/com/futo/platformplayer/Utility.kt new file mode 100644 index 00000000..5357250d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/Utility.kt @@ -0,0 +1,159 @@ +package com.futo.platformplayer + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.os.Build +import android.os.Looper +import android.os.OperationCanceledException +import android.util.DisplayMetrics +import android.util.TypedValue +import android.view.Display +import android.view.View +import android.view.WindowInsetsController +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.PlatformVideoWithTime +import com.futo.platformplayer.others.PlatformLinkMovementMethod +import com.futo.platformplayer.states.StatePlatform +import org.json.JSONArray +import org.json.JSONObject +import userpackage.Protocol +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import java.util.* +import java.util.concurrent.ThreadLocalRandom +import kotlin.math.abs +import kotlin.math.min + +private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz "; +fun getRandomString(sizeOfRandomString: Int): String { + val random = Random(); + val sb = StringBuilder(sizeOfRandomString); + for (i in 0 until sizeOfRandomString) + sb.append(_allowedCharacters[random.nextInt(_allowedCharacters.length)]); + return sb.toString() +} + +fun getRandomStringRandomLength(minLength: Int, maxLength: Int): String { + if (maxLength == minLength) + return getRandomString(minLength); + return getRandomString(ThreadLocalRandom.current().nextInt(minLength, maxLength)); +} + +fun findNonRuntimeException(ex: Throwable?): Throwable? { + if(ex == null) + return null; + if(ex is java.lang.RuntimeException) + return findNonRuntimeException(ex.cause) + else + return ex; +} + +fun ensureNotMainThread() { + if (Looper.myLooper() == Looper.getMainLooper()) { + Logger.e("Utility", "Throwing exception because a function that should not be called on main thread, is called on main thread") + throw IllegalStateException("Cannot run on main thread") + } +} + +private val _regexHexColor = Regex("(#[a-fA-F0-9]{8})|(#[a-fA-F0-9]{6})|(#[a-fA-F0-9]{3})"); +fun String.isHexColor(): Boolean { + return _regexHexColor.matches(this); +} + +fun IPlatformVideo.withTimestamp(sec: Long) = PlatformVideoWithTime(this, sec); + + +fun loadBitmap(url: String): Bitmap { + try { + val client = ManagedHttpClient(); + val response = client.get(url); + if (response.isOk && response.body != null) { + val bitmapStream = response.body.byteStream(); + val bitmap = BitmapFactory.decodeStream(bitmapStream); + return bitmap; + } else { + throw Exception("Failed to find data at URL."); + } + } catch (e: Throwable) { + Logger.w("Utility", "Exception thrown while downloading bitmap.", e); + throw e; + } +} + +fun TextView.setPlatformPlayerLinkMovementMethod(context: Context) { + this.movementMethod = PlatformLinkMovementMethod(context); +} + +fun InputStream.copyToOutputStream(outputStream: OutputStream, isCancelled: (() -> Boolean)? = null) { + val buffer = ByteArray(16384); + var n: Int; + var total = 0; + + while (read(buffer).also { n = it } >= 0) { + if (isCancelled != null && isCancelled()) { + throw OperationCanceledException("Copy stream was cancelled."); + } + + total += n; + outputStream.write(buffer, 0, n); + } +} + +fun InputStream.copyToOutputStream(inputStreamLength: Long, outputStream: OutputStream, onProgress: (Float) -> Unit) { + val buffer = ByteArray(16384); + var n: Int; + var total = 0; + val inputStreamLengthFloat = inputStreamLength.toFloat(); + + while (read(buffer).also { n = it } >= 0) { + total += n; + outputStream.write(buffer, 0, n); + onProgress.invoke(total.toFloat() / inputStreamLengthFloat); + } +} + +fun Activity.setNavigationBarColorAndIcons() { + window.navigationBarColor = ContextCompat.getColor(this, android.R.color.black); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + window.insetsController?.setSystemBarsAppearance(0, WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS); + } else { + val decorView = window.decorView; + var systemUiVisibility = decorView.systemUiVisibility; + systemUiVisibility = systemUiVisibility and View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR.inv(); + decorView.systemUiVisibility = systemUiVisibility; + } +} + +fun Int.dp(resources: Resources): Int { + return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, this.toFloat(), resources.displayMetrics).toInt() +} + +fun Int.sp(resources: Resources): Int { + return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, this.toFloat(), resources.displayMetrics).toInt() +} + +fun File.share(context: Context) { + val uri = FileProvider.getUriForFile(context, context.resources.getString(R.string.authority), this); + + val shareIntent = Intent(); + shareIntent.action = Intent.ACTION_SEND; + shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + shareIntent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY); + shareIntent.setDataAndType(uri, context.contentResolver.getType(uri)); + shareIntent.putExtra(Intent.EXTRA_STREAM, uri); + + val chooserIntent = Intent.createChooser(shareIntent, "Share"); + chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(chooserIntent); +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/activities/AddSourceActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/AddSourceActivity.kt new file mode 100644 index 00000000..e63a9866 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/activities/AddSourceActivity.kt @@ -0,0 +1,221 @@ +package com.futo.platformplayer.activities + +import android.content.Intent +import android.graphics.drawable.Animatable +import android.os.Bundle +import android.view.View +import android.widget.* +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.futo.platformplayer.* +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePlugins +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.views.sources.SourceHeaderView +import com.futo.platformplayer.views.sources.SourceInfoView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerializationException + +class AddSourceActivity : AppCompatActivity() { + private val TAG = "AddSourceActivity"; + + private lateinit var _buttonBack: ImageButton; + + private lateinit var _sourceHeader: SourceHeaderView; + + private lateinit var _sourcePermissions: LinearLayout; + private lateinit var _sourceWarnings: LinearLayout; + + private lateinit var _container: ScrollView; + private lateinit var _loader: ImageView; + + private lateinit var _buttonCancel: TextView; + private lateinit var _buttonInstall: LinearLayout; + + private var _isLoading: Boolean = false; + + private val _client = ManagedHttpClient(); + + private var _config : SourcePluginConfig? = null; + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + + if(!FragmentedStorage.isInitialized) + FragmentedStorage.initialize(filesDir); + if(StateApp.instance.scopeOrNull == null) + StateApp.instance.setGlobalContext(this, lifecycleScope); + + if(!StatePlatform.instance.hasClients) + lifecycleScope.launch { + StatePlatform.instance.updateAvailableClients(this@AddSourceActivity, false); + } + + setContentView(R.layout.activity_add_source); + setNavigationBarColorAndIcons(); + + _buttonBack = findViewById(R.id.button_back); + + _sourceHeader = findViewById(R.id.source_header); + + _sourcePermissions = findViewById(R.id.source_permissions); + _sourceWarnings = findViewById(R.id.source_warnings); + + _container = findViewById(R.id.configContainer); + _loader = findViewById(R.id.loader); + + _buttonCancel = findViewById(R.id.button_cancel); + _buttonInstall = findViewById(R.id.button_install); + + _buttonBack.setOnClickListener { + onBackPressed(); + }; + _buttonCancel.setOnClickListener { + onBackPressed(); + } + _buttonInstall.setOnClickListener { + _config?.let { + install(_config!!); + }; + }; + + setLoading(true); + + onNewIntent(intent); + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + var url = intent?.dataString; + + if(url == null) + UIDialogs.showDialog(this, R.drawable.ic_error, "No valid URL provided..", null, null, + 0, UIDialogs.Action("Ok", { finish() }, UIDialogs.ActionStyle.PRIMARY)); + else { + if(url.startsWith("vfuto://")) + url = "https://" + url.substring("vfuto://".length); + loadConfigUrl(url); + } + } + + fun clear() { + _sourceHeader.clear(); + _sourcePermissions.removeAllViews(); + _sourceWarnings.removeAllViews(); + } + + fun loadConfigUrl(url: String) { + setLoading(true); + + lifecycleScope.launch(Dispatchers.IO) { + try { + val configResp = _client.get(url); + if(!configResp.isOk) + throw IllegalStateException("Failed request with ${configResp.code}"); + val configJson = configResp.body?.string(); + if(configJson.isNullOrEmpty()) + throw IllegalStateException("No response"); + val config = SourcePluginConfig.fromJson(configJson, url); + + withContext(Dispatchers.Main) { + loadConfig(config); + } + } + catch(ex: SerializationException) { + Logger.e(TAG, "Failed decode config", ex); + withContext(Dispatchers.Main) { + UIDialogs.showDialog(this@AddSourceActivity, R.drawable.ic_error, + "Invalid Config Format", null, null, + 0, UIDialogs.Action("Ok", { finish() }, UIDialogs.ActionStyle.PRIMARY)); + }; + } + catch(ex: Exception) { + Logger.e(TAG, "Failed fetch config", ex); + withContext(Dispatchers.Main) { + UIDialogs.showGeneralErrorDialog(this@AddSourceActivity, "Failed to fetch configuration", ex); + }; + } + }; + } + + fun loadConfig(config: SourcePluginConfig) { + _config = config; + + _sourceHeader.loadConfig(config); + _sourcePermissions.removeAllViews(); + _sourceWarnings.removeAllViews(); + + if(!config.allowUrls.isEmpty()) + _sourcePermissions.addView( + SourceInfoView(this, + R.drawable.ic_language, + "URL Access", + "The plugin will have access to the following domains", + config.allowUrls, true) + ) + + if(config.allowEval) + _sourcePermissions.addView( + SourceInfoView(this, + R.drawable.ic_code, + "Eval Access", + "The plugin will have access to eval capability (remote injection)", + config.allowUrls, true) + ) + + val pastelRed = resources.getColor(R.color.pastel_red); + + for(warning in config.getWarnings()) + _sourceWarnings.addView( + SourceInfoView(this, + R.drawable.ic_security_pred, + warning.first, + warning.second) + .withDescriptionColor(pastelRed)); + + setLoading(false); + } + + fun install(config: SourcePluginConfig) { + StatePlugins.instance.installPlugin(this, lifecycleScope, config) { + if(it) + backToSources(); + } + } + + fun backToSources() { + this@AddSourceActivity.startActivity(MainActivity.getTabIntent(this, "Sources")); + finish(); + } + + + fun setLoading(loading: Boolean) { + _isLoading = loading; + if(loading) { + _container.visibility = View.GONE; + _loader.visibility = View.VISIBLE; + (_loader.drawable as Animatable?)?.start() + } + else { + _container.visibility = View.VISIBLE; + _loader.visibility = View.GONE; + (_loader.drawable as Animatable?)?.stop() + } + } + + override fun onResume() { + super.onResume(); + if(_isLoading) + (_loader.drawable as Animatable?)?.start() + } + override fun onPause() { + super.onPause() + (_loader.drawable as Animatable?)?.start() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/activities/AddSourceOptionsActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/AddSourceOptionsActivity.kt new file mode 100644 index 00000000..da4a563b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/activities/AddSourceOptionsActivity.kt @@ -0,0 +1,51 @@ +package com.futo.platformplayer.activities + +import android.os.Bundle +import android.widget.* +import androidx.appcompat.app.AppCompatActivity +import com.futo.platformplayer.* +import com.futo.platformplayer.views.buttons.BigButton +import com.google.zxing.integration.android.IntentIntegrator +import com.journeyapps.barcodescanner.CaptureActivity + +class AddSourceOptionsActivity : AppCompatActivity() { + lateinit var _buttonBack: ImageButton; + + lateinit var _buttonQR: BigButton; + lateinit var _buttonURL: BigButton; + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_add_source_options); + setNavigationBarColorAndIcons(); + + _buttonBack = findViewById(R.id.button_back); + + _buttonQR = findViewById(R.id.option_qr); + _buttonURL = findViewById(R.id.option_url); + + _buttonBack.setOnClickListener { + finish(); + }; + + _buttonQR.onClick.subscribe { + val integrator = IntentIntegrator(this); + integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE) + integrator.setPrompt("Scan a QR Code") + integrator.setOrientationLocked(true); + integrator.setCameraId(0) + integrator.setBeepEnabled(false) + integrator.setBarcodeImageEnabled(true) + integrator.setCaptureActivity(QRCaptureActivity::class.java); + integrator.initiateScan() + } + _buttonURL.onClick.subscribe { + UIDialogs.toast(this, "Not implemented yet.."); + } + } + + + class QRCaptureActivity: CaptureActivity() { + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/activities/DeveloperActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/DeveloperActivity.kt new file mode 100644 index 00000000..53215522 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/activities/DeveloperActivity.kt @@ -0,0 +1,36 @@ +package com.futo.platformplayer.activities + +import android.os.Bundle +import android.widget.ImageButton +import androidx.appcompat.app.AppCompatActivity +import com.futo.platformplayer.* +import com.futo.platformplayer.views.fields.FieldForm + +class DeveloperActivity : AppCompatActivity() { + private lateinit var _form: FieldForm; + private lateinit var _buttonBack: ImageButton; + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_dev); + setNavigationBarColorAndIcons(); + + _buttonBack = findViewById(R.id.button_back); + _form = findViewById(R.id.settings_form); + + _form.fromObject(SettingsDev.instance); + _form.onChanged.subscribe { field, value -> + _form.setObjectValues(); + SettingsDev.instance.save(); + }; + + _buttonBack.setOnClickListener { + finish(); + } + } + + override fun finish() { + super.finish() + overridePendingTransition(R.anim.slide_lighten, R.anim.slide_out_up) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/activities/ExceptionActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/ExceptionActivity.kt new file mode 100644 index 00000000..55d222d1 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/activities/ExceptionActivity.kt @@ -0,0 +1,140 @@ +package com.futo.platformplayer.activities + +import android.content.Intent +import android.os.Bundle +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.futo.platformplayer.* +import com.futo.platformplayer.logging.LogLevel +import com.futo.platformplayer.logging.Logging +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.BufferedWriter +import java.io.File +import java.io.FileWriter + +class ExceptionActivity : AppCompatActivity() { + private lateinit var _exText: TextView; + private lateinit var _buttonShare: LinearLayout; + private lateinit var _buttonSubmit: LinearLayout; + private lateinit var _buttonRestart: LinearLayout; + private lateinit var _buttonClose: LinearLayout; + private var _file: File? = null; + private var _submitted = false; + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_exception); + setNavigationBarColorAndIcons(); + + _exText = findViewById(R.id.ex_text); + _buttonShare = findViewById(R.id.button_share); + _buttonSubmit = findViewById(R.id.button_submit); + _buttonRestart = findViewById(R.id.button_restart); + _buttonClose = findViewById(R.id.button_close); + + val context = intent.getStringExtra(EXTRA_CONTEXT) ?: "Unknown Context"; + val stack = intent.getStringExtra(EXTRA_STACK) ?: "Something went wrong... missing stack trace?"; + + val exceptionString = "Version information (version_name = ${BuildConfig.VERSION_NAME}, version_code = ${BuildConfig.VERSION_CODE}, flavor = ${BuildConfig.FLAVOR}, build_type = ${BuildConfig.BUILD_TYPE})\n\n" + + Logging.buildLogString(LogLevel.ERROR, TAG, "Uncaught exception (\"$context\"): $stack"); + try { + val file = File(filesDir, "log.txt"); + if (!file.exists()) { + file.createNewFile(); + } + + BufferedWriter(FileWriter(file, true)).use { + it.appendLine(exceptionString); + }; + + _file = file + } catch (e: Throwable) { + //Ignored + } + + _exText.text = stack; + + _buttonSubmit.setOnClickListener { + submitFile(); + } + + _buttonShare.setOnClickListener { + share(exceptionString); + }; + + _buttonRestart.setOnClickListener { + startActivity(Intent(this, MainActivity::class.java)); + }; + _buttonClose.setOnClickListener { + finish(); + }; + } + + private fun submitFile() { + if (_submitted) { + Toast.makeText(this, "Logs already submitted.", Toast.LENGTH_LONG).show(); + return; + } + + val file = _file; + if (file == null) { + Toast.makeText(this, "No logs found.", Toast.LENGTH_LONG).show(); + return; + } + + lifecycleScope.launch(Dispatchers.IO) { + var id: String? = null; + + try { + id = Logging.submitLog(file); + } catch (e: Throwable) { + //Ignored + } + + withContext(Dispatchers.Main) { + if (id == null) { + try { + Toast.makeText(this@ExceptionActivity, "Failed automated share, share manually?", Toast.LENGTH_LONG).show(); + } catch (e: Throwable) { + //Ignored + } + } else { + _submitted = true; + file.delete(); + Toast.makeText(this@ExceptionActivity, "Shared $id", Toast.LENGTH_LONG).show(); + } + } + } + } + + private fun share(exceptionString: String) { + try { + val i = Intent(Intent.ACTION_SEND); + i.type = "text/plain"; + i.putExtra(Intent.EXTRA_EMAIL, arrayOf("grayjay@futo.org")); + i.putExtra(Intent.EXTRA_SUBJECT, "Unhandled exception in VS"); + i.putExtra(Intent.EXTRA_TEXT, exceptionString); + + startActivity(Intent.createChooser(i, "Send exception to developers...")); + } catch (e: Throwable) { + //Ignored + + } + } + + override fun finish() { + super.finish() + overridePendingTransition(R.anim.slide_lighten, R.anim.slide_out_up) + } + + companion object { + private const val TAG = "ExceptionActivity"; + val EXTRA_CONTEXT = "CONTEXT"; + val EXTRA_STACK = "STACK"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/activities/LoginActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/LoginActivity.kt new file mode 100644 index 00000000..f26317d1 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/activities/LoginActivity.kt @@ -0,0 +1,117 @@ +package com.futo.platformplayer.activities + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.webkit.CookieManager +import android.webkit.WebView +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.futo.platformplayer.* +import com.futo.platformplayer.api.media.platforms.js.SourceAuth +import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.others.LoginWebViewClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +class LoginActivity : AppCompatActivity() { + private lateinit var _webView: WebView; + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_login); + setNavigationBarColorAndIcons(); + + _webView = findViewById(R.id.web_view); + _webView.settings.javaScriptEnabled = true; + CookieManager.getInstance().setAcceptCookie(true); + + val config = if(intent.hasExtra("plugin")) + Json.decodeFromString(intent.getStringExtra("plugin")!!); + else null; + + val authConfig = if(config != null) + config.authentication ?: throw IllegalStateException("Plugin has no authentication support"); + else if(intent.hasExtra("auth")) + Json.decodeFromString(intent.getStringExtra("auth")!!); + else throw IllegalStateException("No valid configuration?"); + //TODO: Backwards compat removal? + + _webView.settings.userAgentString = authConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36"; + _webView.settings.useWideViewPort = true; + _webView.settings.loadWithOverviewMode = true; + + val webViewClient = if(config != null) LoginWebViewClient(config) else LoginWebViewClient(authConfig); + + webViewClient.onLogin.subscribe { auth -> + _callback?.let { + _callback = null; + it.invoke(auth); + } + finish(); + }; + var isFirstLoad = true; + webViewClient.onPageLoaded.subscribe { view, url -> + if(!isFirstLoad) + return@subscribe; + isFirstLoad = false; + + if(!authConfig.loginButton.isNullOrEmpty() && authConfig.loginButton.matches(REGEX_LOGIN_BUTTON)) { + Logger.i(TAG, "Clicking login button [${authConfig.loginButton}]"); + //TODO: Find most reliable way to wait for page js to finish + view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {}); + } + } + //TODO: Required for some...TBD what to do with it. Clear on finish? + _webView.settings.domStorageEnabled = true; + + _webView.webViewClient = webViewClient; + _webView.loadUrl(authConfig.loginUrl); + } + + override fun finish() { + lifecycleScope.launch(Dispatchers.Main) { + _webView?.loadUrl("about:blank"); + } + _callback?.let { + _callback = null; + it.invoke(null); + } + super.finish(); + } + + companion object { + private val TAG = "LoginActivity"; + private val REGEX_LOGIN_BUTTON = Regex("[a-zA-Z\\-\\.#_ ]*"); + + private var _callback: ((SourceAuth?) -> Unit)? = null; + + fun getLoginIntent(context: Context, authConfig: SourcePluginAuthConfig): Intent { + val intent = Intent(context, LoginActivity::class.java); + intent.putExtra("auth", Json.encodeToString(authConfig)); + return intent; + } + fun getLoginIntent(context: Context, config: SourcePluginConfig): Intent { + val intent = Intent(context, LoginActivity::class.java); + intent.putExtra("plugin", Json.encodeToString(config)); + return intent; + } + + fun showLogin(context: Context, authConfig: SourcePluginAuthConfig, callback: ((SourceAuth?) -> Unit)? = null) { + if(_callback != null) _callback?.invoke(null); + _callback = callback; + context.startActivity(getLoginIntent(context, authConfig)); + } + fun showLogin(context: Context, config: SourcePluginConfig, callback: ((SourceAuth?) -> Unit)? = null) { + if(_callback != null) _callback?.invoke(null); + _callback = callback; + context.startActivity(getLoginIntent(context, config)); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt new file mode 100644 index 00000000..95ae8a9d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -0,0 +1,906 @@ +package com.futo.platformplayer.activities + +import android.content.Context +import android.content.Intent +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import android.content.pm.ActivityInfo +import android.content.res.Configuration +import android.net.Uri +import android.os.Bundle +import android.util.TypedValue +import android.view.View +import android.widget.FrameLayout +import androidx.appcompat.app.AppCompatActivity +import androidx.constraintlayout.motion.widget.MotionLayout +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentContainerView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import com.futo.platformplayer.* +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.channels.SerializedChannel +import com.futo.platformplayer.casting.StateCasting +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment +import com.futo.platformplayer.fragment.mainactivity.main.* +import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment +import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment +import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment +import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment +import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment +import com.futo.platformplayer.listeners.OrientationManager +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.UrlVideoWithTime +import com.futo.platformplayer.states.* +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.SubscriptionStorage +import com.futo.platformplayer.stores.v2.ManagedStore +import com.google.gson.JsonParser +import kotlinx.coroutines.* +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import java.io.File +import java.io.PrintWriter +import java.io.StringWriter +import java.lang.reflect.InvocationTargetException +import java.util.* + +class MainActivity : AppCompatActivity { + + //TODO: Move to dimensions + private val HEIGHT_MENU_DP = 48f; + private val HEIGHT_VIDEO_MINIMIZED_DP = 60f; + + //Containers + lateinit var rootView : MotionLayout; + + private lateinit var _overlayContainer: FrameLayout; + + //Segment Containers + private lateinit var _fragContainerTopBar: FragmentContainerView; + private lateinit var _fragContainerMain: FragmentContainerView; + private lateinit var _fragContainerBotBar: FragmentContainerView; + private lateinit var _fragContainerVideoDetail: FragmentContainerView; + private lateinit var _fragContainerOverlay: FrameLayout; + + //Frags TopBar + lateinit var _fragTopBarGeneral: GeneralTopBarFragment; + lateinit var _fragTopBarSearch: SearchTopBarFragment; + lateinit var _fragTopBarNavigation: NavigationTopBarFragment; + lateinit var _fragTopBarImport: ImportTopBarFragment; + lateinit var _fragTopBarAdd: AddTopBarFragment; + + //Frags BotBar + lateinit var _fragBotBarMenu: MenuBottomBarFragment; + + //Frags Main + lateinit var _fragMainHome: HomeFragment; + lateinit var _fragPostDetail: PostDetailFragment; + lateinit var _fragMainVideoSearchResults: ContentSearchResultsFragment; + lateinit var _fragMainCreatorSearchResults: CreatorSearchResultsFragment; + lateinit var _fragMainPlaylistSearchResults: PlaylistSearchResultsFragment; + lateinit var _fragMainSuggestions: SuggestionsFragment; + lateinit var _fragMainSubscriptions: CreatorsFragment; + lateinit var _fragMainSubscriptionsFeed: SubscriptionsFeedFragment; + lateinit var _fragMainChannel: ChannelFragment; + lateinit var _fragMainSources: SourcesFragment; + lateinit var _fragMainPlaylists: PlaylistsFragment; + lateinit var _fragMainPlaylist: PlaylistFragment; + lateinit var _fragWatchlist: WatchLaterFragment; + lateinit var _fragHistory: HistoryFragment; + lateinit var _fragSourceDetail: SourceDetailFragment; + lateinit var _fragDownloads: DownloadsFragment; + lateinit var _fragImportSubscriptions: ImportSubscriptionsFragment; + lateinit var _fragImportPlaylists: ImportPlaylistsFragment; + lateinit var _fragBuy: BuyFragment; + + lateinit var _fragBrowser: BrowserFragment; + + //Frags Overlay + lateinit var _fragVideoDetail: VideoDetailFragment; + + //State + private val _queue : Queue> = LinkedList(); + lateinit var fragCurrent : MainFragment private set; + private var _parameterCurrent: Any? = null; + + var fragBeforeOverlay : MainFragment? = null; private set; + + val onNavigated = Event1(); + + private lateinit var _orientationManager: OrientationManager; + var orientation: OrientationManager.Orientation = OrientationManager.Orientation.PORTRAIT + private set; + private var _isVisible = true; + private var _wasStopped = false; + + constructor() : super() { + Thread.setDefaultUncaughtExceptionHandler { _, throwable -> + val writer = StringWriter(); + + var excp = throwable; + Logger.e("Application", "Uncaught", excp); + + //Resolve invocation chains + while(excp is InvocationTargetException || excp is java.lang.RuntimeException) { + val before = excp; + + if(excp is InvocationTargetException) + excp = excp.targetException ?: excp.cause ?: excp; + else if(excp is java.lang.RuntimeException) + excp = excp.cause ?: excp; + + if(excp == before) + break; + } + writer.write((excp.message ?: "Empty error") + "\n\n"); + excp.printStackTrace(PrintWriter(writer)); + val message = writer.toString(); + Logger.e(TAG, message, excp); + + val exIntent = Intent(this, ExceptionActivity::class.java); + exIntent.addFlags(FLAG_ACTIVITY_NEW_TASK); + exIntent.putExtra(ExceptionActivity.EXTRA_STACK, message); + startActivity(exIntent); + + Runtime.getRuntime().exit(0); + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + StateApp.instance.setGlobalContext(this, lifecycleScope); + StateApp.instance.mainAppStarting(this); + + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + setNavigationBarColorAndIcons(); + + runBlocking { + StatePlatform.instance.updateAvailableClients(this@MainActivity); + } + + //Preload common files to memory + FragmentedStorage.get(); + FragmentedStorage.get(); + + rootView = findViewById(R.id.rootView); + _fragContainerTopBar = findViewById(R.id.fragment_top_bar); + _fragContainerMain = findViewById(R.id.fragment_main); + _fragContainerBotBar = findViewById(R.id.fragment_bottom_bar); + _fragContainerVideoDetail = findViewById(R.id.fragment_overlay); + _fragContainerOverlay = findViewById(R.id.fragment_overlay_container); + _overlayContainer = findViewById(R.id.overlay_container); + //_overlayContainer.visibility = View.GONE; + + //Initialize fragments + + //TopBars + _fragTopBarGeneral = GeneralTopBarFragment.newInstance(); + _fragTopBarSearch = SearchTopBarFragment.newInstance(); + _fragTopBarNavigation = NavigationTopBarFragment.newInstance(); + _fragTopBarImport = ImportTopBarFragment.newInstance(); + _fragTopBarAdd = AddTopBarFragment.newInstance(); + + //BotBars + _fragBotBarMenu = MenuBottomBarFragment.newInstance(); + + //Main + _fragMainHome = HomeFragment.newInstance(); + _fragMainSuggestions = SuggestionsFragment.newInstance(); + _fragMainVideoSearchResults = ContentSearchResultsFragment.newInstance(); + _fragMainCreatorSearchResults = CreatorSearchResultsFragment.newInstance(); + _fragMainPlaylistSearchResults = PlaylistSearchResultsFragment.newInstance(); + _fragMainSubscriptions = CreatorsFragment.newInstance(); + _fragMainChannel = ChannelFragment.newInstance(); + _fragMainSubscriptionsFeed = SubscriptionsFeedFragment.newInstance(); + _fragMainSources = SourcesFragment.newInstance(); + _fragMainPlaylists = PlaylistsFragment.newInstance(); + _fragMainPlaylist = PlaylistFragment.newInstance(); + _fragPostDetail = PostDetailFragment.newInstance(); + _fragWatchlist = WatchLaterFragment.newInstance(); + _fragHistory = HistoryFragment.newInstance(); + _fragSourceDetail = SourceDetailFragment.newInstance(); + _fragDownloads = DownloadsFragment(); + _fragImportSubscriptions = ImportSubscriptionsFragment.newInstance(); + _fragImportPlaylists = ImportPlaylistsFragment.newInstance(); + _fragBuy = BuyFragment.newInstance(); + + _fragBrowser = BrowserFragment.newInstance(); + + //Overlays + _fragVideoDetail = VideoDetailFragment.newInstance(); + //Overlay Init + _fragVideoDetail.onMinimize.subscribe { }; + _fragVideoDetail.onShownEvent.subscribe { + _fragMainHome.setPreviewsEnabled(false); + _fragMainVideoSearchResults.setPreviewsEnabled(false); + _fragMainSubscriptionsFeed.setPreviewsEnabled(false); + }; + + + _fragVideoDetail.onMinimize.subscribe { + updateSegmentPaddings(); + }; + _fragVideoDetail.onTransitioning.subscribe { + if(it || _fragVideoDetail.state != VideoDetailFragment.State.MINIMIZED) + _fragContainerOverlay.elevation = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15f, resources.displayMetrics); + else + _fragContainerOverlay.elevation = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics); + } + + _fragVideoDetail.onCloseEvent.subscribe { + _fragMainHome.setPreviewsEnabled(true); + _fragMainVideoSearchResults.setPreviewsEnabled(true); + _fragMainSubscriptionsFeed.setPreviewsEnabled(true); + _fragContainerVideoDetail.visibility = View.INVISIBLE; + updateSegmentPaddings(); + }; + + StatePlayer.instance.also { + it.onQueueChanged.subscribe { shouldSwapCurrentItem -> + if (!shouldSwapCurrentItem) { + return@subscribe; + } + + if(_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) { + if (fragCurrent !is VideoDetailFragment) { + val toPlay = StatePlayer.instance.getCurrentQueueItem(); + navigate(_fragVideoDetail, toPlay); + + if (!StatePlayer.instance.queueFocused) + _fragVideoDetail.minimizeVideoDetail(); + } + } else { + val toPlay = StatePlayer.instance.getCurrentQueueItem() ?: return@subscribe; + Logger.i(TAG, "Queue changed _fragVideoDetail.currentUrl=${_fragVideoDetail.currentUrl} toPlay.url=${toPlay.url}") + if (_fragVideoDetail.currentUrl == null || _fragVideoDetail.currentUrl != toPlay.url) { + navigate(_fragVideoDetail, toPlay); + } + } + }; + } + + onNavigated.subscribe { + updateSegmentPaddings(); + } + + + //Set top bars + _fragMainHome.topBar = _fragTopBarGeneral; + _fragMainSubscriptions.topBar = _fragTopBarGeneral; + _fragMainSuggestions.topBar = _fragTopBarSearch; + _fragMainVideoSearchResults.topBar = _fragTopBarSearch; + _fragMainCreatorSearchResults.topBar = _fragTopBarSearch; + _fragMainPlaylistSearchResults.topBar = _fragTopBarSearch; + _fragMainChannel.topBar = _fragTopBarNavigation; + _fragMainSubscriptionsFeed.topBar = _fragTopBarGeneral; + _fragMainSources.topBar = _fragTopBarAdd; + _fragMainPlaylists.topBar = _fragTopBarGeneral; + _fragMainPlaylist.topBar = _fragTopBarNavigation; + _fragPostDetail.topBar = _fragTopBarNavigation; + _fragWatchlist.topBar = _fragTopBarNavigation; + _fragHistory.topBar = _fragTopBarNavigation; + _fragSourceDetail.topBar = _fragTopBarNavigation; + _fragDownloads.topBar = _fragTopBarGeneral; + _fragImportSubscriptions.topBar = _fragTopBarImport; + _fragImportPlaylists.topBar = _fragTopBarImport; + + _fragBrowser.topBar = _fragTopBarNavigation; + + fragCurrent = _fragMainHome; + + val defaultTab = Settings.instance.tabs.mapNotNull { + val buttonDefinition = MenuBottomBarFragment.buttonDefinitions.firstOrNull { bd -> it.id == bd.id }; + if (buttonDefinition == null) { + return@mapNotNull null; + } else { + return@mapNotNull Pair(it, buttonDefinition); + } + }.first { it.first.enabled }.second; + + supportFragmentManager.beginTransaction() + .replace(R.id.fragment_top_bar, _fragTopBarGeneral) + .replace(R.id.fragment_main, _fragMainHome) + .replace(R.id.fragment_bottom_bar, _fragBotBarMenu) + .replace(R.id.fragment_overlay, _fragVideoDetail) + .commitNow(); + + defaultTab.action(_fragBotBarMenu); + + _orientationManager = OrientationManager(this); + _orientationManager.onOrientationChanged.subscribe { + orientation = it; + Logger.i(TAG, "Orientation changed (Found ${it})"); + fragCurrent.onOrientationChanged(it); + if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED) + _fragVideoDetail.onOrientationChanged(it); + }; + _orientationManager.enable(); + + StateSubscriptions.instance; + + fragCurrent.onShown(null, false); + + //Other stuff + rootView.progress = 0f; + + handleIntent(intent); + + if (Settings.instance.casting.enabled) { + StateCasting.instance.start(this); + } + + StatePlatform.instance.onDevSourceChanged.subscribe { + Logger.i(TAG, "onDevSourceChanged") + + lifecycleScope.launch(Dispatchers.Main) { + try { + if (!_isVisible) { + val bringUpIntent = Intent(this@MainActivity, MainActivity::class.java); + bringUpIntent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + bringUpIntent.action = "TAB"; + bringUpIntent.putExtra("TAB", "Sources"); + startActivity(bringUpIntent); + } else { + _fragVideoDetail.closeVideoDetails(); + navigate(_fragMainSources); + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to make sources front.", e); + } + } + }; + + StateApp.instance.mainAppStarted(this); + + //if(ContextCompat.checkSelfPermission(this, Manifest.permission.MANAGE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) + // ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.MANAGE_EXTERNAL_STORAGE), 123); + //else + StateApp.instance.mainAppStartedWithExternalFiles(this); + + //startActivity(Intent(this, TestActivity::class.java)); + } + + /* + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if(requestCode != 123) + return; + + if(grantResults.size == 1 && grantResults[0] == PackageManager.PERMISSION_GRANTED) + StateApp.instance.mainAppStartedWithExternalFiles(this); + else { + UIDialogs.showDialog(this, R.drawable.ic_help, "File Permissions", "Grayjay requires file permissions for exporting downloads and automatic backups", null, 0, + UIDialogs.Action("Cancel", {}), + UIDialogs.Action("Configure", { + startActivity(Intent().apply { + action = android.provider.Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION; + data = Uri.fromParts("package", packageName, null) + }); + }, UIDialogs.ActionStyle.PRIMARY)); + } + UIDialogs.toast(this, "No external file permissions\nExporting and auto backups will not work"); + }*/ + + override fun onResume() { + super.onResume(); + Logger.i(TAG, "onResume") + + val curOrientation = _orientationManager.orientation; + + if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.lastOrientation != curOrientation) { + Logger.i(TAG, "Orientation mismatch (Found ${curOrientation})"); + orientation = curOrientation; + fragCurrent.onOrientationChanged(curOrientation); + if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED) + _fragVideoDetail.onOrientationChanged(curOrientation); + } + + _isVisible = true; + val videoToOpen = StateSaved.instance.videoToOpen; + + if (_wasStopped) { + Logger.i(TAG, "_wasStopped is true"); + Logger.i(TAG, "set _wasStopped = false"); + _wasStopped = false; + + Logger.i(TAG, "onResume videoToOpen=$videoToOpen"); + + if (videoToOpen != null && _fragVideoDetail.state == VideoDetailFragment.State.CLOSED) { + if (StatePlatform.instance.hasEnabledVideoClient(videoToOpen.url)) { + navigate(_fragVideoDetail, UrlVideoWithTime(videoToOpen.url, videoToOpen.timeSeconds, false)); + _fragVideoDetail.maximizeVideoDetail(true); + } + + StateSaved.instance.setVideoToOpenNonBlocking(null); + } + } + } + + override fun onPause() { + super.onPause(); + Logger.i(TAG, "onPause") + _isVisible = false; + } + + override fun onStop() { + super.onStop() + Logger.i(TAG, "_wasStopped = true"); + _wasStopped = true; + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent); + handleIntent(intent); + } + + private fun handleIntent(intent: Intent?) { + if(intent == null) + return; + Logger.i(TAG, "handleIntent started by " + intent.action); + + + var targetData: String? = null; + + when(intent.action) { + Intent.ACTION_SEND -> { + targetData = intent.getStringExtra(Intent.EXTRA_STREAM) ?: intent.getStringExtra(Intent.EXTRA_TEXT); + Logger.i(TAG, "Share Received: " + targetData); + } + Intent.ACTION_VIEW -> { + targetData = intent.dataString + + if(!targetData.isNullOrEmpty()) { + Logger.i(TAG, "View Received: " + targetData); + } + } + "TAB" -> { + when(intent.getStringExtra("TAB")){ + "Sources" -> { + runBlocking { + StatePlatform.instance.updateAvailableClients(this@MainActivity, true) //Ideally this is not needed.. + navigate(_fragMainSources); + } + }; + } + } + } + + try { + if (targetData != null) { + when(intent.scheme) { + "grayjay" -> { + if(targetData.startsWith("grayjay://license/")) { + if(StatePayment.instance.setPaymentLicenseUrl(targetData)) + { + UIDialogs.showDialogOk(this, R.drawable.ic_check, "Your license key has been set!\nAn app restart might be required."); + + if(fragCurrent is BuyFragment) + closeSegment(fragCurrent); + } + else + UIDialogs.toast("Invalid license format"); + + } + else if(targetData.startsWith("grayjay://plugin/")) { + val intent = Intent(this, AddSourceActivity::class.java).apply { + data = Uri.parse(targetData.substring("grayjay://plugin/".length)); + }; + startActivity(intent); + } + } + "content" -> { + if(!handleContent(targetData, intent.type)) { + UIDialogs.showSingleButtonDialog( + this, + R.drawable.ic_play, + "Unknown content format [${targetData}]", + "Ok", + { }); + } + } + "file" -> { + if(!handleFile(targetData)) { + UIDialogs.showSingleButtonDialog( + this, + R.drawable.ic_play, + "Unknown file format [${targetData}]", + "Ok", + { }); + } + } + "polycentric" -> { + if(!handlePolycentric(targetData)) { + UIDialogs.showSingleButtonDialog( + this, + R.drawable.ic_play, + "Unknown Polycentric format [${targetData}]", + "Ok", + { }); + } + } + else -> { + if (!handleUrl(targetData)) { + UIDialogs.showSingleButtonDialog( + this, + R.drawable.ic_play, + "Unknown url format [${targetData}]", + "Ok", + { }); + } + } + } + } + } + catch(ex: Throwable) { + UIDialogs.showGeneralErrorDialog(this, "Failed to handle file", ex); + } + } + + fun handleUrl(url: String): Boolean { + Logger.i(TAG, "handleUrl(url=$url)") + + if (StatePlatform.instance.hasEnabledVideoClient(url)) { + navigate(_fragVideoDetail, url); + _fragVideoDetail.maximizeVideoDetail(true); + return true; + } else if(StatePlatform.instance.hasEnabledChannelClient(url)) { + navigate(_fragMainChannel, url); + + lifecycleScope.launch { + delay(100); + _fragVideoDetail.minimizeVideoDetail(); + }; + return true; + } + return false; + } + fun handleContent(file: String, mime: String? = null): Boolean { + Logger.i(TAG, "handleContent(url=$file)"); + + val data = readSharedContent(file); + if(file.lowercase().endsWith(".json") || mime == "application/json") { + var recon = String(data); + if(!recon.trim().startsWith("[")) + return handleUnknownJson(file, recon); + + val reconLines = Json.decodeFromString>(recon); + recon = reconLines.joinToString("\n"); + Logger.i(TAG, "Opened shared playlist reconstruction\n${recon}"); + handleReconstruction(recon); + return true; + } + else if(file.lowercase().endsWith(".zip") || mime == "application/zip") { + StateBackup.importZipBytes(this, lifecycleScope, data); + return true; + } + return false; + } + fun handleFile(file: String): Boolean { + Logger.i(TAG, "handleFile(url=$file)"); + if(file.lowercase().endsWith(".json")) { + val recon = String(readSharedFile(file)); + if(!recon.startsWith("[")) + return handleUnknownJson(file, recon); + + Logger.i(TAG, "Opened shared playlist reconstruction\n${recon}"); + handleReconstruction(recon); + return true; + } + else if(file.lowercase().endsWith(".zip")) { + StateBackup.importZipBytes(this, lifecycleScope, readSharedFile(file)); + return true; + } + return false; + } + fun handleReconstruction(recon: String) { + val type = ManagedStore.getReconstructionIdentifier(recon); + val store: ManagedStore<*> = when(type) { + "Playlist" -> StatePlaylists.instance.playlistStore + else -> { + UIDialogs.toast("Unknown reconstruction type ${type}", false); + return; + }; + }; + val name = when(type) { + "Playlist" -> recon.split("\n").filter { !it.startsWith(ManagedStore.RECONSTRUCTION_HEADER_OPERATOR) }.firstOrNull() ?: type; + else -> type + } + + + if(!type.isNullOrEmpty()) { + UIDialogs.showImportDialog(this, store, name, listOf(recon)) { + + } + } + } + + fun handleUnknownJson(name: String?, json: String): Boolean { + + val context = this; + + //TODO: Proper import selection + try { + val newPipeSubsParsed = JsonParser.parseString(json).asJsonObject; + if (!newPipeSubsParsed.has("subscriptions") || !newPipeSubsParsed["subscriptions"].isJsonArray) + return false;//throw IllegalArgumentException("Invalid NewPipe json structure found"); + + val jsonSubs = newPipeSubsParsed["subscriptions"] + val jsonSubsArray = jsonSubs.asJsonArray; + val jsonSubsArrayItt = jsonSubsArray.iterator(); + val subs = mutableListOf() + while(jsonSubsArrayItt.hasNext()) { + val jsonSubObj = jsonSubsArrayItt.next().asJsonObject; + + if(jsonSubObj.has("url")) + subs.add(jsonSubObj["url"].asString); + } + + navigate(_fragImportSubscriptions, subs); + } + catch(ex: Exception) { + Logger.e(TAG, ex.message, ex); + UIDialogs.showGeneralErrorDialog(context, "Failed to parse NewPipe Subscriptions", ex); + } + + /* + lifecycleScope.launch(Dispatchers.Main) { + UISlideOverlays.showOverlay(_overlayContainer, "Import Json", "", {}, + SlideUpMenuGroup(context, "What kind of json import is this?", "", + SlideUpMenuItem(context, 0, "NewPipe Subscriptions", "", "NewPipeSubs", { + })) + ); + }*/ + + + return true; + } + + + fun handlePolycentric(url: String): Boolean { + Logger.i(TAG, "handlePolycentric"); + startActivity(Intent(this, PolycentricImportProfileActivity::class.java).apply { putExtra("url", url) }) + return true; + } + private fun readSharedContent(contentPath: String): ByteArray { + return contentResolver.openInputStream(Uri.parse(contentPath))?.use { + return it.readBytes(); + } ?: throw IllegalStateException("Opened content was not accessible"); + } + + private fun readSharedFile(filePath: String): ByteArray { + val dataFile = File(filePath); + if(!dataFile.exists()) + throw IllegalArgumentException("Opened file does not exist or not permitted"); + val data = dataFile.readBytes(); + return data; + } + + override fun onBackPressed() { + Logger.i(TAG, "onBackPressed") + + if(_fragBotBarMenu.onBackPressed()) + return; + + if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && + _fragVideoDetail.onBackPressed()) + return; + + + if(!fragCurrent.onBackPressed()) + closeSegment(); + } + + override fun onUserLeaveHint() { + super.onUserLeaveHint(); + Logger.i(TAG, "onUserLeaveHint") + + if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED || _fragVideoDetail.state == VideoDetailFragment.State.MINIMIZED) + _fragVideoDetail.onUserLeaveHint(); + } + + override fun onRestart() { + super.onRestart(); + Logger.i(TAG, "onRestart"); + + //Force Portrait on restart + Logger.i(TAG, "Restarted with state ${_fragVideoDetail.state}"); + if(_fragVideoDetail.state != VideoDetailFragment.State.MAXIMIZED) { + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; + WindowCompat.setDecorFitsSystemWindows(window, true) + WindowInsetsControllerCompat(window, rootView).let { controller -> + controller.show(WindowInsetsCompat.Type.statusBars()); + controller.show(WindowInsetsCompat.Type.systemBars()) + } + _fragVideoDetail.onOrientationChanged(OrientationManager.Orientation.PORTRAIT); + } + + Logger.i(TAG, "onRestart5"); + } + + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig); + + val isStop: Boolean = lifecycle.currentState == Lifecycle.State.CREATED; + Logger.i(TAG, "onPictureInPictureModeChanged isInPictureInPictureMode=$isInPictureInPictureMode isStop=$isStop") + _fragVideoDetail?.onPictureInPictureModeChanged(isInPictureInPictureMode, isStop, newConfig); + Logger.i(TAG, "onPictureInPictureModeChanged Ready"); + } + + override fun onDestroy() { + super.onDestroy(); + Logger.i(TAG, "onDestroy") + + _orientationManager.disable(); + + StateApp.instance.mainAppDestroyed(this); + StateSaved.instance.setVideoToOpenBlocking(null); + } + + + /** + * Navigate takes a MainFragment, and makes them the current main visible view + * A parameter can be provided which becomes available in the onShow of said fragment + */ + fun navigate(segment: MainFragment, parameter: Any? = null, withHistory: Boolean = true, isBack: Boolean = false) { + Logger.i(TAG, "Navigate to $segment (parameter=$parameter, withHistory=$withHistory, isBack=$isBack)") + + if(segment != fragCurrent) { + + if(segment is VideoDetailFragment) { + if(_fragContainerVideoDetail.visibility != View.VISIBLE) + _fragContainerVideoDetail.visibility = View.VISIBLE; + when(segment.state) { + VideoDetailFragment.State.MINIMIZED -> segment.maximizeVideoDetail() + VideoDetailFragment.State.CLOSED -> segment.maximizeVideoDetail() + else -> {} + } + segment.onShown(parameter, isBack); + return; + } + + + fragCurrent.onHide(); + + if(segment.isMainView) { + var transaction = supportFragmentManager.beginTransaction(); + if (segment.topBar != null) { + if (segment.topBar != fragCurrent.topBar) { + transaction = transaction + .show(segment.topBar as Fragment) + .replace(R.id.fragment_top_bar, segment.topBar as Fragment); + fragCurrent.topBar?.onHide(); + } + } + else if(fragCurrent.topBar != null) + transaction.hide(fragCurrent.topBar as Fragment); + + transaction = transaction.replace(R.id.fragment_main, segment); + + val extraBottomDP = if(_fragVideoDetail.state == VideoDetailFragment.State.MINIMIZED) HEIGHT_VIDEO_MINIMIZED_DP else 0f + if (segment.hasBottomBar) { + if (!fragCurrent.hasBottomBar) + transaction = transaction.show(_fragBotBarMenu); + } + else { + if(fragCurrent.hasBottomBar) + transaction = transaction.hide(_fragBotBarMenu); + } + transaction.commitNow(); + } + else { + //Special cases + if(segment is VideoDetailFragment) { + _fragContainerVideoDetail.visibility = View.VISIBLE; + _fragVideoDetail.maximizeVideoDetail(); + } + + if(!segment.hasBottomBar) { + supportFragmentManager.beginTransaction() + .hide(_fragBotBarMenu) + .commitNow(); + } + } + + if(fragCurrent.isHistory && withHistory && _queue.lastOrNull() != fragCurrent) + _queue.add(Pair(fragCurrent, _parameterCurrent)); + + if(segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory) + fragBeforeOverlay = fragCurrent; + + + fragCurrent = segment; + _parameterCurrent = parameter; + } + + segment.topBar?.onShown(parameter); + segment.onShown(parameter, isBack); + onNavigated.emit(segment); + } + + /** + * Called when the current segment (main) should be closed, if already at a root view (tab), close application + * If called with a non-null fragment, it will only close if the current fragment is the provided one + */ + fun closeSegment(fragment: MainFragment? = null) { + if(fragment is VideoDetailFragment) { + fragment.onHide(); + return; + } + + if((fragment?.isOverlay ?: false) && fragBeforeOverlay != null) { + navigate(fragBeforeOverlay!!, null, false, true); + + } + else { + val last = _queue.lastOrNull(); + if (last != null) { + _queue.remove(last); + navigate(last.first, last.second, false, true); + } else + finish(); + } + } + + /** + * Provides the fragment instance for the provided fragment class + */ + inline fun getFragment() : T { + return when(T::class) { + HomeFragment::class -> _fragMainHome as T; + ContentSearchResultsFragment::class -> _fragMainVideoSearchResults as T; + CreatorSearchResultsFragment::class -> _fragMainCreatorSearchResults as T; + SuggestionsFragment::class -> _fragMainSuggestions as T; + VideoDetailFragment::class -> _fragVideoDetail as T; + MenuBottomBarFragment::class -> _fragBotBarMenu as T; + GeneralTopBarFragment::class -> _fragTopBarGeneral as T; + SearchTopBarFragment::class -> _fragTopBarSearch as T; + CreatorsFragment::class -> _fragMainSubscriptions as T; + SubscriptionsFeedFragment::class -> _fragMainSubscriptionsFeed as T; + PlaylistSearchResultsFragment::class -> _fragMainPlaylistSearchResults as T; + ChannelFragment::class -> _fragMainChannel as T; + SourcesFragment::class -> _fragMainSources as T; + PlaylistsFragment::class -> _fragMainPlaylists as T; + PlaylistFragment::class -> _fragMainPlaylist as T; + PostDetailFragment::class -> _fragPostDetail as T; + WatchLaterFragment::class -> _fragWatchlist as T; + HistoryFragment::class -> _fragHistory as T; + SourceDetailFragment::class -> _fragSourceDetail as T; + DownloadsFragment::class -> _fragDownloads as T; + ImportSubscriptionsFragment::class -> _fragImportSubscriptions as T; + ImportPlaylistsFragment::class -> _fragImportPlaylists as T; + BrowserFragment::class -> _fragBrowser as T; + BuyFragment::class -> _fragBuy as T; + else -> throw IllegalArgumentException("Fragment type ${T::class.java.name} is not available in MainActivity"); + } + } + + + private fun updateSegmentPaddings() { + var paddingBottom = 0f; + if(fragCurrent.hasBottomBar) + paddingBottom += HEIGHT_MENU_DP; + + _fragContainerOverlay.setPadding(0,0,0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, paddingBottom - HEIGHT_MENU_DP, resources.displayMetrics).toInt()); + + if(_fragVideoDetail.state == VideoDetailFragment.State.MINIMIZED) + paddingBottom += HEIGHT_VIDEO_MINIMIZED_DP; + + _fragContainerMain.setPadding(0,0,0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, paddingBottom, resources.displayMetrics).toInt()); + } + + companion object { + private val TAG = "MainActivity" + + fun getTabIntent(context: Context, tab: String) : Intent { + val sourcesIntent = Intent(context, MainActivity::class.java); + sourcesIntent.action = "TAB"; + sourcesIntent.putExtra("TAB", tab); + sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + return sourcesIntent; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/activities/ManageTabsActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/ManageTabsActivity.kt new file mode 100644 index 00000000..9d928f2c --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/activities/ManageTabsActivity.kt @@ -0,0 +1,99 @@ +package com.futo.platformplayer.activities + +import android.os.Bundle +import android.widget.ImageButton +import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.MenuBottomBarSetting +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment +import com.futo.platformplayer.setNavigationBarColorAndIcons +import com.futo.platformplayer.views.AnyAdapterView +import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny +import com.futo.platformplayer.views.adapters.ItemMoveCallback +import com.futo.platformplayer.views.adapters.viewholders.TabViewHolder +import com.futo.platformplayer.views.adapters.viewholders.TabViewHolderData +import java.util.* + +class ManageTabsActivity : AppCompatActivity() { + private lateinit var _buttonBack: ImageButton; + private lateinit var _listTabs: AnyAdapterView; + private lateinit var _recyclerTabs: RecyclerView; + private lateinit var _touchHelper: ItemTouchHelper; + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_manage_tabs); + setNavigationBarColorAndIcons(); + + _buttonBack = findViewById(R.id.button_back); + + val callback = ItemMoveCallback(); + _touchHelper = ItemTouchHelper(callback); + _recyclerTabs = findViewById(R.id.recycler_tabs); + _touchHelper.attachToRecyclerView(_recyclerTabs); + + val itemsRemoved = Settings.instance.tabs.removeIf { MenuBottomBarFragment.buttonDefinitions.none { d -> it.id == d.id } } + + var itemsAdded = false + for (buttonDefinition in MenuBottomBarFragment.buttonDefinitions) { + if (Settings.instance.tabs.none { it.id == buttonDefinition.id }) { + Settings.instance.tabs.add(MenuBottomBarSetting(buttonDefinition.id, true)) + itemsAdded = true + } + } + + if (itemsAdded || itemsRemoved) { + Settings.instance.save() + } + + val items = Settings.instance.tabs.mapNotNull { + val buttonDefinition = MenuBottomBarFragment.buttonDefinitions.find { d -> it.id == d.id } ?: return@mapNotNull null + TabViewHolderData(buttonDefinition, it.enabled) + }; + + _listTabs = _recyclerTabs.asAny(items) { + it.onDragDrop.subscribe { vh -> + _touchHelper.startDrag(vh); + }; + it.onEnableChanged.subscribe { enabled -> + val d = it.data ?: return@subscribe + Settings.instance.tabs.find { def -> d.buttonDefinition.id == def.id }?.enabled = enabled + Settings.instance.onTabsChanged.emit() + Settings.instance.save() + }; + }; + + callback.onRowMoved.subscribe { fromPosition, toPosition -> + if (fromPosition < toPosition) { + for (i in fromPosition until toPosition) { + Collections.swap(items, i, i + 1) + Collections.swap(Settings.instance.tabs, i, i + 1) + } + + Settings.instance.onTabsChanged.emit() + Settings.instance.save() + } else { + for (i in fromPosition downTo toPosition + 1) { + Collections.swap(items, i, i - 1) + Collections.swap(Settings.instance.tabs, i, i - 1) + } + + Settings.instance.onTabsChanged.emit() + Settings.instance.save() + } + + _listTabs.adapter.notifyItemMoved(fromPosition, toPosition); + }; + + _buttonBack.setOnClickListener { + onBackPressed(); + }; + } + + companion object { + private const val TAG = "ManageTabsActivity"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt new file mode 100644 index 00000000..4413eb97 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt @@ -0,0 +1,175 @@ +package com.futo.platformplayer.activities + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Color +import android.os.Bundle +import android.util.TypedValue +import android.view.View +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import com.futo.platformplayer.R +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.setNavigationBarColorAndIcons +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.views.buttons.BigButton +import com.futo.polycentric.core.* +import com.google.zxing.BarcodeFormat +import com.google.zxing.MultiFormatWriter +import com.google.zxing.common.BitMatrix +import userpackage.Protocol +import userpackage.Protocol.ExportBundle +import userpackage.Protocol.URLInfo + +class PolycentricBackupActivity : AppCompatActivity() { + private lateinit var _buttonShare: BigButton; + private lateinit var _buttonCopy: BigButton; + private lateinit var _imageQR: ImageView; + private lateinit var _exportBundle: String; + private lateinit var _textQR: TextView; + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_polycentric_backup); + setNavigationBarColorAndIcons(); + + _buttonShare = findViewById(R.id.button_share); + _buttonCopy = findViewById(R.id.button_copy); + _imageQR = findViewById(R.id.image_qr); + _textQR = findViewById(R.id.text_qr); + findViewById(R.id.button_back).setOnClickListener { + finish(); + }; + + _exportBundle = createExportBundle(); + + try { + val dimension = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics).toInt(); + val qrCodeBitmap = generateQRCode(_exportBundle, dimension, dimension); + _imageQR.setImageBitmap(qrCodeBitmap); + } catch (e: Exception) { + Logger.e(TAG, "Failed to generate QR code", e); + _imageQR.visibility = View.INVISIBLE; + _textQR.visibility = View.INVISIBLE; + } + + _buttonShare.onClick.subscribe { + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain"; + putExtra(Intent.EXTRA_TEXT, _exportBundle); + } + startActivity(Intent.createChooser(shareIntent, "Share Text")); + }; + + _buttonCopy.onClick.subscribe { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager; + val clip = ClipData.newPlainText("Copied Text", _exportBundle); + clipboard.setPrimaryClip(clip); + }; + } + + private fun generateQRCode(content: String, width: Int, height: Int): Bitmap { + val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height); + return bitMatrixToBitmap(bitMatrix); + } + + private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap { + val width = matrix.width; + val height = matrix.height; + val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565); + + for (x in 0 until width) { + for (y in 0 until height) { + bmp.setPixel(x, y, if (matrix[x, y]) Color.BLACK else Color.WHITE); + } + } + return bmp; + } + + private fun createExportBundle(): String { + val processHandle = StatePolycentric.instance.processHandle!!; + + val relevantContentTypes = listOf(ContentType.SERVER.value, ContentType.AVATAR.value, ContentType.USERNAME.value); + val crdtSetItems = arrayListOf>(); + val crdtItems = arrayListOf>(); + + Store.instance.enumerateSignedEvents(processHandle.system) { signedEvent -> + if (!relevantContentTypes.contains(signedEvent.event.contentType)) { + return@enumerateSignedEvents; + } + + val event = signedEvent.event; + event.lwwElementSet?.let { lwwElementSet -> + val foundIndex = crdtSetItems.indexOfFirst { pair -> + pair.second.contentType == event.contentType && pair.second.value.contentEquals(lwwElementSet.value) + } + + var found = false + if (foundIndex != -1) { + val foundPair = crdtSetItems[foundIndex] + if (foundPair.second.unixMilliseconds < lwwElementSet.unixMilliseconds) { + foundPair.second.operation = lwwElementSet.operation + foundPair.second.unixMilliseconds = lwwElementSet.unixMilliseconds + found = true + } + } + + if (!found) { + crdtSetItems.add(Pair(signedEvent, StorageTypeCRDTSetItem(event.contentType, lwwElementSet.value, lwwElementSet.unixMilliseconds, lwwElementSet.operation))) + } + } + + event.lwwElement?.let { lwwElement -> + val foundIndex = crdtItems.indexOfFirst { pair -> + pair.second.contentType == event.contentType + } + + var found = false + if (foundIndex != -1) { + val foundPair = crdtItems[foundIndex] + if (foundPair.second.unixMilliseconds < lwwElement.unixMilliseconds) { + foundPair.second.value = lwwElement.value + foundPair.second.unixMilliseconds = lwwElement.unixMilliseconds + found = true + } + } + + if (!found) { + crdtItems.add(Pair(signedEvent, StorageTypeCRDTItem(event.contentType, lwwElement.value, lwwElement.unixMilliseconds))) + } + } + }; + + val relevantEvents = arrayListOf(); + for (pair in crdtSetItems) { + relevantEvents.add(pair.first); + } + + for (pair in crdtItems) { + relevantEvents.add(pair.first); + } + + val exportBundle = ExportBundle.newBuilder() + .setKeyPair(processHandle.processSecret.system.toProto()) + .setEvents(Protocol.Events.newBuilder() + .addAllEvents(relevantEvents.map { it.toProto() }) + .build()) + .build(); + + val urlInfo = URLInfo.newBuilder() + .setUrlType(3) + .setBody(exportBundle.toByteString()) + .build(); + + return "polycentric://" + urlInfo.toByteArray().toBase64Url() + } + + companion object { + private const val TAG = "PolycentricBackupActivity"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/activities/PolycentricCreateProfileActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/PolycentricCreateProfileActivity.kt new file mode 100644 index 00000000..a8889ad9 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/activities/PolycentricCreateProfileActivity.kt @@ -0,0 +1,93 @@ +package com.futo.platformplayer.activities + +import android.content.Intent +import android.os.Bundle +import android.widget.EditText +import android.widget.ImageButton +import android.widget.LinearLayout +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.setNavigationBarColorAndIcons +import com.futo.platformplayer.states.StatePolycentric +import com.futo.polycentric.core.ProcessHandle +import com.futo.polycentric.core.Store +import com.futo.polycentric.core.Synchronization +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class PolycentricCreateProfileActivity : AppCompatActivity() { + private lateinit var _buttonHelp: ImageButton; + private lateinit var _profileName: EditText; + private lateinit var _buttonCreate: LinearLayout; + private val TAG = "PolycentricCreateProfileActivity"; + + private var _creating = false; + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_polycentric_create_profile); + setNavigationBarColorAndIcons(); + + _buttonHelp = findViewById(R.id.button_help); + _profileName = findViewById(R.id.edit_profile_name); + _buttonCreate = findViewById(R.id.button_create_profile); + findViewById(R.id.button_back).setOnClickListener { + finish(); + }; + + _buttonHelp.setOnClickListener { + startActivity(Intent(this, PolycentricWhyActivity::class.java)); + }; + + _buttonCreate.setOnClickListener { + if (_creating) { + return@setOnClickListener; + } + + _creating = true; + + try { + val username = _profileName.text.toString(); + if (username.length < 3) { + UIDialogs.toast(this@PolycentricCreateProfileActivity, "Must be at least 3 characters long."); + return@setOnClickListener; + } + + lifecycleScope.launch(Dispatchers.IO) { + val processHandle: ProcessHandle; + + try { + processHandle = ProcessHandle.create(); + Store.instance.addProcessSecret(processHandle.processSecret); + processHandle.addServer("https://srv1-stg.polycentric.io"); + processHandle.setUsername(username); + StatePolycentric.instance.setProcessHandle(processHandle); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to create profile .", e); + return@launch; + } finally { + _creating = false; + } + + try { + processHandle.fullyBackfillServers(); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to fully backfill servers."); + } + + withContext(Dispatchers.Main) { + startActivity(Intent(this@PolycentricCreateProfileActivity, PolycentricProfileActivity::class.java)); + finish(); + } + } + } finally { + _creating = false; + } + }; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/activities/PolycentricHomeActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/PolycentricHomeActivity.kt new file mode 100644 index 00000000..2f691c80 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/activities/PolycentricHomeActivity.kt @@ -0,0 +1,94 @@ +package com.futo.platformplayer.activities + +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.util.TypedValue +import android.widget.ImageButton +import android.widget.LinearLayout +import androidx.appcompat.app.AppCompatActivity +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import com.futo.platformplayer.R +import com.futo.platformplayer.dp +import com.futo.platformplayer.selectBestImage +import com.futo.platformplayer.setNavigationBarColorAndIcons +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.views.buttons.BigButton +import com.futo.polycentric.core.Store +import com.futo.polycentric.core.SystemState +import com.futo.polycentric.core.toURLInfoSystemLinkUrl + +class PolycentricHomeActivity : AppCompatActivity() { + private lateinit var _buttonHelp: ImageButton; + private lateinit var _buttonNewProfile: BigButton; + private lateinit var _buttonImportProfile: BigButton; + private lateinit var _layoutButtons: LinearLayout; + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_polycentric_home); + setNavigationBarColorAndIcons(); + + _buttonHelp = findViewById(R.id.button_help); + _buttonNewProfile = findViewById(R.id.button_new_profile); + _buttonImportProfile = findViewById(R.id.button_import_profile); + _layoutButtons = findViewById(R.id.layout_buttons); + findViewById(R.id.button_back).setOnClickListener { + finish(); + }; + + for (processHandle in StatePolycentric.instance.getProcessHandles()) { + val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(processHandle.system)); + val profileButton = BigButton(this); + profileButton.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { + this.setMargins(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8f, resources.displayMetrics).toInt()); + }; + profileButton.withPrimaryText(systemState.username); + profileButton.withSecondaryText("Sign in to this identity"); + profileButton.onClick.subscribe { + StatePolycentric.instance.setProcessHandle(processHandle); + startActivity(Intent(this@PolycentricHomeActivity, PolycentricProfileActivity::class.java)); + finish(); + } + + val dp_32 = 32.dp(resources) + val avatarUrl = systemState.avatar.selectBestImage(dp_32 * dp_32)?.toURLInfoSystemLinkUrl(processHandle, systemState.servers.toList()); + Glide.with(profileButton) + .asBitmap() + .load(avatarUrl) + .placeholder(R.drawable.ic_loader) + .fallback(R.drawable.placeholder_profile) + .into(object : CustomTarget() { + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + profileButton.withIcon(resource, true) + } + override fun onLoadCleared(placeholder: Drawable?) { + profileButton.withIcon(R.drawable.placeholder_profile) + } + }) + + _layoutButtons.addView(profileButton, 0); + } + + _buttonHelp.setOnClickListener { + startActivity(Intent(this, PolycentricWhyActivity::class.java)); + }; + + _buttonNewProfile.onClick.subscribe { + startActivity(Intent(this, PolycentricCreateProfileActivity::class.java)); + finish(); + }; + + _buttonImportProfile.onClick.subscribe { + startActivity(Intent(this, PolycentricImportProfileActivity::class.java)); + finish(); + } + } + + companion object { + private const val TAG = "PolycentricHomeActivity"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/activities/PolycentricImportProfileActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/PolycentricImportProfileActivity.kt new file mode 100644 index 00000000..2a92cbf1 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/activities/PolycentricImportProfileActivity.kt @@ -0,0 +1,129 @@ +package com.futo.platformplayer.activities + +import android.content.Intent +import android.os.Bundle +import android.widget.EditText +import android.widget.ImageButton +import android.widget.LinearLayout +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.setNavigationBarColorAndIcons +import com.futo.platformplayer.states.StatePolycentric +import com.futo.polycentric.core.* +import com.google.zxing.integration.android.IntentIntegrator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import userpackage.Protocol +import userpackage.Protocol.ExportBundle + +class PolycentricImportProfileActivity : AppCompatActivity() { + private lateinit var _buttonHelp: ImageButton; + private lateinit var _buttonScanProfile: LinearLayout; + private lateinit var _buttonImportProfile: LinearLayout; + private lateinit var _editProfile: EditText; + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_polycentric_import_profile); + setNavigationBarColorAndIcons(); + + _buttonHelp = findViewById(R.id.button_help); + _buttonScanProfile = findViewById(R.id.button_scan_profile); + _buttonImportProfile = findViewById(R.id.button_import_profile); + _editProfile = findViewById(R.id.edit_profile); + findViewById(R.id.button_back).setOnClickListener { + finish(); + }; + + _buttonHelp.setOnClickListener { + startActivity(Intent(this, PolycentricWhyActivity::class.java)); + }; + + _buttonScanProfile.setOnClickListener { + val integrator = IntentIntegrator(this); + integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE); + integrator.setPrompt("Scan a QR code"); + integrator.initiateScan(); + }; + + _buttonImportProfile.setOnClickListener { + if (_editProfile.text.isEmpty()) { + UIDialogs.toast(this, "Text field does not contain any data"); + return@setOnClickListener; + } + + import(_editProfile.text.toString()); + }; + + val url = intent.getStringExtra("url"); + if (url != null) { + import(url); + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data) + if (result != null) { + if (result.contents != null) { + val scannedUrl = result.contents; + import(scannedUrl); + } + } else { + super.onActivityResult(requestCode, resultCode, data) + } + } + + private fun import(url: String) { + if (!url.startsWith("polycentric://")) { + UIDialogs.toast(this, "Not a valid URL"); + return; + } + + try { + val data = url.substring("polycentric://".length).base64UrlToByteArray(); + val urlInfo = Protocol.URLInfo.parseFrom(data); + if (urlInfo.urlType != 3L) { + throw Exception("Expected urlInfo struct of type ExportBundle") + } + + val exportBundle = ExportBundle.parseFrom(urlInfo.body); + val keyPair = KeyPair.fromProto(exportBundle.keyPair); + + val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey); + if (existingProcessSecret != null) { + UIDialogs.toast(this, "This profile is already imported"); + return; + } + + val processSecret = ProcessSecret(keyPair, Process.random()); + Store.instance.addProcessSecret(processSecret); + + val processHandle = processSecret.toProcessHandle(); + + for (e in exportBundle.events.eventsList) { + try { + val se = SignedEvent.fromProto(e); + Store.instance.putSignedEvent(se); + } catch (e: Throwable) { + Logger.w(TAG, "Ignored invalid event", e); + } + } + + StatePolycentric.instance.setProcessHandle(processHandle); + startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java)); + finish(); + } catch (e: Throwable) { + Logger.w(TAG, "Failed to import profile", e); + UIDialogs.toast(this, "Failed to import profile: '${e.message}'"); + } + } + + companion object { + private const val TAG = "PolycentricImportProfileActivity"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/activities/PolycentricProfileActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/PolycentricProfileActivity.kt new file mode 100644 index 00000000..67c66aae --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/activities/PolycentricProfileActivity.kt @@ -0,0 +1,283 @@ +package com.futo.platformplayer.activities + +import android.app.Activity +import android.content.ContentResolver +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Bundle +import android.webkit.MimeTypeMap +import android.widget.EditText +import android.widget.ImageButton +import android.widget.ImageView +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.dp +import com.futo.platformplayer.images.GlideHelper.Companion.crossfade +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.selectBestImage +import com.futo.platformplayer.setNavigationBarColorAndIcons +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.views.buttons.BigButton +import com.futo.polycentric.core.Store +import com.futo.polycentric.core.Synchronization +import com.futo.polycentric.core.SystemState +import com.futo.polycentric.core.toURLInfoDataLink +import com.github.dhaval2404.imagepicker.ImagePicker +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import userpackage.Protocol +import java.io.ByteArrayOutputStream +import java.io.InputStream + +class PolycentricProfileActivity : AppCompatActivity() { + private lateinit var _buttonHelp: ImageButton; + private lateinit var _editName: EditText; + private lateinit var _buttonExport: BigButton; + private lateinit var _buttonLogout: BigButton; + private lateinit var _buttonDelete: BigButton; + private lateinit var _username: String; + private lateinit var _imagePolycentric: ImageView; + private var _avatarUri: Uri? = null; + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_polycentric_profile); + setNavigationBarColorAndIcons(); + + _buttonHelp = findViewById(R.id.button_help); + _imagePolycentric = findViewById(R.id.image_polycentric); + _editName = findViewById(R.id.edit_profile_name); + _buttonExport = findViewById(R.id.button_export); + _buttonLogout = findViewById(R.id.button_logout); + _buttonDelete = findViewById(R.id.button_delete); + findViewById(R.id.button_back).setOnClickListener { + saveIfRequired(); + finish(); + }; + + lifecycleScope.launch(Dispatchers.IO) { + try { + val processHandle = StatePolycentric.instance.processHandle!!; + Synchronization.fullyBackFillClient(processHandle, processHandle.system, "https://srv1-stg.polycentric.io"); + + withContext(Dispatchers.Main) { + updateUI(); + } + } catch (e: Throwable) { + withContext(Dispatchers.Main) { + UIDialogs.toast(this@PolycentricProfileActivity, "Failed to backfill client"); + } + } + } + + updateUI(); + + _imagePolycentric.setOnClickListener { + ImagePicker.with(this) + .cropSquare() + .maxResultSize(256, 256) + .start(); + } + + _buttonHelp.setOnClickListener { + startActivity(Intent(this, PolycentricWhyActivity::class.java)); + }; + + _buttonExport.onClick.subscribe { + startActivity(Intent(this, PolycentricBackupActivity::class.java)); + }; + + _buttonLogout.onClick.subscribe { + StatePolycentric.instance.setProcessHandle(null); + startActivity(Intent(this, PolycentricHomeActivity::class.java)); + finish(); + } + + _buttonDelete.onClick.subscribe { + UIDialogs.showConfirmationDialog(this, "Are you sure you want to remove this profile?", { + val processHandle = StatePolycentric.instance.processHandle; + if (processHandle == null) { + UIDialogs.toast(this, "No process handle set"); + return@showConfirmationDialog; + } + + StatePolycentric.instance.setProcessHandle(null); + Store.instance.removeProcessSecret(processHandle.system); + startActivity(Intent(this, PolycentricHomeActivity::class.java)); + finish(); + }); + } + } + + private fun saveIfRequired() { + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + try { + var hasChanges = false; + val username = _editName.text.toString(); + if (username.length < 3) { + UIDialogs.toast(this@PolycentricProfileActivity, "Name must be at least 3 characters long"); + return@launch; + } + + val processHandle = StatePolycentric.instance.processHandle; + if (processHandle == null) { + UIDialogs.toast(this@PolycentricProfileActivity, "Process handle unset"); + return@launch; + } + + if (_username != username) { + _username = username; + processHandle.setUsername(username); + hasChanges = true; + } + + val avatarUri = _avatarUri; + if (avatarUri != null) { + val bytes = readBytesFromUri(applicationContext.contentResolver, avatarUri); + if (bytes == null) { + withContext(Dispatchers.Main) { + UIDialogs.toast(this@PolycentricProfileActivity, "Failed to read image"); + } + + return@launch; + } + + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size); + val imageBundleBuilder = Protocol.ImageBundle.newBuilder(); + val resolutions = arrayListOf(256, 128, 32); + for (resolution in resolutions) { + val image = Bitmap.createScaledBitmap(bitmap, resolution, resolution, true) + + val outputStream = ByteArrayOutputStream() + val originalMimeType = getMimeType(applicationContext.contentResolver, avatarUri) ?: "image/png" + val compressFormat = when(originalMimeType) { + "image/png" -> Pair(Bitmap.CompressFormat.PNG, "image/png") + "image/jpeg" -> Pair(Bitmap.CompressFormat.JPEG, "image/jpeg") + else -> Pair(Bitmap.CompressFormat.PNG, "image/png") + } + image.compress(compressFormat.first, 100, outputStream) + val imageBytes = outputStream.toByteArray() + + val imageRanges = processHandle.publishBlob(imageBytes) + val imageManifest = Protocol.ImageManifest.newBuilder() + .setMime(compressFormat.second) + .setWidth(image.width.toLong()) + .setHeight(image.height.toLong()) + .setByteCount(imageBytes.size.toLong()) + .setProcess(processHandle.processSecret.process.toProto()) + .addAllSections(imageRanges.map { it.toProto() }) + .build() + + imageBundleBuilder.addImageManifests(imageManifest) + } + + processHandle.setAvatar(imageBundleBuilder.build()) + hasChanges = true; + + _avatarUri = null; + } + + if (hasChanges) { + try { + processHandle.fullyBackfillServers(); + withContext(Dispatchers.Main) { + UIDialogs.toast(this@PolycentricProfileActivity, "Changes have been saved"); + } + } catch (e: Throwable) { + Logger.w(TAG, "Failed to synchronize changes", e); + withContext(Dispatchers.Main) { + UIDialogs.toast(this@PolycentricProfileActivity, "Failed to synchronize changes"); + } + } + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to save polycentric profile.", e) + } + } + } + + override fun onBackPressed() { + saveIfRequired(); + super.onBackPressed(); + } + + private fun updateUI() { + val processHandle = StatePolycentric.instance.processHandle!!; + val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(processHandle.system)) + _username = systemState.username; + _editName.text.clear(); + _editName.text.append(_username); + + val dp_80 = 80.dp(resources) + val avatar = systemState.avatar.selectBestImage(dp_80 * dp_80); + + Glide.with(_imagePolycentric) + .load(avatar?.toURLInfoDataLink(processHandle.system.toProto(), processHandle.processSecret.process.toProto(), systemState.servers.toList())) + .placeholder(R.drawable.placeholder_profile) + .crossfade() + .into(_imagePolycentric) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data); + + if (resultCode == Activity.RESULT_OK) { + val uri: Uri = data?.data!! + _imagePolycentric.setImageURI(uri); + _avatarUri = uri; + } else if (resultCode == ImagePicker.RESULT_ERROR) { + UIDialogs.toast(this, ImagePicker.getError(data)); + } else { + UIDialogs.toast(this, "Image picker cancelled"); + } + } + + private fun getMimeType(contentResolver: ContentResolver, uri: Uri): String? { + var mimeType: String? = null; + + // Try to get MIME type from the content URI + mimeType = contentResolver.getType(uri); + + // If the MIME type couldn't be determined from the content URI, try using the file extension + if (mimeType == null) { + val fileExtension = MimeTypeMap.getFileExtensionFromUrl(uri.toString()); + mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension.lowercase()); + } + + return mimeType; + } + + private fun readBytesFromUri(contentResolver: ContentResolver, uri: Uri): ByteArray? { + var inputStream: InputStream? = null; + val outputStream = ByteArrayOutputStream(); + + try { + inputStream = contentResolver.openInputStream(uri); + if (inputStream != null) { + val buffer = ByteArray(4096); + var bytesRead: Int; + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead); + } + return outputStream.toByteArray() + } + } catch (e: Exception) { + Logger.w(TAG, "Failed to read bytes from URI '${uri}'."); + } finally { + inputStream?.close(); + outputStream.close(); + } + return null + } + + companion object { + private const val TAG = "PolycentricProfileActivity"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/activities/PolycentricWhyActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/PolycentricWhyActivity.kt new file mode 100644 index 00000000..17c9acfd --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/activities/PolycentricWhyActivity.kt @@ -0,0 +1,41 @@ +package com.futo.platformplayer.activities + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.widget.ImageButton +import androidx.appcompat.app.AppCompatActivity +import com.futo.platformplayer.R +import com.futo.platformplayer.setNavigationBarColorAndIcons +import com.futo.platformplayer.views.buttons.BigButton + +class PolycentricWhyActivity : AppCompatActivity() { + private lateinit var _buttonVideo: BigButton; + private lateinit var _buttonTechnical: BigButton; + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_polycentric_why); + setNavigationBarColorAndIcons(); + + _buttonVideo = findViewById(R.id.button_video); + _buttonTechnical = findViewById(R.id.button_technical); + findViewById(R.id.button_back).setOnClickListener { + finish(); + }; + + _buttonVideo.onClick.subscribe { + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://www.youtube.com/watch?v=xYL96hb_p78")); + startActivity(browserIntent); + }; + + _buttonTechnical.onClick.subscribe { + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://docs.polycentric.io")); + startActivity(browserIntent); + }; + } + + companion object { + private const val TAG = "PolycentricWhyActivity"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/activities/SettingsActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/SettingsActivity.kt new file mode 100644 index 00000000..e3c4bed6 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/activities/SettingsActivity.kt @@ -0,0 +1,93 @@ +package com.futo.platformplayer.activities + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.ImageButton +import android.widget.LinearLayout +import androidx.appcompat.app.AppCompatActivity +import com.futo.platformplayer.* +import com.futo.platformplayer.views.fields.FieldForm +import com.futo.platformplayer.views.fields.ReadOnlyTextField +import com.google.android.material.button.MaterialButton + +class SettingsActivity : AppCompatActivity() { + private lateinit var _form: FieldForm; + private lateinit var _buttonBack: ImageButton; + + private lateinit var _devSets: LinearLayout; + private lateinit var _buttonDev: MaterialButton; + + private var _isFinished = false; + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_settings); + setNavigationBarColorAndIcons(); + + _form = findViewById(R.id.settings_form); + _buttonBack = findViewById(R.id.button_back); + _buttonDev = findViewById(R.id.button_dev); + _devSets = findViewById(R.id.dev_settings); + + _form.fromObject(Settings.instance); + _form.onChanged.subscribe { field, value -> + _form.setObjectValues(); + Settings.instance.save(); + }; + _buttonBack.setOnClickListener { + finish(); + } + + _buttonDev.setOnClickListener { + startActivity(Intent(this, DeveloperActivity::class.java)); + } + + var devCounter = 0; + _form.findField("code")?.assume()?.setOnClickListener { + devCounter++; + if(devCounter > 5) { + devCounter = 0; + SettingsDev.instance.developerMode = true; + SettingsDev.instance.save(); + updateDevMode(); + UIDialogs.toast(this, "You are now in developer mode"); + } + }; + _lastActivity = this; + } + + override fun onResume() { + super.onResume() + updateDevMode(); + } + + fun updateDevMode() { + if(SettingsDev.instance.developerMode) + _devSets.visibility = View.VISIBLE; + else + _devSets.visibility = View.GONE; + } + + override fun finish() { + super.finish() + _isFinished = true; + if(_lastActivity == this) + _lastActivity = null; + overridePendingTransition(R.anim.slide_lighten, R.anim.slide_out_up) + } + + companion object { + //TODO: Temporary for solving Settings issues + @SuppressLint("StaticFieldLeak") + private var _lastActivity: SettingsActivity? = null; + + fun getActivity(): SettingsActivity? { + val act = _lastActivity; + if(act != null && !act._isFinished) + return act; + return null; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/activities/TestActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/TestActivity.kt new file mode 100644 index 00000000..608bda0a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/activities/TestActivity.kt @@ -0,0 +1,16 @@ +package com.futo.platformplayer.activities + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.futo.platformplayer.R + +class TestActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_test); + } + + companion object { + private const val TAG = "TestActivity"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/http/ManagedHttpClient.kt b/app/src/main/java/com/futo/platformplayer/api/http/ManagedHttpClient.kt new file mode 100644 index 00000000..22705451 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/http/ManagedHttpClient.kt @@ -0,0 +1,276 @@ +package com.futo.platformplayer.api.http + +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.ensureNotMainThread +import com.futo.platformplayer.logging.Logger +import okhttp3.Call +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.ResponseBody +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import java.util.Dictionary +import java.util.concurrent.TimeUnit +import kotlin.system.measureTimeMillis + +open class ManagedHttpClient { + protected val _builderTemplate: OkHttpClient.Builder; + + private var client: OkHttpClient; + + private var onBeforeRequest : ((Request) -> Unit)? = null; + private var onAfterRequest : ((Request, Response) -> Unit)? = null; + + var user_agent = "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0" + + constructor(builder: OkHttpClient.Builder = OkHttpClient.Builder()) { + _builderTemplate = builder; + client = builder.build(); + } + + open fun clone(): ManagedHttpClient { + val clonedClient = ManagedHttpClient(_builderTemplate); + clonedClient.user_agent = user_agent; + return clonedClient; + } + + fun tryHead(url: String): Map? { + try { + val result = head(url); + if(result.isOk) + return result.getHeadersFlat(); + else + return null; + } + catch(ex: Throwable) { + //Ignore + return null; + } + } + + fun socket(url: String, headers: MutableMap = HashMap(), listener: SocketListener): Socket { + + val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder() + .url(url); + if(user_agent != null && !user_agent.isEmpty() && !headers.any { it.key.lowercase() == "user-agent" }) + requestBuilder.addHeader("User-Agent", user_agent) + + for (pair in headers.entries) + requestBuilder.header(pair.key, pair.value); + + val request = requestBuilder.build(); + + val websocket = client.newWebSocket(request, object: WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) { + super.onOpen(webSocket, response); + listener.open(); + } + override fun onMessage(webSocket: WebSocket, text: String) { + super.onMessage(webSocket, text) + listener.message(text); + } + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + super.onClosing(webSocket, code, reason); + listener.closing(code, reason); + } + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + super.onClosed(webSocket, code, reason); + listener.closed(code, reason); + } + override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) { + super.onFailure(webSocket, t, response); + listener.failure(t); + } + }); + return Socket(websocket); + } + + fun get(url : String, headers : MutableMap = HashMap()) : Response { + return execute(Request(url, "GET", null, headers)); + } + + fun head(url : String, headers : MutableMap = HashMap()) : Response { + return execute(Request(url, "HEAD", null, headers)); + } + + fun post(url : String, headers : MutableMap = HashMap()) : Response { + return execute(Request(url, "POST", ByteArray(0), headers)); + } + fun post(url : String, body : String, headers : MutableMap = HashMap()) : Response { + return post(url, body.toByteArray(), headers); + } + fun post(url : String, body : ByteArray, headers : MutableMap = HashMap()) : Response { + return execute(Request(url, "POST", body, headers)); + } + + fun requestMethod(method: String, url : String, headers : MutableMap = HashMap()) : Response { + return execute(Request(url, method, null, headers)); + } + fun requestMethod(method: String, url : String, body: String?, headers : MutableMap = HashMap()) : Response { + return execute(Request(url, method, body?.toByteArray(), headers)); + } + + fun execute(request : Request) : Response { + ensureNotMainThread(); + + beforeRequest(request); + + Logger.v(TAG, "HTTP Request [${request.method}] ${request.url} - [${if(request.body != null) request.body.size else 0}]"); + + var requestBody: RequestBody? = null + if (request.body != null) { + val ct = request.getContentType(); + if(ct != null) + requestBody = request.body.toRequestBody(ct.toMediaTypeOrNull(), 0, request.body.size); + else + requestBody = request.body.toRequestBody(null, 0, request.body.size); + } + + val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder() + .method(request.method, requestBody) + .url(request.url); + if(user_agent != null && !user_agent.isEmpty() && !request.headers.any { it.key.lowercase() == "user-agent" }) + requestBuilder.addHeader("User-Agent", user_agent) + + for (pair in request.headers.entries) + requestBuilder.header(pair.key, pair.value); + + val response: okhttp3.Response; + val resp: Response; + + val time = measureTimeMillis { + val call = client.newCall(requestBuilder.build()); + request.onCallCreated?.emit(call); + response = call.execute() + resp = Response( + response.code, + response.request.url.toString(), + response.message, + response.headers.toMultimap(), + response.body + ) + } + if(true) + Logger.v(TAG, "HTTP Response [${request.method}] ${request.url} - [${time}ms]"); + + afterRequest(request, resp); + return resp; + } + + //Set Listeners + fun setOnBeforeRequest(listener : (Request)->Unit) { + this.onBeforeRequest = listener; + } + fun setOnAfterRequest(listener : (Request, Response)->Unit) { + this.onAfterRequest = listener; + } + + open fun beforeRequest(request: Request) { + onBeforeRequest?.invoke(request); + } + open fun afterRequest(request: Request, resp: Response) { + onAfterRequest?.invoke(request, resp); + } + + + class Request + { + val url : String; + val method : String; + val body : ByteArray?; + val headers : MutableMap; + + val onCallCreated = Event1(); + + constructor(url : String, method : String, body : ByteArray?, headers : MutableMap = HashMap()) { + this.url = url; + this.method = method; + this.body = body; + this.headers = headers; + } + + fun getContentType(): String? { + val ct = headers.keys.find { it.lowercase() == "content-type" }; + if(ct != null) + return headers[ct]; + return null; + } + } + + //TODO: Wrap ResponseBody into a non-library class? + class Response + { + val code : Int; + val url : String; + val message : String; + val headers : Map>; + val body : ResponseBody?; + + val isOk : Boolean get() = code >= 200 && code < 300; + + constructor(code : Int, url : String, msg : String, headers : Map>, body : ResponseBody?) { + this.code = code; + this.url = url; + this.message = msg; + this.headers = headers; + this.body = body; + } + + fun getHeader(key: String): List? { + for(header in headers) { + if (header.key.equals(key, ignoreCase = true)) { + return header.value; + }; + } + + return null; + } + + fun getHeaderFlat(key: String): String? { + for(header in headers) { + if (header.key.equals(key, ignoreCase = true)) { + return header.value.joinToString(", ") + }; + } + + return null; + } + + fun getHeadersFlat(): MutableMap { + val map = HashMap(); + for(header in headers) + map.put(header.key, header.value.joinToString(", ")); + return map; + } + } + + class Socket { + private val socket: WebSocket; + + constructor(socket: WebSocket) { + this.socket = socket; + } + + fun send(msg: String) { + socket.send(msg); + } + + fun close(code: Int, reason: String) { + socket.close(code, reason); + } + } + interface SocketListener { + fun open(); + fun message(msg: String); + fun closing(code: Int, reason: String); + fun closed(code: Int, reason: String); + fun failure(exception: Throwable); + } + + companion object { + val TAG = "ManagedHttpClient"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/HttpBridge.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/HttpBridge.kt new file mode 100644 index 00000000..5a5f0081 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/HttpBridge.kt @@ -0,0 +1,9 @@ +package com.futo.platformplayer.api.http.server + +@Target(AnnotationTarget.FIELD, AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class HttpGET(val path: String, val contentType: String = ""); + +@Target(AnnotationTarget.FIELD, AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class HttpPOST(val path: String, val contentType: String = ""); diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/HttpContext.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/HttpContext.kt new file mode 100644 index 00000000..1d0a525c --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/HttpContext.kt @@ -0,0 +1,285 @@ +package com.futo.platformplayer.api.http.server + +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException +import com.futo.platformplayer.api.http.server.exceptions.KeepAliveTimeoutException +import com.futo.platformplayer.api.media.Serializer +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.BufferedReader +import java.io.OutputStream +import java.io.StringWriter +import java.net.SocketTimeoutException + +class HttpContext : AutoCloseable { + private val _stream: BufferedReader; + private var _responseStream: OutputStream? = null; + + var id: String? = null; + + var head: String = ""; + var headers: HttpHeaders = HttpHeaders(); + + var method: String = ""; + var path: String = ""; + var query = mutableMapOf(); + + var contentType: String? = null; + var contentLength: Long = 0; + + var keepAlive: Boolean = false; + var keepAliveTimeout: Int = 0; + var keepAliveMax: Int = 0; + + var _totalRead: Long = 0; + + var statusCode: Int = -1; + + private val _responseHeaders: HttpHeaders = HttpHeaders(); + + + constructor(stream: BufferedReader, responseStream: OutputStream? = null, requestId: String? = null, timeout: Int? = null) { + _stream = stream; + _responseStream = responseStream; + this.id = requestId; + + try { + head = stream.readLine() ?: throw EmptyRequestException("No head found"); + } + catch(ex: SocketTimeoutException) { + if((timeout ?: 0) > 0) + throw KeepAliveTimeoutException("Keep-Alive timedout", ex); + throw ex; + } + + val methodEndIndex = head.indexOf(' '); + val urlEndIndex = head.indexOf(' ', methodEndIndex + 1); + if (methodEndIndex == -1 || urlEndIndex == -1) { + Logger.w(TAG, "Skipped request, wrong format."); + throw IllegalStateException("Invalid request"); + } + + method = head.substring(0, methodEndIndex); + path = head.substring(methodEndIndex + 1, urlEndIndex); + + if (path.contains("?")) { + val queryPartIndex = path.indexOf("?"); + val queryParts = path.substring(queryPartIndex + 1).split("&"); + path = path.substring(0, queryPartIndex); + + for(queryPart in queryParts) { + val eqIndex = queryPart.indexOf("="); + if(eqIndex > 0) + query.put(queryPart.substring(0, eqIndex), queryPart.substring(eqIndex + 1)); + else + query.put(queryPart, ""); + } + } + + while (true) { + val line = stream.readLine(); + val headerEndIndex = line.indexOf(":"); + if (headerEndIndex == -1) + break; + + val headerKey = line.substring(0, headerEndIndex).lowercase() + val headerValue = line.substring(headerEndIndex + 1).trim(); + headers[headerKey] = headerValue; + + when(headerKey) { + "content-length" -> contentLength = headerValue.toLong(); + "content-type" -> contentType = headerValue; + "connection" -> keepAlive = headerValue.lowercase() == "keep-alive"; + "keep-alive" -> { + val keepAliveParams = headerValue.split(","); + for(keepAliveParam in keepAliveParams) { + val eqIndex = keepAliveParam.indexOf("="); + if(eqIndex > 0){ + when(keepAliveParam.substring(0, eqIndex)) { + "timeout" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt(); + "max" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt(); + } + } + } + } + } + if(line.isNullOrEmpty()) + break; + } + } + + fun getHttpHeaderString(): String { + val writer = StringWriter(); + writer.write(head + "\r\n"); + for(header in headers) { + writer.write("${header.key}: ${header.value}\r\n"); + } + writer.write("\r\n"); + return writer.toString(); + } + + fun getHeader(header: String) : String? { + return headers[header.lowercase()]; + } + fun setResponseHeaders(vararg respHeaders: Pair) { + for(header in respHeaders) + _responseHeaders.put(header.first, header.second); + } + fun setResponseHeaders(respHeaders: HttpHeaders) { + for(header in respHeaders) + _responseHeaders.put(header.key, header.value); + } + + inline fun respondJson(status: Int, body: T) { + respondCode(status, Json.encodeToString(body), "application/json"); + } + fun respondCode(status: Int, body: String = "", contentType: String = "text/plain") { + respondCode(status, HttpHeaders(Pair("Content-Type", contentType)), body); + } + fun respondCode(status: Int, headers: HttpHeaders, body: String? = null) { + val bytes = body?.toByteArray(Charsets.UTF_8); + if(body != null && headers.get("content-length").isNullOrEmpty()) + headers.put("content-length", bytes!!.size.toString()); + respond(status, headers) { responseStream -> + if(body != null) { + responseStream.write(bytes!!); + } + } + } + fun respond(status: Int, headers: HttpHeaders, writing: (OutputStream)->Unit) { + val responseStream = _responseStream ?: throw IllegalStateException("No response stream set"); + + val headersToRespond = headers.toMutableMap(); + + for(preHeader in _responseHeaders) + if(!headersToRespond.containsKey(preHeader.key)) + headersToRespond.put(preHeader.key, preHeader.value); + + if(keepAlive) { + headersToRespond.put("connection", "keep-alive"); + headersToRespond.put("keep-alive", "timeout=5, max=1000"); + } + + val responseHeader = HttpResponse(status, headers); + + responseStream.write(responseHeader.getHttpHeaderBytes()); + + if(method != "HEAD") { + writing(responseStream); + responseStream.flush(); + } + statusCode = status; + } + + fun readContentBytes(buffer: CharArray, length: Int) : Int { + val reading = Math.min(length, (contentLength - _totalRead).toInt()); + val read = _stream.read(buffer, 0, reading); + _totalRead += read; + + //TODO: Fix this properly + if(contentLength - _totalRead < 400 && read < length) { + _totalRead = contentLength; + } + return read; + } + fun readContentString() : String{ + val writer = StringWriter(); + var read = 0; + val buffer = CharArray(4096); + do { + read = readContentBytes(buffer, buffer.size); + writer.write(buffer, 0, read); + } while(read > 0); + return writer.toString(); + } + inline fun readContentJson() : T { + return Serializer.json.decodeFromString(readContentString()); + } + fun skipBody() { + if(contentLength > 0) + _stream.skip(contentLength - _totalRead); + } + + override fun close() { + if(!keepAlive) { + _stream?.close(); + _responseStream?.close(); + } + } + + companion object { + private val TAG = "HttpRequest"; + private val statusCodeMap = mapOf( + 100 to "Continue", + 101 to "Switching Protocols", + 102 to "Processing (WebDAV)", + 200 to "OK", + 201 to "Created", + 202 to "Accepted", + 203 to "Non-Authoritative Information", + 204 to "No Content", + 205 to "Reset Content", + 206 to "Partial Content", + 207 to "Multi-Status (WebDAV)", + 208 to "Already Reported (WebDAV)", + 226 to "IM Used", + 300 to "Multiple Choices", + 301 to "Moved Permanently", + 302 to "Found", + 303 to "See Other", + 304 to "Not Modified", + 305 to "Use Proxy", + 306 to "(Unused)", + 307 to "Temporary Redirect", + 308 to "Permanent Redirect (experimental)", + 400 to "Bad Request", + 401 to "Unauthorized", + 402 to "Payment Required", + 403 to "Forbidden", + 404 to "Not Found", + 405 to "Method Not Allowed", + 406 to "Not Acceptable", + 407 to "Proxy Authentication Required", + 408 to "Request Timeout", + 409 to "Conflict", + 410 to "Gone", + 411 to "Length Required", + 412 to "Precondition Failed", + 413 to "Request Entity Too Large", + 414 to "Request-URI Too Long", + 415 to "Unsupported Media Type", + 416 to "Requested Range Not Satisfiable", + 417 to "Expectation Failed", + 418 to "I'm a teapot (RFC 2324)", + 420 to "Enhance Your Calm (Twitter)", + 422 to "Unprocessable Entity (WebDAV)", + 423 to "Locked (WebDAV)", + 424 to "Failed Dependency (WebDAV)", + 425 to "Reserved for WebDAV", + 426 to "Upgrade Required", + 428 to "Precondition Required", + 429 to "Too Many Requests", + 431 to "Request Header Fields Too Large", + 444 to "No Response (Nginx)", + 449 to "Retry With (Microsoft)", + 450 to "Blocked by Windows Parental Controls (Microsoft)", + 451 to "Unavailable For Legal Reasons", + 499 to "Client Closed Request (Nginx)", + 500 to "Internal Server Error", + 501 to "Not Implemented", + 502 to "Bad Gateway", + 503 to "Service Unavailable", + 504 to "Gateway Timeout", + 505 to "HTTP Version Not Supported", + 506 to "Variant Also Negotiates (Experimental)", + 507 to "Insufficient Storage (WebDAV)", + 508 to "Loop Detected (WebDAV)", + 509 to "Bandwidth Limit Exceeded (Apache)", + 510 to "Not Extended", + 511 to "Network Authentication Required", + 598 to "Network read timeout error", + 599 to "Network connect timeout error", + ); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/HttpHeaders.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/HttpHeaders.kt new file mode 100644 index 00000000..40e4fbf6 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/HttpHeaders.kt @@ -0,0 +1,25 @@ +package com.futo.platformplayer.api.http.server + +class HttpHeaders : HashMap { + + constructor() : super(){} + constructor(vararg headers: Pair) : + super(headers.map{ Pair(it.first.lowercase(), it.second) }.toMap()) { } + constructor(headers: Map) : + super(headers.mapKeys { it.key.lowercase() }) { } + + override fun put(key: String, value: String): String? { + return super.put(key.lowercase(), value) + } + override fun get(key: String): String? { + return super.get(key.lowercase()); + } + + override fun containsKey(key: String): Boolean { + return super.containsKey(key.lowercase()) + } + + override fun clone() : HttpHeaders { + return HttpHeaders(this); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/HttpResponse.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/HttpResponse.kt new file mode 100644 index 00000000..01e51bed --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/HttpResponse.kt @@ -0,0 +1,115 @@ +package com.futo.platformplayer.api.http.server + +import java.io.InputStream +import java.io.StringWriter + +class HttpResponse : AutoCloseable { + private var _stream: InputStream? = null; + + var head: String = ""; + var headers: Map; + + var status: Int = 0; + + + constructor(status: Int, headers: Map) { + head = "HTTP/1.1 ${status} ${statusCodeMap.get(status)}"; + this.status = status; + this.headers = headers; + } + + fun getHttpHeaderString(): String { + val writer = StringWriter(); + writer.write(head + "\r\n"); + for(header in headers) { + writer.write("${header.key}: ${header.value}\r\n"); + } + writer.write("\r\n"); + return writer.toString(); + } + fun getHttpHeaderBytes(): ByteArray { + return getHttpHeaderString().toByteArray(Charsets.UTF_8); + } + + + override fun close() { + _stream?.close(); + } + + companion object { + private val REGEX_HEAD = Regex("(\\S+) (\\S+) (\\S+)"); + + + private val statusCodeMap = mapOf( + 100 to "Continue", + 101 to "Switching Protocols", + 102 to "Processing (WebDAV)", + 200 to "OK", + 201 to "Created", + 202 to "Accepted", + 203 to "Non-Authoritative Information", + 204 to "No Content", + 205 to "Reset Content", + 206 to "Partial Content", + 207 to "Multi-Status (WebDAV)", + 208 to "Already Reported (WebDAV)", + 226 to "IM Used", + 300 to "Multiple Choices", + 301 to "Moved Permanently", + 302 to "Found", + 303 to "See Other", + 304 to "Not Modified", + 305 to "Use Proxy", + 306 to "(Unused)", + 307 to "Temporary Redirect", + 308 to "Permanent Redirect (experimental)", + 400 to "Bad Request", + 401 to "Unauthorized", + 402 to "Payment Required", + 403 to "Forbidden", + 404 to "Not Found", + 405 to "Method Not Allowed", + 406 to "Not Acceptable", + 407 to "Proxy Authentication Required", + 408 to "Request Timeout", + 409 to "Conflict", + 410 to "Gone", + 411 to "Length Required", + 412 to "Precondition Failed", + 413 to "Request Entity Too Large", + 414 to "Request-URI Too Long", + 415 to "Unsupported Media Type", + 416 to "Requested Range Not Satisfiable", + 417 to "Expectation Failed", + 418 to "I'm a teapot (RFC 2324)", + 420 to "Enhance Your Calm (Twitter)", + 422 to "Unprocessable Entity (WebDAV)", + 423 to "Locked (WebDAV)", + 424 to "Failed Dependency (WebDAV)", + 425 to "Reserved for WebDAV", + 426 to "Upgrade Required", + 428 to "Precondition Required", + 429 to "Too Many Requests", + 431 to "Request Header Fields Too Large", + 444 to "No Response (Nginx)", + 449 to "Retry With (Microsoft)", + 450 to "Blocked by Windows Parental Controls (Microsoft)", + 451 to "Unavailable For Legal Reasons", + 499 to "Client Closed Request (Nginx)", + 500 to "Internal Server Error", + 501 to "Not Implemented", + 502 to "Bad Gateway", + 503 to "Service Unavailable", + 504 to "Gateway Timeout", + 505 to "HTTP Version Not Supported", + 506 to "Variant Also Negotiates (Experimental)", + 507 to "Insufficient Storage (WebDAV)", + 508 to "Loop Detected (WebDAV)", + 509 to "Bandwidth Limit Exceeded (Apache)", + 510 to "Not Extended", + 511 to "Network Authentication Required", + 598 to "Network read timeout error", + 599 to "Network connect timeout error", + ); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/ManagedHttpServer.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/ManagedHttpServer.kt new file mode 100644 index 00000000..09f898bf --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/ManagedHttpServer.kt @@ -0,0 +1,260 @@ +package com.futo.platformplayer.api.http.server + +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException +import com.futo.platformplayer.api.http.server.handlers.HttpFuntionHandler +import com.futo.platformplayer.api.http.server.handlers.HttpHandler +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.OutputStream +import java.lang.reflect.Field +import java.lang.reflect.Method +import java.net.InetAddress +import java.net.NetworkInterface +import java.net.ServerSocket +import java.net.Socket +import java.util.* +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.stream.IntStream.range + +class ManagedHttpServer(private val _requestedPort: Int = 0) { + private val _client : ManagedHttpClient = ManagedHttpClient(); + private val _logVerbose: Boolean = false; + + var active : Boolean = false + private set; + private var _stopCount = 0; + var port = 0 + private set; + + private val _handlers = mutableListOf(); + private var _workerPool: ExecutorService? = null; + + @Synchronized + fun start() { + if (active) + return; + active = true; + _workerPool = Executors.newCachedThreadPool(); + + Thread { + try { + val socket = ServerSocket(_requestedPort); + port = socket.localPort; + + val stopCount = _stopCount; + while (_stopCount == stopCount) { + if(_logVerbose) + Logger.i(TAG, "Waiting for connection..."); + val s = socket.accept() ?: continue; + + try { + handleClientRequest(s); + } + catch(ex : Exception) { + Logger.e(TAG, "Client disconnected due to: " + ex.message, ex); + } + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to accept socket.", e); + stop(); + } + }.start(); + + Logger.i(TAG, "Started ${port}. \n" + getAddresses().map { it.hostAddress }.joinToString("\n")); + } + @Synchronized + fun stop() { + _stopCount++; + active = false; + _workerPool?.shutdown(); + _workerPool = null; + port = 0; + } + + private fun handleClientRequest(socket: Socket) { + _workerPool?.submit { + val requestReader = BufferedReader(InputStreamReader(socket.getInputStream())) + val responseStream = socket.getOutputStream(); + + val requestId = UUID.randomUUID().toString().substring(0, 5); + try { + keepAliveLoop(requestReader, responseStream, requestId) { req -> + req.use { httpContext -> + if(!httpContext.path.startsWith("/plugin/")) + Logger.i(TAG, "[${req.id}] ${httpContext.method}: ${httpContext.path}") + else + ;//Logger.v(TAG, "[${req.id}] ${httpContext.method}: ${httpContext.path}") + val handler = getHandler(httpContext.method, httpContext.path); + if (handler != null) { + handler.handle(httpContext); + } else { + Logger.i(TAG, "[${req.id}] 404 on ${httpContext.method}: ${httpContext.path}"); + httpContext.respondCode(404); + } + if(_logVerbose) + Logger.i(TAG, "[${req.id}] Responded [${req.statusCode}] ${httpContext.method}: ${httpContext.path}") + }; + } + } + catch(emptyRequest: EmptyRequestException) { + if(_logVerbose) + Logger.i(TAG, "[${requestId}] Request ended due to empty request: ${emptyRequest.message}"); + } + catch (e: Throwable) { + Logger.e(TAG, "Failed to handle client request.", e); + } + finally { + requestReader.close(); + responseStream.close(); + } + }; + } + + fun getHandler(method: String, path: String) : HttpHandler? { + synchronized(_handlers) { + //TODO: Support regex paths? + if(method == "HEAD") + return _handlers.firstOrNull { it.path == path && (it.allowHEAD || it.method == "HEAD") } + return _handlers.firstOrNull { it.method == method && it.path == path }; + } + } + fun addHandler(handler: HttpHandler, withHEAD: Boolean = false) : HttpHandler { + synchronized(_handlers) { + _handlers.add(handler); + handler.allowHEAD = withHEAD; + } + return handler; + } + fun removeHandler(method: String, path: String) { + synchronized(_handlers) { + val handler = getHandler(method, path); + if(handler != null) + _handlers.remove(handler); + } + } + fun removeAllHandlers(tag: String? = null) { + synchronized(_handlers) { + if(tag == null) + _handlers.clear(); + else + _handlers.removeIf { it.tag == tag }; + } + } + fun addBridgeHandlers(obj: Any, tag: String? = null) { + val tagToUse = tag ?: obj.javaClass.name; + val getMethods = obj::class.java.declaredMethods + .filter { it.getAnnotation(HttpGET::class.java) != null } + .map { Pair(it, it.getAnnotation(HttpGET::class.java)!!) } + .toList(); + val postMethods = obj::class.java.declaredMethods + .filter { it.getAnnotation(HttpPOST::class.java) != null } + .map { Pair(it, it.getAnnotation(HttpPOST::class.java)!!) } + .toList(); + + val getFields = obj::class.java.declaredFields + .filter { it.getAnnotation(HttpGET::class.java) != null && it.type == String::class.java } + .map { Pair(it, it.getAnnotation(HttpGET::class.java)!!) } + .toList(); + + for(getMethod in getMethods) + if(getMethod.first.parameterTypes.firstOrNull() == HttpContext::class.java && getMethod.first.parameterCount == 1) + addHandler(HttpFuntionHandler("GET", getMethod.second.path) { getMethod.first.invoke(obj, it) }).apply { + if(!getMethod.second.contentType.isEmpty()) + this.withContentType(getMethod.second.contentType); + }.withContentType(getMethod.second.contentType ?: ""); + for(postMethod in postMethods) + if(postMethod.first.parameterTypes.firstOrNull() == HttpContext::class.java && postMethod.first.parameterCount == 1) + addHandler(HttpFuntionHandler("POST", postMethod.second.path) { postMethod.first.invoke(obj, it) }).apply { + if(!postMethod.second.contentType.isEmpty()) + this.withContentType(postMethod.second.contentType); + }.withContentType(postMethod.second.contentType ?: ""); + + for(getField in getFields) { + getField.first.isAccessible = true; + addHandler(HttpFuntionHandler("GET", getField.second.path) { + val value = getField.first.get(obj) as String?; + if(value != null) { + val headers = HttpHeaders( + Pair("Content-Type", getField.second.contentType) + ); + it.respondCode(200, headers, value); + } + else + it.respondCode(204); + }).withContentType(getField.second.contentType ?: ""); + } + } + + private fun keepAliveLoop(requestReader: BufferedReader, responseStream: OutputStream, requestId: String, handler: (HttpContext)->Unit) { + val stopCount = _stopCount; + var keepAlive = false; + var requestsMax = 0; + var requestsTotal = 0; + do { + val req = HttpContext(requestReader, responseStream, requestId); + + //Handle Request + handler(req); + + requestsTotal++; + if(req.keepAlive) { + keepAlive = true; + if(req.keepAliveMax > 0) + requestsMax = req.keepAliveMax; + + req.skipBody(); + } else { + keepAlive = false; + } + } + while (keepAlive && (requestsMax == 0 || requestsTotal < requestsMax) && _stopCount == stopCount); + } + + fun getAddressByIP(addresses: List) : String = getAddress(addresses.map { it.address }.toList()); + fun getAddress(addresses: List = listOf()): String { + if(addresses.isEmpty()) + return getAddresses().first().hostAddress ?: ""; + else + //Matches the closest address to the list of provided addresses + return getAddresses().maxBy { + val availableAddress = it.address; + return@maxBy addresses.map { deviceAddress -> + var matches = 0; + for(index in range(0, Math.min(availableAddress.size, deviceAddress.size))) { + if(availableAddress[index] == deviceAddress[index]) + matches++; + else + break; + } + return@map matches; + }.max(); + }.hostAddress ?: ""; + } + private fun getAddresses(): List { + val addresses = arrayListOf(); + + try { + for (intf in NetworkInterface.getNetworkInterfaces()) { + for (addr in intf.inetAddresses) { + if (!addr.isLoopbackAddress) { + val ipString: String = addr.hostAddress; + val isIPv4 = ipString.indexOf(':') < 0; + if (!isIPv4) + continue; + addresses.add(addr); + } + } + } + } + catch (ignored: Exception) { } + + return addresses; + } + + companion object { + val TAG = "ManagedHttpServer"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/exceptions/EmptyRequestException.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/exceptions/EmptyRequestException.kt new file mode 100644 index 00000000..91182132 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/exceptions/EmptyRequestException.kt @@ -0,0 +1,6 @@ +package com.futo.platformplayer.api.http.server.exceptions + +import java.net.SocketTimeoutException +import java.util.concurrent.TimeoutException + +class EmptyRequestException(msg: String) : Exception(msg) {} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/exceptions/KeepAliveTimeoutException.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/exceptions/KeepAliveTimeoutException.kt new file mode 100644 index 00000000..211553e8 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/exceptions/KeepAliveTimeoutException.kt @@ -0,0 +1,3 @@ +package com.futo.platformplayer.api.http.server.exceptions + +class KeepAliveTimeoutException(msg: String, ex: Exception) : Exception(msg, ex) {} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpConstantHandler.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpConstantHandler.kt new file mode 100644 index 00000000..c868b2b7 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpConstantHandler.kt @@ -0,0 +1,14 @@ +package com.futo.platformplayer.api.http.server.handlers + +import com.futo.platformplayer.api.http.server.HttpContext + +class HttpConstantHandler(method: String, path: String, val content: String, val contentType: String? = null) : HttpHandler(method, path) { + override fun handle(httpContext: HttpContext) { + val headers = this.headers.clone(); + if(contentType != null) + headers["Content-Type"] = contentType; + headers["Content-Length"] = content.length.toString(); + + httpContext.respondCode(200, headers, content); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpFileHandler.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpFileHandler.kt new file mode 100644 index 00000000..89ad16c6 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpFileHandler.kt @@ -0,0 +1,107 @@ +package com.futo.platformplayer.api.http.server.handlers + +import com.futo.platformplayer.api.http.server.HttpContext +import com.futo.platformplayer.logging.Logger +import java.io.File +import java.nio.file.Files +import java.text.SimpleDateFormat +import java.util.* +import java.util.zip.GZIPOutputStream + +class HttpFileHandler(method: String, path: String, private val contentType: String, private val filePath: String, private val closeAfterRequest: Boolean = false): HttpHandler(method, path) { + override fun handle(httpContext: HttpContext) { + val requestHeaders = httpContext.headers; + val responseHeaders = this.headers.clone(); + responseHeaders["Content-Type"] = contentType; + + val file = File(filePath); + if (!file.exists()) { + throw Exception("File does not exist."); + } + + val lastModified = Files.getLastModifiedTime(file.toPath()) + responseHeaders["Last-Modified"] = httpDateFormat.format(Date(lastModified.toMillis())) + + val ifModifiedSince = requestHeaders["If-Modified-Since"]?.let { httpDateFormat.parse(it) } + if (ifModifiedSince != null && lastModified.toMillis() <= ifModifiedSince.time) { + httpContext.respondCode(304, headers) + return + } + + responseHeaders["Content-Disposition"] = "attachment; filename=\"${file.name.replace("\"", "\\\"")}\"" + + val acceptEncoding = requestHeaders["Accept-Encoding"] + val shouldGzip = acceptEncoding != null && acceptEncoding.split(',').any { it.trim().equals("gzip", ignoreCase = true) || it == "*" } + if (shouldGzip) { + responseHeaders["Content-Encoding"] = "gzip" + } + + val range = requestHeaders["Range"] + var start: Long + val end: Long + if (range != null && range.startsWith("bytes=")) { + val parts = range.substring(6).split("-") + start = parts[0].toLong() + end = parts.getOrNull(1)?.toLong() ?: (file.length() - 1) + responseHeaders["Content-Range"] = "bytes $start-$end/${file.length()}" + } else { + start = 0 + end = file.length() - 1 + } + + var totalBytesSent = 0 + val contentLength = end - start + 1 + Logger.i(TAG, "Sending $contentLength bytes (start: $start, end: $end, shouldGzip: $shouldGzip)") + responseHeaders["Content-Length"] = contentLength.toString() + + file.inputStream().use { inputStream -> + httpContext.respond(if (range == null) 200 else 206, responseHeaders) { responseStream -> + try { + val buffer = ByteArray(8192) + inputStream.skip(start) + + val outputStream = if (shouldGzip) GZIPOutputStream(responseStream) else responseStream + while (true) { + val expectedBytesRead = (end - start + 1).coerceAtMost(buffer.size.toLong()); + val bytesRead = inputStream.read(buffer); + if (bytesRead < 0) { + Logger.i(TAG, "End of file reached") + break; + } + + val bytesToSend = bytesRead.coerceAtMost(expectedBytesRead.toInt()); + outputStream.write(buffer, 0, bytesToSend) + + totalBytesSent += bytesToSend + Logger.v(TAG, "Sent bytes $start-${start + bytesToSend}, totalBytesSent=$totalBytesSent") + + start += bytesToSend.toLong() + if (start >= end) { + Logger.i(TAG, "Expected amount of bytes sent") + break + } + } + + Logger.i(TAG, "Finished sending file (segment)") + + if (shouldGzip) (outputStream as GZIPOutputStream).finish() + outputStream.flush() + } catch (e: Exception) { + httpContext.respondCode(500, headers) + } + } + + if (closeAfterRequest) { + httpContext.keepAlive = false; + } + } + } + + companion object { + private const val TAG = "HttpFileHandler" + + private val httpDateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("GMT") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpFunctionHandler.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpFunctionHandler.kt new file mode 100644 index 00000000..758c8d33 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpFunctionHandler.kt @@ -0,0 +1,10 @@ +package com.futo.platformplayer.api.http.server.handlers + +import com.futo.platformplayer.api.http.server.HttpContext + +class HttpFuntionHandler(method: String, path: String, val handler: (HttpContext)->Unit) : HttpHandler(method, path) { + override fun handle(httpContext: HttpContext) { + httpContext.setResponseHeaders(this.headers); + handler(httpContext); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpHandler.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpHandler.kt new file mode 100644 index 00000000..509c1b40 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpHandler.kt @@ -0,0 +1,24 @@ +package com.futo.platformplayer.api.http.server.handlers + +import com.futo.platformplayer.api.http.server.HttpContext +import com.futo.platformplayer.api.http.server.HttpHeaders + + +abstract class HttpHandler(val method: String, val path: String) { + var tag: String? = null; + val headers = HttpHeaders() + var allowHEAD = false; + + abstract fun handle(httpContext: HttpContext); + + fun withHeader(key: String, value: String) : HttpHandler { + headers.put(key, value); + return this; + } + fun withContentType(contentType: String) = withHeader("Content-Type", contentType); + + fun withTag(tag: String) : HttpHandler { + this.tag = tag; + return this; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpOptionsAllowHandler.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpOptionsAllowHandler.kt new file mode 100644 index 00000000..af226aa6 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpOptionsAllowHandler.kt @@ -0,0 +1,20 @@ +package com.futo.platformplayer.api.http.server.handlers + +import com.futo.platformplayer.api.http.server.HttpContext + +class HttpOptionsAllowHandler(path: String) : HttpHandler("OPTIONS", path) { + override fun handle(httpContext: HttpContext) { + //Just allow whatever is requested + + val requestedOrigin = httpContext.headers.getOrDefault("Access-Control-Request-Origin", ""); + val requestedMethods = httpContext.headers.getOrDefault("Access-Control-Request-Method", ""); + val requestedHeaders = httpContext.headers.getOrDefault("Access-Control-Request-Headers", ""); + + val newHeaders = headers.clone(); + newHeaders.put("Allow", requestedMethods); + newHeaders.put("Access-Control-Allow-Methods", requestedMethods); + newHeaders.put("Access-Control-Allow-Headers", "*"); + + httpContext.respondCode(200, newHeaders); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpProxyHandler.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpProxyHandler.kt new file mode 100644 index 00000000..1756925c --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpProxyHandler.kt @@ -0,0 +1,95 @@ +package com.futo.platformplayer.api.http.server.handlers + +import android.net.Uri +import com.futo.platformplayer.api.http.server.HttpContext +import com.futo.platformplayer.api.http.server.HttpHeaders +import com.futo.platformplayer.api.http.ManagedHttpClient + +class HttpProxyHandler(method: String, path: String, val targetUrl: String): HttpHandler(method, path) { + var content: String? = null; + var contentType: String? = null; + + private val _ignoreRequestHeaders = mutableListOf(); + private val _injectRequestHeader = mutableListOf>(); + + private val _ignoreResponseHeaders = mutableListOf(); + + private var _injectHost = false; + private var _injectReferer = false; + + + private val _client = ManagedHttpClient(); + + override fun handle(context: HttpContext) { + val proxyHeaders = HashMap(); + for (header in context.headers.filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) }) + proxyHeaders[header.key] = header.value; + for (injectHeader in _injectRequestHeader) + proxyHeaders[injectHeader.first] = injectHeader.second; + + val parsed = Uri.parse(targetUrl); + if(_injectHost) + proxyHeaders.put("Host", parsed.host!!); + if(_injectReferer) + proxyHeaders.put("Referer", targetUrl); + + val useMethod = if (method == "inherit") context.method else method; + //Logger.i(TAG, "Proxied Request ${useMethod}: ${targetUrl}"); + //Logger.i(TAG, "Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n")); + + val resp = when (useMethod) { + "GET" -> _client.get(targetUrl, proxyHeaders); + "POST" -> _client.post(targetUrl, content ?: "", proxyHeaders); + "HEAD" -> _client.head(targetUrl, proxyHeaders) + else -> _client.requestMethod(useMethod, targetUrl, proxyHeaders); + }; + + //Logger.i(TAG, "Proxied Response [${resp.code}]"); + val headersFiltered = HttpHeaders(resp.getHeadersFlat().filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) }); + for(newHeader in headers) + headersFiltered.put(newHeader.key, newHeader.value); + + if(resp.body == null) + context.respondCode(resp.code, headersFiltered); + else { + resp.body.byteStream().use { inputStream -> + context.respond(resp.code, headersFiltered) { responseStream -> + val buffer = ByteArray(8192); + + var read: Int; + while (inputStream.read(buffer).also { read = it } >= 0) { + responseStream.write(buffer, 0, read); + } + }; + } + } + } + + fun withContent(body: String) : HttpProxyHandler { + this.content = body; + return this; + } + + fun withRequestHeader(header: String, value: String) : HttpProxyHandler { + _injectRequestHeader.add(Pair(header, value)); + return this; + } + fun withIgnoredRequestHeaders(ignored: List) : HttpProxyHandler { + _ignoreRequestHeaders.addAll(ignored.map { it.lowercase() }); + return this; + } + fun withIgnoredResponseHeaders(ignored: List) : HttpProxyHandler { + _ignoreResponseHeaders.addAll(ignored.map { it.lowercase() }); + return this; + } + fun withInjectedHost() : HttpProxyHandler { + _injectHost = true; + _ignoreRequestHeaders.add("host"); + return this; + } + fun withInjectedReferer() : HttpProxyHandler { + _injectReferer = true; + _ignoreRequestHeaders.add("referer"); + return this; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/CachedPlatformClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/CachedPlatformClient.kt new file mode 100644 index 00000000..66a86f8b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/CachedPlatformClient.kt @@ -0,0 +1,105 @@ +package com.futo.platformplayer.api.media + +import androidx.collection.LruCache +import com.futo.platformplayer.api.media.models.ResultCapabilities +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor +import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent +import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.models.ImageVariable +import com.futo.platformplayer.models.Playlist + +/** + * A temporary class that caches video results + * In future this should be part of a bigger system + */ +class CachedPlatformClient : IPlatformClient { + private val _client : IPlatformClient; + override val id: String get() = _client.id; + override val name: String get() = _client.name; + override val icon: ImageVariable? get() = _client.icon; + + private val _cache: LruCache; + + override val capabilities: PlatformClientCapabilities + get() = _client.capabilities; + + constructor(client : IPlatformClient, cacheSize : Int = 10 * 1024 * 1024) { + this._client = client; + this._cache = LruCache(cacheSize); + } + override fun initialize() { _client.initialize() } + override fun disable() { _client.disable() } + + override fun isContentDetailsUrl(url: String): Boolean = _client.isContentDetailsUrl(url); + override fun getContentDetails(url: String): IPlatformContentDetails { + var result = _cache.get(url); + if(result == null) { + result = _client.getContentDetails(url); + if (result != null) + _cache.put(url, result); + } + return result; + } + + override fun getPlaybackTracker(url: String): IPlaybackTracker? = _client.getPlaybackTracker(url); + + override fun isChannelUrl(url: String): Boolean = _client.isChannelUrl(url); + override fun getChannel(channelUrl: String): IPlatformChannel = _client.getChannel(channelUrl); + + override fun getChannelCapabilities(): ResultCapabilities = _client.getChannelCapabilities(); + override fun getChannelContents( + channelUrl: String, + type: String?, + order: String?, + filters: Map>? + ): IPager = _client.getChannelContents(channelUrl); + + override fun getChannelUrlByClaim(claimType: Int, claimValues: Map): String? = _client.getChannelUrlByClaim(claimType, claimValues) + + override fun searchSuggestions(query: String): Array = _client.searchSuggestions(query); + override fun getSearchCapabilities(): ResultCapabilities = _client.getSearchCapabilities(); + override fun search( + query: String, + type: String?, + order: String?, + filters: Map>? + ): IPager = _client.search(query, type, order, filters); + + override fun getSearchChannelContentsCapabilities(): ResultCapabilities = _client.getSearchChannelContentsCapabilities(); + override fun searchChannelContents( + channelUrl: String, + query: String, + type: String?, + order: String?, + filters: Map>? + ): IPager = _client.searchChannelContents(channelUrl, query, type, order, filters); + + override fun searchChannels(query: String) = _client.searchChannels(query); + + override fun getComments(url: String): IPager = _client.getComments(url); + override fun getSubComments(comment: IPlatformComment): IPager = _client.getSubComments(comment); + + override fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor? = _client.getLiveChatWindow(url); + override fun getLiveEvents(url: String): IPager? = _client.getLiveEvents(url); + + override fun getHome(): IPager = _client.getHome(); + + override fun getUserSubscriptions(): Array { return arrayOf(); }; + + override fun searchPlaylists(query: String, type: String?, order: String?, filters: Map>?): IPager = _client.searchPlaylists(query, type, order, filters); + override fun isPlaylistUrl(url: String): Boolean = _client.isPlaylistUrl(url); + override fun getPlaylist(url: String): IPlatformPlaylistDetails = _client.getPlaylist(url); + override fun getUserPlaylists(): Array { return arrayOf(); }; + + override fun isClaimTypeSupported(claimType: Int): Boolean { + return _client.isClaimTypeSupported(claimType); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt new file mode 100644 index 00000000..0178cb5a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt @@ -0,0 +1,155 @@ +package com.futo.platformplayer.api.media + +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.ResultCapabilities +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor +import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent +import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.models.ImageVariable +import com.futo.platformplayer.models.Playlist + +/** + * A client for a specific platform + */ +interface IPlatformClient { + val id: String; + val name: String; + + val icon: ImageVariable?; + + //Capabilities + val capabilities: PlatformClientCapabilities; + + fun initialize(); + fun disable(); + + /** + * Gets the home recommendations + */ + fun getHome(): IPager + + //Search + /** + * Gets search suggestion for the provided query string + */ + fun searchSuggestions(query: String): Array; + /** + * Describes what the plugin is capable on filtering/sorting search results + */ + fun getSearchCapabilities(): ResultCapabilities; + /** + * Searches for content and returns a search pager with results + */ + fun search(query: String, type: String? = null, order: String? = null, filters: Map>? = null): IPager; + + + + /** + * Describes what the plugin is capable on filtering/sorting search results on channels + */ + fun getSearchChannelContentsCapabilities(): ResultCapabilities; + /** + * Searches for content on a channel and returns a video pager + */ + fun searchChannelContents(channelUrl: String, query: String, type: String? = null, order: String? = null, filters: Map>? = null): IPager; + + + /** + * Searches for channels and returns a channel pager + */ + fun searchChannels(query: String): IPager; + + + //Video Pages + /** + * Determines if the provided url is a valid url for getting channel from this client + */ + fun isChannelUrl(url: String): Boolean; + /** + * Gets channel details, might also fetch videos which is then obtained by IPlatformChannel.getVideos. Otherwise might fall back to getChannelVideos + */ + fun getChannel(channelUrl: String): IPlatformChannel; + /** + * Describes what the plugin is capable on filtering/sorting channel results + */ + fun getChannelCapabilities(): ResultCapabilities; + /** + * Gets all videos of a channel, ideally in upload time descending + */ + fun getChannelContents(channelUrl: String, type: String? = null, order: String? = null, filters: Map>? = null): IPager; + + /** + * Gets the channel url associated with a claimType + */ + fun getChannelUrlByClaim(claimType: Int, claimValues: Map): String?; + + //Video + /** + * Determines if the provided url is a valid url for getting details from this client + */ + fun isContentDetailsUrl(url: String): Boolean; + /** + * Gets the video details for a given url, including video/audio streams + */ + fun getContentDetails(url: String): IPlatformContentDetails; + + /** + * Gets the playback tracker for a piece of content + */ + fun getPlaybackTracker(url: String): IPlaybackTracker?; + + + //Comments + /** + * Gets the comments underneath a video + */ + fun getComments(url: String): IPager; + /** + * Gets the replies to a comment + */ + fun getSubComments(comment: IPlatformComment): IPager; + + /** + * Gets the live events of a livestream + */ + fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor?; + /** + * Gets the live events of a livestream + */ + fun getLiveEvents(url: String): IPager? + + + //Playlists + /** + * Search for Playlists and returns a Playlist pager + */ + fun searchPlaylists(query: String, type: String? = null, order: String? = null, filters: Map>? = null): IPager; + /** + * Gets a playlist from a url + */ + fun isPlaylistUrl(url: String): Boolean; + /** + * Gets a playlist from a url + */ + fun getPlaylist(url: String): IPlatformPlaylistDetails; + + //Migration + /** + * Retrieves the playlists of the currently logged in user + */ + fun getUserPlaylists(): Array; + /** + * Retrieves the subscriptions of the currently logged in user + */ + fun getUserSubscriptions(): Array; + + + fun isClaimTypeSupported(claimType: Int): Boolean; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/IPluginSourced.kt b/app/src/main/java/com/futo/platformplayer/api/media/IPluginSourced.kt new file mode 100644 index 00000000..0ad592f1 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/IPluginSourced.kt @@ -0,0 +1,7 @@ +package com.futo.platformplayer.api.media + +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig + +interface IPluginSourced { + val sourceConfig: SourcePluginConfig; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/LiveChatManager.kt b/app/src/main/java/com/futo/platformplayer/api/media/LiveChatManager.kt new file mode 100644 index 00000000..78c99bff --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/LiveChatManager.kt @@ -0,0 +1,223 @@ +package com.futo.platformplayer.api.media + +import android.graphics.BitmapFactory +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.PictureDrawable +import androidx.core.graphics.drawable.toBitmap +import com.caverock.androidsvg.SVG +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent +import com.futo.platformplayer.api.media.models.live.LiveEventComment +import com.futo.platformplayer.api.media.models.live.LiveEventDonation +import com.futo.platformplayer.api.media.models.live.LiveEventEmojis +import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.constructs.BatchedTaskHandler +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.views.overlays.LiveChatOverlay +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class LiveChatManager { + private val _scope: CoroutineScope; + private val _emojiCache: EmojiCache = EmojiCache(); + private val _pager: IPager?; + + private val _history: ArrayList = arrayListOf(); + + private var _startCounter = 0; + + private val _followers: HashMap) -> Unit> = hashMapOf(); + + var viewCount: Long = 0 + private set; + + constructor(scope: CoroutineScope, pager: IPager, initialViewCount: Long = 0) { + _scope = scope; + _pager = pager; + viewCount = initialViewCount; + handleEvents(listOf(LiveEventComment("SYSTEM", null, "Live chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n"))); + handleEvents(pager.getResults()); + } + + fun start() { + val counter = ++_startCounter; + startLoop(counter); + } + + fun stop() { + _startCounter++; + } + + fun getHistory(): List { + synchronized(_history) { + return _history.toList(); + } + } + + fun follow(tag: Any, eventHandler: (List) -> Unit) { + val before = synchronized(_history) { + _history.toList(); + }; + synchronized(_followers) { + _followers.put(tag, eventHandler); + } + eventHandler(before); + } + fun unfollow(tag: Any) { + synchronized(_followers) { + _followers.remove(tag); + } + } + + fun hasEmoji(emoji: String): Boolean { + return _emojiCache.hasEmoji(emoji); + } + fun getEmoji(emoji: String, handler: (Drawable?)->Unit) { + return _emojiCache.getEmojiDrawable(emoji, handler); + } + + private fun startLoop(counter: Int) { + _scope.launch(Dispatchers.IO) { + try { + while(_startCounter == counter) { + var nextInterval = 1000L; + try { + if(_pager == null || !_pager.hasMorePages()) + return@launch; + _pager.nextPage(); + val newEvents = _pager.getResults(); + if(_pager is JSLiveEventPager) + nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong(); + + Logger.i(TAG, "New Live Events (${newEvents.size}) [${newEvents.map { it.type.name }.joinToString(", ")}]"); + + _scope.launch(Dispatchers.Main) { + try { + handleEvents(newEvents); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to handle new live events.", e); + } + } + } + catch(ex: Throwable) { + Logger.e(LiveChatOverlay.TAG, "Failed to load live events", ex); + } + delay(nextInterval); + } + } catch (e: Throwable) { + Logger.e(TAG, "Live events loop crashed.", e); + } + } + } + fun handleEvents(events: List) { + for(event in events) { + if(event is LiveEventEmojis) + _emojiCache.setEmojis(event); + } + synchronized(_history) { + _history.addAll(events); + } + val handlers = synchronized(_followers) { _followers.values.toList() }; + for(handler in handlers) { + try { + handler(events); + } + catch(ex: Throwable) { + Logger.e(TAG, "Failed to chat handle events on handler", ex); + } + } + } + + companion object { + val TAG = "LiveChatManager"; + } + + + class EmojiCache { + private val _cache_lock = Object(); + private val _cache_drawables = HashMap(); //TODO: Replace with LRUCache + private val _cache_urls = HashMap(); + + private val _client = ManagedHttpClient(); + private val _download_drawable = + BatchedTaskHandler(StateApp.instance.scope, { url -> + val req = _client.get(url); + if (req.isOk && req.body != null) { + val contentType = req.body.contentType(); + return@BatchedTaskHandler when (contentType?.toString()) { + //TODO: Get scaling to work with drawable (no bitmap conversion) + "image/svg+xml" -> { + val bitmap = PictureDrawable(SVG.getFromString(req.body.string()).renderToPicture(150, 150)).toBitmap(150,150,null); + return@BatchedTaskHandler BitmapDrawable(bitmap) + }; + //"image/svg+xml" -> PictureDrawable(SVG.getFromString(req.body.string()).renderToPicture(15, 15)); + else -> { + val bytes = req.body.bytes(); + BitmapDrawable(BitmapFactory.decodeByteArray(bytes, 0, bytes.size)) + } + } + } else { + Logger.w(TAG, "Failed to request emoji (${req.code}) [${req.url}]"); + return@BatchedTaskHandler null; + } + }, { url -> + synchronized(_cache_lock) { + return@synchronized _cache_drawables[url]; + } + }, { url, drawable -> + if (drawable != null) + synchronized(_cache_lock) { + _cache_drawables[url] = drawable; + } + }); + + fun setEmojis(emojis: LiveEventEmojis) { + synchronized(_cache_lock) { + for(emoji in emojis.emojis) { + _cache_urls[emoji.key] = emoji.value; + } + } + } + + fun hasEmoji(emoji: String): Boolean { + synchronized(_cache_lock) { + return _cache_urls.containsKey(emoji); + } + } + + fun getEmojiDrawable(emoji: String, cb: (drawable: Drawable?)->Unit) { + var drawable: Drawable? = null; + var url: String? = null; + synchronized(_cache_lock) { + url = _cache_urls[emoji]; + if(url != null) + drawable = _cache_drawables[url]; + } + if(drawable != null) + cb(drawable); + else if(url != null){ + Logger.i(TAG, "Requesting [${emoji}] (${url})"); + _download_drawable.execute(url!!).invokeOnCompletion { + if(it == null) { + Logger.i(TAG, "Found emoji [${emoji}]") + cb(synchronized(_cache_lock) { _cache_drawables[url] }); + } + else { + Logger.w(TAG, "Exception on emoji load [${emoji}]: ${it.message}", it); + } + } + } + } + fun getEmojiUrl(emoji: String): String? { + synchronized(_cache_lock) { + return _cache_urls[emoji]; + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientCapabilities.kt b/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientCapabilities.kt new file mode 100644 index 00000000..4b15791e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientCapabilities.kt @@ -0,0 +1,21 @@ +package com.futo.platformplayer.api.media + +data class PlatformClientCapabilities( + val hasChannelSearch: Boolean = false, + val hasGetComments: Boolean = false, + val hasGetUserSubscriptions: Boolean = false, + val hasSearchPlaylists: Boolean = false, + val hasGetPlaylist: Boolean = false, + val hasGetUserPlaylists: Boolean = false, + val hasSearchChannelContents: Boolean = false, + val hasSaveState: Boolean = false, + val hasGetPlaybackTracker: Boolean = false, + val hasGetChannelUrlByClaim: Boolean = false, + val hasGetChannelTemplateByClaimMap: Boolean = false, + val hasGetSearchCapabilities: Boolean = false, + val hasGetChannelCapabilities: Boolean = false, + val hasGetLiveEvents: Boolean = false, + val hasGetLiveChatWindow: Boolean = false +) { + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientPool.kt b/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientPool.kt new file mode 100644 index 00000000..ef1fc37c --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientPool.kt @@ -0,0 +1,66 @@ +package com.futo.platformplayer.api.media + +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.logging.Logger + +class PlatformClientPool { + private val _parent: JSClient; + private val _pool: HashMap = hashMapOf(); + private var _poolCounter = 0; + + var isDead: Boolean = false + private set; + val onDead = Event2(); + + constructor(parentClient: IPlatformClient) { + if(parentClient !is JSClient) + throw IllegalArgumentException("Pooling only supported for JSClients right now"); + Logger.i(TAG, "Pool for ${parentClient.name} was started"); + + this._parent = parentClient; + parentClient.getUnderlyingPlugin().onStopped.subscribe { + Logger.i(TAG, "Pool for [${parentClient.name}] was killed"); + isDead = true; + onDead.emit(parentClient, this); + + for(clientPair in _pool) { + clientPair.key.disable(); + } + }; + } + + fun getClient(capacity: Int): IPlatformClient { + if(capacity < 1) + throw IllegalArgumentException("Capacity should be at least 1"); + val parentPlugin = _parent.getUnderlyingPlugin(); + if(parentPlugin._runtime?.isDead == true || parentPlugin._runtime?.isClosed == true) { + isDead = true; + onDead.emit(_parent, this); + } + + var reserved: JSClient?; + synchronized(_pool) { + _poolCounter++; + reserved = _pool.keys.find { !it.isBusy }; + if(reserved == null && _pool.size < capacity) { + Logger.i(TAG, "Started additional [${_parent.name}] client in pool (${_pool.size + 1}/${capacity})"); + reserved = _parent.getCopy(); + reserved?.initialize(); + _pool[reserved!!] = _poolCounter; + } + else + reserved = _pool.entries.toList().sortedBy { it.value }.first().key; + _pool[reserved!!] = _poolCounter; + } + return reserved!!; + } + + + companion object { + val TAG = "PlatformClientPool"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/PlatformID.kt b/app/src/main/java/com/futo/platformplayer/api/media/PlatformID.kt new file mode 100644 index 00000000..0617c3ee --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/PlatformID.kt @@ -0,0 +1,53 @@ +package com.futo.platformplayer.api.media + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.getOrThrowNullable +import com.futo.polycentric.core.combineHashCodes + +@kotlinx.serialization.Serializable +class PlatformID { + val platform: String; + val value: String?; + var pluginId: String? = null; + var claimType: Int = 0; + var claimFieldType: Int = -1; + + constructor(platform: String, id: String?, pluginId: String? = null, claimType: Int = 0, claimFieldType: Int = -1) { + this.platform = platform; + this.value = id; + this.pluginId = pluginId; + this.claimType = claimType; + this.claimFieldType = claimFieldType; + } + + override fun equals(other: Any?): Boolean { + if (other !is PlatformID) { + return false + } + + return platform == other.platform && value == other.value + } + + override fun hashCode(): Int { + return combineHashCodes(listOf(platform.hashCode(), value?.hashCode())) + } + + override fun toString(): String { + return "(platform: $platform, value: $value, pluginId: $pluginId, claimType: $claimType, claimFieldType: $claimFieldType)"; + } + + companion object { + fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformID { + val contextName = "PlatformID"; + return PlatformID( + value.getOrThrow(config, "platform", contextName), + value.getOrThrowNullable(config, "value", contextName), + config.id, + value.getOrDefault(config, "claimType", contextName, 0) ?: 0, + value.getOrDefault(config, "claimFieldType", contextName, -1) ?: -1); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/Serializer.kt b/app/src/main/java/com/futo/platformplayer/api/media/Serializer.kt new file mode 100644 index 00000000..40c3edfd --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/Serializer.kt @@ -0,0 +1,9 @@ +package com.futo.platformplayer.api.media + +import kotlinx.serialization.json.Json + +class Serializer { + companion object { + val json = Json { ignoreUnknownKeys = true; encodeDefaults = true; }; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/exceptions/APIRequestFailedException.kt b/app/src/main/java/com/futo/platformplayer/api/media/exceptions/APIRequestFailedException.kt new file mode 100644 index 00000000..0a7e8160 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/exceptions/APIRequestFailedException.kt @@ -0,0 +1,4 @@ +package com.futo.platformplayer.api.media.exceptions + +class APIRequestFailedException(msg : String) : IllegalStateException(msg) { +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/exceptions/AlreadyQueuedException.kt b/app/src/main/java/com/futo/platformplayer/api/media/exceptions/AlreadyQueuedException.kt new file mode 100644 index 00000000..a3af72b9 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/exceptions/AlreadyQueuedException.kt @@ -0,0 +1,5 @@ +package com.futo.platformplayer.api.media.exceptions + +class AlreadyQueuedException(message: String?) : Exception(message) { + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/exceptions/ContentNotAvailableYetException.kt b/app/src/main/java/com/futo/platformplayer/api/media/exceptions/ContentNotAvailableYetException.kt new file mode 100644 index 00000000..fd3987a0 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/exceptions/ContentNotAvailableYetException.kt @@ -0,0 +1,5 @@ +package com.futo.platformplayer.api.media.exceptions + +class ContentNotAvailableYetException(message: String?, val availableWhen: String) : Exception(message) { + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/exceptions/NoPlatformClientException.kt b/app/src/main/java/com/futo/platformplayer/api/media/exceptions/NoPlatformClientException.kt new file mode 100644 index 00000000..4761ba81 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/exceptions/NoPlatformClientException.kt @@ -0,0 +1,3 @@ +package com.futo.platformplayer.api.media.exceptions + +class NoPlatformClientException(s: String) : IllegalArgumentException("No enabled PlatformClient: $s") {} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/exceptions/NotFoundException.kt b/app/src/main/java/com/futo/platformplayer/api/media/exceptions/NotFoundException.kt new file mode 100644 index 00000000..dff1bdbc --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/exceptions/NotFoundException.kt @@ -0,0 +1,4 @@ +package com.futo.platformplayer.api.media.exceptions + +class NotFoundException(message: String?) : Exception(message) { +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/exceptions/UnknownPlatformException.kt b/app/src/main/java/com/futo/platformplayer/api/media/exceptions/UnknownPlatformException.kt new file mode 100644 index 00000000..9ab7d470 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/exceptions/UnknownPlatformException.kt @@ -0,0 +1,4 @@ +package com.futo.platformplayer.api.media.exceptions + +class UnknownPlatformException(s : String) : IllegalArgumentException("Unknown platform type:$s") { +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/exceptions/search/NoNextPageException.kt b/app/src/main/java/com/futo/platformplayer/api/media/exceptions/search/NoNextPageException.kt new file mode 100644 index 00000000..07a5d837 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/exceptions/search/NoNextPageException.kt @@ -0,0 +1,5 @@ +package com.futo.platformplayer.api.media.exceptions.search + +class NoNextPageException(s: String? = null) : IllegalStateException("No next page available:$s") { + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/PlatformAuthorLink.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/PlatformAuthorLink.kt new file mode 100644 index 00000000..3104fe2c --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/PlatformAuthorLink.kt @@ -0,0 +1,40 @@ +package com.futo.platformplayer.api.media.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.getOrThrow + +/** + * A link to a channel, often with its own name and thumbnail + */ +@kotlinx.serialization.Serializable +class PlatformAuthorLink { + val id: PlatformID; + val name: String; + val url: String; + val thumbnail: String?; + var subscribers: Long? = null; //Optional + + constructor(id: PlatformID, name: String, url: String, thumbnail: String? = null, subscribers: Long? = null) + { + this.id = id; + this.name = name; + this.url = url; + this.thumbnail = thumbnail; + this.subscribers = subscribers; + } + + companion object { + fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorLink { + val context = "AuthorLink" + return PlatformAuthorLink(PlatformID.fromV8(config, value.getOrThrow(config, "id", context, false)), + value.getOrThrow(config ,"name", context), + value.getOrThrow(config, "url", context), + value.getOrDefault(config, "thumbnail", context, null), + if(value.has("subscribers")) value.getOrThrow(config,"subscribers", context) else null + ); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/ResultCapabilities.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/ResultCapabilities.kt new file mode 100644 index 00000000..6fa88c25 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/ResultCapabilities.kt @@ -0,0 +1,102 @@ +package com.futo.platformplayer.api.media.models + +import com.caoccao.javet.values.V8Value +import com.caoccao.javet.values.primitive.V8ValueInteger +import com.caoccao.javet.values.reference.V8ValueArray +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.expectV8Variant +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.getOrThrow + + +class ResultCapabilities( + val types: List = listOf(), + val sorts: List = listOf(), + val filters: List = listOf() +) { + + fun hasType(type: String): Boolean { + return types.contains(type); + } + fun hasSort(sort: String): Boolean { + return sorts.contains(sort); + } + + companion object { + const val TYPE_VIDEOS = "VIDEOS"; + const val TYPE_STREAMS = "STREAMS"; + const val TYPE_LIVE = "LIVE"; + const val TYPE_MIXED = "MIXED"; + + const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL"; + + const val DATE_LAST_HOUR = "LAST_HOUR"; + const val DATE_TODAY = "TODAY"; + const val DATE_LAST_WEEK = "LAST_WEEK"; + const val DATE_LAST_MONTH = "LAST_MONTH"; + const val DATE_LAST_YEAR = "LAST_YEAR"; + + const val DURATION_SHORT = "SHORT"; + const val DURATION_MEDIUM = "MEDIUM"; + const val DURATION_LONG = "LONG"; + + fun fromV8(config: IV8PluginConfig, value: V8ValueObject): ResultCapabilities { + val contextName = "ResultCapabilities"; + return ResultCapabilities( + value.getOrThrow(config, "types", contextName).toArray().map { it.expectV8Variant(config, "Capabilities.types") }, + value.getOrThrow(config, "sorts", contextName).toArray().map { it.expectV8Variant(config, "Capabilities.sorts"); }, + value.getOrDefault(config, "filters", contextName, null) + ?.toArray() + ?.map { FilterGroup.fromV8(config, it as V8ValueObject) } + ?.toList() ?: listOf()); + } + } +} + + +@kotlinx.serialization.Serializable +class FilterGroup( + val name: String, + val filters: List = listOf(), + val isMultiSelect: Boolean, + val id: String? = null +) { + @kotlinx.serialization.Transient + val idOrName: String get() = id ?: name; + + companion object { + fun fromV8(config: IV8PluginConfig, value: V8ValueObject): FilterGroup { + return FilterGroup( + value.getString("name"), + value.getOrDefault(config, "filters", "FilterGroup", null) + ?.toArray() + ?.map { FilterCapability.fromV8(it as V8ValueObject) } + ?.toList() ?: listOf(), + value.getBoolean("isMultiSelect"), + value.getString("id")); + } + } +} + +@kotlinx.serialization.Serializable +class FilterCapability( + val name: String, + val value: String, + val id: String? = null) { + val idOrName: String get() = id ?: name; + + companion object { + fun fromV8(obj: V8ValueObject): FilterCapability { + val value = obj.get("value") as V8Value; + return FilterCapability( + obj.getString("name"), + if(value is V8ValueInteger) + value.value.toString() + else + value.toString(), + obj.getString("id") + ); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/Thumbnails.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/Thumbnails.kt new file mode 100644 index 00000000..a4ab6d8a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/Thumbnails.kt @@ -0,0 +1,45 @@ +package com.futo.platformplayer.api.media.models + +import com.caoccao.javet.values.reference.V8ValueArray +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrThrow + +@kotlinx.serialization.Serializable +class Thumbnails { + val sources : Array; + + constructor() { sources = arrayOf(); } + constructor(thumbnails : Array) { + sources = thumbnails.filter {it.url != null} .sortedBy { it.quality }.toTypedArray(); + } + + fun getHQThumbnail() : String? { + return sources.lastOrNull()?.url; + } + fun getLQThumbnail() : String? { + return sources.firstOrNull()?.url; + } + fun hasMultiple() = sources.size > 1; + + + companion object { + fun fromV8(config: IV8PluginConfig, value: V8ValueObject): Thumbnails { + return Thumbnails((value.getOrThrow(config, "sources", "Thumbnails")) + .toArray() + .map { Thumbnail.fromV8(it as V8ValueObject) } + .toTypedArray()); + } + } +} +@kotlinx.serialization.Serializable +data class Thumbnail(val url : String?, val quality : Int = 0) { + + companion object { + fun fromV8(value: V8ValueObject): Thumbnail { + return Thumbnail( + value.getString("url"), + value.getInteger("quality")); + } + } +}; \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/channels/IPlatformChannel.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/channels/IPlatformChannel.kt new file mode 100644 index 00000000..e744be9b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/channels/IPlatformChannel.kt @@ -0,0 +1,20 @@ +package com.futo.platformplayer.api.media.models.channels + +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.structures.IPager + +interface IPlatformChannel { + val id : PlatformID; + val name : String; + val thumbnail : String?; + val banner : String?; + val subscribers : Long; + val description: String?; + val url: String; + val links: Map; + val urlAlternatives: List; + + fun getContents(client: IPlatformClient): IPager; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/channels/SerializedChannel.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/channels/SerializedChannel.kt new file mode 100644 index 00000000..7c457a30 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/channels/SerializedChannel.kt @@ -0,0 +1,55 @@ +package com.futo.platformplayer.api.media.models.channels + +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.Serializer +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.structures.IPager +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@kotlinx.serialization.Serializable +class SerializedChannel( + override val id: PlatformID, + override val name: String, + override val thumbnail: String?, + override val banner: String?, + override val subscribers: Long, + override val description: String?, + override val url: String, + override val links: Map, + override val urlAlternatives: List = listOf() +) : IPlatformChannel { + + fun toJson(): String { + return Json.encodeToString(this); + } + + fun fromJson(str: String): SerializedChannel { + return Serializer.json.decodeFromString(str); + } + fun fromJsonArray(str: String): Array { + return Serializer.json.decodeFromString>(str); + } + + override fun getContents(client: IPlatformClient): IPager { + TODO("Not yet implemented") + } + + companion object { + fun fromChannel(channel: IPlatformChannel): SerializedChannel { + return SerializedChannel( + channel.id, + channel.name, + channel.thumbnail, + channel.banner, + channel.subscribers, + channel.description, + channel.url, + channel.links, + channel.urlAlternatives + ); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/comments/IPlatformComment.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/comments/IPlatformComment.kt new file mode 100644 index 00000000..3cad3558 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/comments/IPlatformComment.kt @@ -0,0 +1,19 @@ +package com.futo.platformplayer.api.media.models.comments + +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.structures.IPager +import java.time.OffsetDateTime + +interface IPlatformComment { + val contextUrl: String; + val author : PlatformAuthorLink; + val message : String; + val rating : IRating; + val date : OffsetDateTime?; + + val replyCount : Int?; + + fun getReplies(client: IPlatformClient) : IPager?; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/comments/NoCommentsPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/comments/NoCommentsPager.kt new file mode 100644 index 00000000..a7a4f10d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/comments/NoCommentsPager.kt @@ -0,0 +1,12 @@ +package com.futo.platformplayer.api.media.models.comments + +import com.futo.platformplayer.api.media.structures.IPager + +class NoCommentsPager : IPager { + + override fun hasMorePages(): Boolean = false; + override fun nextPage() { } + override fun getResults(): List { + return listOf(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/comments/PlatformComment.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/comments/PlatformComment.kt new file mode 100644 index 00000000..7de46eb3 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/comments/PlatformComment.kt @@ -0,0 +1,30 @@ +package com.futo.platformplayer.api.media.models.comments + +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.structures.IPager +import java.time.OffsetDateTime + +open class PlatformComment : IPlatformComment { + override val contextUrl: String; + override val author: PlatformAuthorLink; + override val message: String; + override val rating: IRating; + override val date: OffsetDateTime; + + override val replyCount: Int?; + + constructor(contextUrl: String, author: PlatformAuthorLink, msg: String, rating: IRating, date: OffsetDateTime, replyCount: Int? = null) { + this.contextUrl = contextUrl; + this.author = author; + this.message = msg; + this.rating = rating; + this.date = date; + this.replyCount = replyCount; + } + + override fun getReplies(client: IPlatformClient): IPager { + return NoCommentsPager(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/comments/PolycentricPlatformComment.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/comments/PolycentricPlatformComment.kt new file mode 100644 index 00000000..a07d10c9 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/comments/PolycentricPlatformComment.kt @@ -0,0 +1,42 @@ +package com.futo.platformplayer.api.media.models.comments + +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.polycentric.PolycentricCache +import com.futo.platformplayer.states.StatePolycentric +import com.futo.polycentric.core.Pointer +import com.futo.polycentric.core.SignedEvent +import userpackage.Protocol.Reference +import java.time.OffsetDateTime + +class PolycentricPlatformComment : IPlatformComment { + override val contextUrl: String; + override val author: PlatformAuthorLink; + override val message: String; + override val rating: IRating; + override val date: OffsetDateTime; + + override val replyCount: Int?; + + val reference: Reference; + + constructor(contextUrl: String, author: PlatformAuthorLink, msg: String, rating: IRating, date: OffsetDateTime, reference: Reference, replyCount: Int? = null) { + this.contextUrl = contextUrl; + this.author = author; + this.message = msg; + this.rating = rating; + this.date = date; + this.replyCount = replyCount; + this.reference = reference; + } + + override fun getReplies(client: IPlatformClient): IPager { + return NoCommentsPager(); + } + + fun cloneWithUpdatedReplyCount(replyCount: Int?): PolycentricPlatformComment { + return PolycentricPlatformComment(contextUrl, author, message, rating, date, reference, replyCount); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt new file mode 100644 index 00000000..4f93a377 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt @@ -0,0 +1,29 @@ +package com.futo.platformplayer.api.media.models.contents + +import com.futo.platformplayer.api.media.exceptions.UnknownPlatformException + +enum class ContentType(val value: Int) { + UNKNOWN(0), + MEDIA(1), + POST(2), + ARTICLE(3), + PLAYLIST(4), + + URL(9), + + NESTED_VIDEO(11), + + + PLACEHOLDER(90), + DEFERRED(91); + + companion object { + fun fromInt(value: Int): ContentType + { + val result = ContentType.values().firstOrNull { it.value == value }; + if(result == null) + throw UnknownPlatformException(value.toString()); + return result; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt new file mode 100644 index 00000000..554a4723 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt @@ -0,0 +1,18 @@ +package com.futo.platformplayer.api.media.models.contents + +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import java.time.OffsetDateTime + +interface IPlatformContent { + val contentType: ContentType; + + val id: PlatformID; + val name: String; + val url: String; + val shareUrl: String; + + val datetime: OffsetDateTime?; + + val author: PlatformAuthorLink; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContentDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContentDetails.kt new file mode 100644 index 00000000..cd422a9f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContentDetails.kt @@ -0,0 +1,13 @@ +package com.futo.platformplayer.api.media.models.contents + +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker +import com.futo.platformplayer.api.media.structures.IPager + +interface IPlatformContentDetails: IPlatformContent { + + + fun getComments(client: IPlatformClient): IPager?; + fun getPlaybackTracker(): IPlaybackTracker?; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/PlatformContentDeferred.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/PlatformContentDeferred.kt new file mode 100644 index 00000000..c98c935c --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/PlatformContentDeferred.kt @@ -0,0 +1,15 @@ +package com.futo.platformplayer.api.media.models.contents + +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import java.time.OffsetDateTime + +class PlatformContentDeferred(pluginId: String?): IPlatformContent { + override val contentType: ContentType = ContentType.DEFERRED; + override val id: PlatformID = PlatformID("", null, pluginId); + override val name: String = ""; + override val url: String = ""; + override val shareUrl: String = ""; + override val datetime: OffsetDateTime? = null; + override val author: PlatformAuthorLink = PlatformAuthorLink(PlatformID("", pluginId), "", "", null, null); +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/PlatformContentPlaceholder.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/PlatformContentPlaceholder.kt new file mode 100644 index 00000000..e8633aa8 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/PlatformContentPlaceholder.kt @@ -0,0 +1,15 @@ +package com.futo.platformplayer.api.media.models.contents + +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import java.time.OffsetDateTime + +class PlatformContentPlaceholder(pluginId: String): IPlatformContent { + override val contentType: ContentType = ContentType.PLACEHOLDER; + override val id: PlatformID = PlatformID("", null, pluginId); + override val name: String = ""; + override val url: String = ""; + override val shareUrl: String = ""; + override val datetime: OffsetDateTime? = null; + override val author: PlatformAuthorLink = PlatformAuthorLink(PlatformID("", pluginId), "", "", null, null); +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/live/ILiveChatWindowDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/live/ILiveChatWindowDescriptor.kt new file mode 100644 index 00000000..ef5096fa --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/live/ILiveChatWindowDescriptor.kt @@ -0,0 +1,6 @@ +package com.futo.platformplayer.api.media.models.live + +interface ILiveChatWindowDescriptor { + val url: String; + val removeElements: List; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/live/ILiveEventChatMessage.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/live/ILiveEventChatMessage.kt new file mode 100644 index 00000000..c26b603a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/live/ILiveEventChatMessage.kt @@ -0,0 +1,8 @@ +package com.futo.platformplayer.api.media.models.live + +interface ILiveEventChatMessage: IPlatformLiveEvent { + + val name: String; + val thumbnail: String?; + val message: String; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/live/IPlatformLiveEvent.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/live/IPlatformLiveEvent.kt new file mode 100644 index 00000000..6ffaea35 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/live/IPlatformLiveEvent.kt @@ -0,0 +1,32 @@ +package com.futo.platformplayer.api.media.models.live + +import com.caoccao.javet.values.V8Value +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.api.media.models.ratings.RatingScaler +import com.futo.platformplayer.api.media.models.ratings.RatingType +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.orDefault + +interface IPlatformLiveEvent { + val type : LiveEventType; + + + companion object { + fun fromV8(config: IV8PluginConfig, obj: V8ValueObject, contextName: String = "Unknown") : IPlatformLiveEvent { + val contextName = "LiveEvent"; + val type = LiveEventType.fromInt(obj.getOrThrow(config, "type", contextName)); + return when(type) { + LiveEventType.COMMENT -> LiveEventComment.fromV8(config, obj); + LiveEventType.EMOJIS -> LiveEventEmojis.fromV8(config, obj); + LiveEventType.DONATION -> LiveEventDonation.fromV8(config, obj); + LiveEventType.VIEWCOUNT -> LiveEventViewCount.fromV8(config, obj); + LiveEventType.RAID -> LiveEventRaid.fromV8(config, obj); + else -> throw NotImplementedError("Unknown type ${type}"); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventComment.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventComment.kt new file mode 100644 index 00000000..28bbe15a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventComment.kt @@ -0,0 +1,42 @@ +package com.futo.platformplayer.api.media.models.live + +import com.caoccao.javet.values.reference.V8ValueArray +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.getOrThrow + +class LiveEventComment: IPlatformLiveEvent, ILiveEventChatMessage { + override val type: LiveEventType = LiveEventType.COMMENT; + + override val name: String; + override val thumbnail: String?; + override val message: String; + + val colorName: String?; + val badges: List; + + constructor(name: String, thumbnail: String?, message: String, colorName: String? = null, badges: List? = null) { + this.name = name; + this.message = message; + this.thumbnail = thumbnail; + this.colorName = colorName; + this.badges = badges ?: listOf(); + } + + companion object { + fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventComment { + val contextName = "LiveEventComment" + + val colorName = obj.getOrDefault(config, "colorName", contextName, null); + val badges = obj.getOrDefault>(config, "badges", contextName, null); + + return LiveEventComment( + obj.getOrThrow(config, "name", contextName), + obj.getOrThrow(config, "thumbnail", contextName, true), + obj.getOrThrow(config, "message", contextName), + colorName, badges); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventDonation.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventDonation.kt new file mode 100644 index 00000000..a4ac5d47 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventDonation.kt @@ -0,0 +1,50 @@ +package com.futo.platformplayer.api.media.models.live + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.getOrThrow + +class LiveEventDonation: IPlatformLiveEvent, ILiveEventChatMessage { + override val type: LiveEventType = LiveEventType.DONATION; + + private val _creationTimestamp = System.currentTimeMillis(); + private var _hasExpired = false; + + override val name: String; + override val thumbnail: String?; + override val message: String; + val amount: String; + val colorDonation: String?; + + var expire: Int = 6000; + + + constructor(name: String, thumbnail: String?, message: String, amount: String, expire: Int = 6000, colorDonation: String? = null) { + this.name = name; + this.message = message; + this.thumbnail = thumbnail; + this.amount = amount; + this.expire = expire; + this.colorDonation = colorDonation; + } + + fun hasExpired(): Boolean { + _hasExpired = _hasExpired || (System.currentTimeMillis() - _creationTimestamp) > expire; + return _hasExpired; + } + + companion object { + fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventDonation { + val contextName = "LiveEventDonation" + return LiveEventDonation( + obj.getOrThrow(config, "name", contextName), + obj.getOrThrow(config, "thumbnail", contextName, true), + obj.getOrThrow(config, "message", contextName), + obj.getOrThrow(config, "amount", contextName), + obj.getOrDefault(config, "expire", contextName, 6000) ?: 6000, + obj.getOrDefault(config, "colorDonation", contextName, null)); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventEmojis.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventEmojis.kt new file mode 100644 index 00000000..6e29bac5 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventEmojis.kt @@ -0,0 +1,23 @@ +package com.futo.platformplayer.api.media.models.live + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrThrow + +class LiveEventEmojis: IPlatformLiveEvent { + override val type: LiveEventType = LiveEventType.EMOJIS; + + val emojis: HashMap; + + constructor(emojis: HashMap) { + this.emojis = emojis; + } + + companion object { + fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventEmojis { + val contextName = "LiveEventEmojis" + return LiveEventEmojis( + obj.getOrThrow(config, "emojis", contextName)); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventRaid.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventRaid.kt new file mode 100644 index 00000000..ff5dd36f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventRaid.kt @@ -0,0 +1,29 @@ +package com.futo.platformplayer.api.media.models.live + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrThrow + +class LiveEventRaid: IPlatformLiveEvent { + override val type: LiveEventType = LiveEventType.RAID; + + val targetName: String; + val targetThumbnail: String; + val targetUrl: String; + + constructor(name: String, url: String, thumbnail: String) { + this.targetName = name; + this.targetUrl = url; + this.targetThumbnail = thumbnail; + } + + companion object { + fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventRaid { + val contextName = "LiveEventRaid" + return LiveEventRaid( + obj.getOrThrow(config, "targetName", contextName), + obj.getOrThrow(config, "targetUrl", contextName), + obj.getOrThrow(config, "targetThumbnail", contextName)); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventType.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventType.kt new file mode 100644 index 00000000..ddec2e0a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventType.kt @@ -0,0 +1,16 @@ +package com.futo.platformplayer.api.media.models.live + +enum class LiveEventType(val value : Int) { + UNKNOWN(0), + COMMENT(1), + EMOJIS(4), + DONATION(5), + VIEWCOUNT(10), + RAID(100); + + companion object{ + fun fromInt(value : Int) : LiveEventType{ + return LiveEventType.values().first { it.value == value }; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventViewCount.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventViewCount.kt new file mode 100644 index 00000000..adcfb883 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventViewCount.kt @@ -0,0 +1,23 @@ +package com.futo.platformplayer.api.media.models.live + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrThrow + +class LiveEventViewCount: IPlatformLiveEvent { + override val type: LiveEventType = LiveEventType.VIEWCOUNT; + + val viewCount: Int; + + constructor(viewCount: Int) { + this.viewCount = viewCount; + } + + companion object { + fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventViewCount { + val contextName = "LiveEventViewCount" + return LiveEventViewCount( + obj.getOrThrow(config, "viewCount", contextName)); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/nested/IPlatformNestedContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/nested/IPlatformNestedContent.kt new file mode 100644 index 00000000..a44afc88 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/nested/IPlatformNestedContent.kt @@ -0,0 +1,16 @@ +package com.futo.platformplayer.api.media.models.nested + +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent + +interface IPlatformNestedContent: IPlatformContent { + val nestedContentType: ContentType; + val contentUrl: String; + val contentName: String?; + val contentDescription: String?; + val contentProvider: String? + val contentThumbnails: Thumbnails; + val contentPlugin: String?; + val contentSupported: Boolean; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/playback/IPlaybackTracker.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/playback/IPlaybackTracker.kt new file mode 100644 index 00000000..59527d66 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/playback/IPlaybackTracker.kt @@ -0,0 +1,10 @@ +package com.futo.platformplayer.api.media.models.playback + +interface IPlaybackTracker { + val nextRequest: Int; + + fun shouldUpdate(): Boolean; + + fun onInit(seconds: Double); + fun onProgress(seconds: Double, isPlaying: Boolean); +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/playlists/IPlatformPlaylist.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/playlists/IPlatformPlaylist.kt new file mode 100644 index 00000000..d0470cfe --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/playlists/IPlatformPlaylist.kt @@ -0,0 +1,11 @@ +package com.futo.platformplayer.api.media.models.playlists + +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.structures.IPager + +interface IPlatformPlaylist : IPlatformContent { + val thumbnail: String?; + val videoCount: Int; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/playlists/IPlatformPlaylistDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/playlists/IPlatformPlaylistDetails.kt new file mode 100644 index 00000000..b7783668 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/playlists/IPlatformPlaylistDetails.kt @@ -0,0 +1,12 @@ +package com.futo.platformplayer.api.media.models.playlists + +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.models.Playlist + +interface IPlatformPlaylistDetails: IPlatformPlaylist { + //TODO: Determine if this should be IPlatformContent (probably not?) + val contents: IPager; + + fun toPlaylist(): Playlist; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/post/IPlatformPost.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/post/IPlatformPost.kt new file mode 100644 index 00000000..a5503c11 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/post/IPlatformPost.kt @@ -0,0 +1,13 @@ +package com.futo.platformplayer.api.media.models.post + +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.contents.IPlatformContent + +/** + * A search result representing a video (overview data) + */ +interface IPlatformPost: IPlatformContent { + val description: String; + val thumbnails: List; + val images: List; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/post/IPlatformPostDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/post/IPlatformPostDetails.kt new file mode 100644 index 00000000..985e8697 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/post/IPlatformPostDetails.kt @@ -0,0 +1,18 @@ +package com.futo.platformplayer.api.media.models.post + +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.sources.* +import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource +import com.futo.platformplayer.api.media.models.video.IPlatformVideo + +/** + * A detailed video model with data including video/audio sources + */ +interface IPlatformPostDetails : IPlatformPost, IPlatformContentDetails { + val rating : IRating; + + val textType: TextType; + val content: String; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/post/TextType.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/post/TextType.kt new file mode 100644 index 00000000..8a2c20d4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/post/TextType.kt @@ -0,0 +1,19 @@ +package com.futo.platformplayer.api.media.models.post + +import com.futo.platformplayer.api.media.exceptions.UnknownPlatformException + +enum class TextType(val value: Int) { + RAW(0), + HTML(1), + MARKUP(2); + + companion object { + fun fromInt(value: Int): TextType + { + val result = TextType.values().firstOrNull { it.value == value }; + if(result == null) + throw IllegalArgumentException("Unknown Texttype: $value"); + return result; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/IRating.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/IRating.kt new file mode 100644 index 00000000..4d560b9f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/IRating.kt @@ -0,0 +1,28 @@ +package com.futo.platformplayer.api.media.models.ratings + +import com.caoccao.javet.values.V8Value +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.orDefault +import com.futo.platformplayer.serializers.IRatingSerializer + +@kotlinx.serialization.Serializable(with = IRatingSerializer::class) +interface IRating { + val type : RatingType; + + + companion object { + fun fromV8OrDefault(config: IV8PluginConfig, obj: V8Value?, default: IRating) = obj.orDefault(default) { fromV8(config, it as V8ValueObject) }; + fun fromV8(config: IV8PluginConfig, obj: V8ValueObject, contextName: String = "Unknown") : IRating { + val contextName = "Rating"; + val type = RatingType.fromInt(obj.getOrThrow(config, "type", contextName)); + return when(type) { + RatingType.LIKES -> RatingLikes.fromV8(config, obj); + RatingType.LIKEDISLIKES -> RatingLikeDislikes.fromV8(config, obj); + RatingType.SCALE -> RatingScaler.fromV8(config, obj); + else -> throw NotImplementedError("Unknown type ${type}"); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingLikeDislikes.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingLikeDislikes.kt new file mode 100644 index 00000000..6d0e787b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingLikeDislikes.kt @@ -0,0 +1,20 @@ +package com.futo.platformplayer.api.media.models.ratings + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrThrow + +/** + * A rating that has both likes and dislikes + */ +@kotlinx.serialization.Serializable +class RatingLikeDislikes(val likes: Long, val dislikes: Long) : IRating { + + override val type: RatingType = RatingType.LIKEDISLIKES; + + companion object { + fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : RatingLikeDislikes { + return RatingLikeDislikes(obj.getOrThrow(config, "likes", "RatingLikeDislikes"), obj.getOrThrow(config, "dislikes", "RatingLikeDislikes")); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingLikes.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingLikes.kt new file mode 100644 index 00000000..e40169f2 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingLikes.kt @@ -0,0 +1,19 @@ +package com.futo.platformplayer.api.media.models.ratings + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrThrow + +/** + * A rating that has just likes + */ +@kotlinx.serialization.Serializable +class RatingLikes(val likes: Long) : IRating { + override val type: RatingType = RatingType.LIKES; + + companion object { + fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : RatingLikes { + return RatingLikes(obj.getOrThrow(config, "likes", "RatingLikes")); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingScaler.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingScaler.kt new file mode 100644 index 00000000..7646cf24 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingScaler.kt @@ -0,0 +1,19 @@ +package com.futo.platformplayer.api.media.models.ratings + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrThrow + +/** + * A rating that is based on a scaler (0..1) + */ +@kotlinx.serialization.Serializable +class RatingScaler(val value: Float) : IRating { + override val type: RatingType = RatingType.SCALE; + + companion object { + fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : RatingScaler { + return RatingScaler(obj.getOrThrow(config, "value", "RatingScaler")); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingType.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingType.kt new file mode 100644 index 00000000..eba21430 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingType.kt @@ -0,0 +1,14 @@ +package com.futo.platformplayer.api.media.models.ratings + +enum class RatingType(val value : Int) { + UNKNOWN(0), + LIKES(1), + LIKEDISLIKES(2), + SCALE(3); + + companion object{ + fun fromInt(value : Int) : RatingType{ + return RatingType.values().first { it.value == value }; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/IVideoSourceDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/IVideoSourceDescriptor.kt new file mode 100644 index 00000000..d5c1d964 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/IVideoSourceDescriptor.kt @@ -0,0 +1,8 @@ +package com.futo.platformplayer.api.media.models.streams + +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource + +interface IVideoSourceDescriptor { + val isUnMuxed: Boolean; + val videoSources: Array; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/LocalVideoMuxedSourceDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/LocalVideoMuxedSourceDescriptor.kt new file mode 100644 index 00000000..b5309931 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/LocalVideoMuxedSourceDescriptor.kt @@ -0,0 +1,10 @@ +package com.futo.platformplayer.api.media.models.streams + +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.downloads.VideoLocal + +class LocalVideoMuxedSourceDescriptor( + private val video: VideoLocal + ) : VideoMuxedSourceDescriptor() { + override val videoSources: Array get() = video.videoSource.toTypedArray(); +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/LocalVideoUnMuxedSourceDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/LocalVideoUnMuxedSourceDescriptor.kt new file mode 100644 index 00000000..b5d28c6f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/LocalVideoUnMuxedSourceDescriptor.kt @@ -0,0 +1,11 @@ +package com.futo.platformplayer.api.media.models.streams + +import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.downloads.VideoLocal + + +class LocalVideoUnMuxedSourceDescriptor(private val video: VideoLocal) : VideoUnMuxedSourceDescriptor() { + override val videoSources: Array get() = video.videoSource.toTypedArray(); + override val audioSources: Array get() = video.audioSource.toTypedArray(); +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/VideoMuxedSourceDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/VideoMuxedSourceDescriptor.kt new file mode 100644 index 00000000..30d35fba --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/VideoMuxedSourceDescriptor.kt @@ -0,0 +1,10 @@ +package com.futo.platformplayer.api.media.models.streams + +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource + +@kotlinx.serialization.Serializable +abstract class VideoMuxedSourceDescriptor : IVideoSourceDescriptor { + override val isUnMuxed : Boolean = false; + + abstract override val videoSources : Array; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/VideoUnMuxedSourceDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/VideoUnMuxedSourceDescriptor.kt new file mode 100644 index 00000000..f298291d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/VideoUnMuxedSourceDescriptor.kt @@ -0,0 +1,12 @@ +package com.futo.platformplayer.api.media.models.streams + +import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource + +@kotlinx.serialization.Serializable +abstract class VideoUnMuxedSourceDescriptor : IVideoSourceDescriptor { + override val isUnMuxed : Boolean = true; + + abstract override val videoSources : Array; + abstract val audioSources : Array; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/AudioUrlSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/AudioUrlSource.kt new file mode 100644 index 00000000..67548b89 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/AudioUrlSource.kt @@ -0,0 +1,46 @@ +package com.futo.platformplayer.api.media.models.streams.sources + +import com.futo.platformplayer.api.media.models.streams.sources.other.IStreamMetaDataSource +import com.futo.platformplayer.api.media.models.streams.sources.other.StreamMetaData +import com.futo.platformplayer.others.Language + +@kotlinx.serialization.Serializable +class AudioUrlSource( + override val name: String, + val url : String, + override val bitrate : Int, + override val container : String = "", + override val codec: String = "", + override val language: String = Language.UNKNOWN, + override val duration: Long? = null, + override var priority: Boolean = false +) : IAudioUrlSource, IStreamMetaDataSource{ + override var streamMetaData: StreamMetaData? = null; + + override fun getAudioUrl() : String { + return url; + } + + companion object { + fun fromUrlSource(source: IAudioUrlSource?): AudioUrlSource? { + if(source == null) + return null; + + val streamData = if(source is IStreamMetaDataSource) + source.streamMetaData else null; + + val ret = AudioUrlSource( + source.name, + source.getAudioUrl(), + source.bitrate, + source.container, + source.codec, + source.language, + source.duration + ); + ret.streamMetaData = streamData; + + return ret; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/DashManifestSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/DashManifestSource.kt new file mode 100644 index 00000000..3d117516 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/DashManifestSource.kt @@ -0,0 +1,18 @@ +package com.futo.platformplayer.api.media.models.streams.sources + +class DashManifestSource : IVideoSource, IDashManifestSource { + override val width : Int = 0; + override val height : Int = 0; + override val container : String = "Dash"; + override val codec: String = "Dash"; + override val name : String = "Dash"; + override val bitrate: Int? = null; + override val url : String; + override val duration: Long get() = 0; + + override var priority: Boolean = false; + + constructor(url : String) { + this.url = url; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSManifestSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSManifestSource.kt new file mode 100644 index 00000000..52304473 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSManifestSource.kt @@ -0,0 +1,18 @@ +package com.futo.platformplayer.api.media.models.streams.sources + +class HLSManifestSource : IVideoSource, IHLSManifestSource { + override val width : Int = 0; + override val height : Int = 0; + override val container : String = "HLS"; + override val codec: String = "HLS"; + override val name : String = "HLS"; + override val bitrate : Int? = null; + override val url : String; + override val duration: Long = 0; + + override var priority: Boolean = false; + + constructor(url : String) { + this.url = url; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IAudioSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IAudioSource.kt new file mode 100644 index 00000000..eca17e47 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IAudioSource.kt @@ -0,0 +1,11 @@ +package com.futo.platformplayer.api.media.models.streams.sources + +interface IAudioSource { + val name : String; + val bitrate : Int; + val container : String; + val codec : String; + val language : String; + val duration : Long?; + val priority: Boolean; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IAudioUrlSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IAudioUrlSource.kt new file mode 100644 index 00000000..4a9ea42a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IAudioUrlSource.kt @@ -0,0 +1,5 @@ +package com.futo.platformplayer.api.media.models.streams.sources + +interface IAudioUrlSource : IAudioSource { + fun getAudioUrl(): String; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IDashManifestSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IDashManifestSource.kt new file mode 100644 index 00000000..c6086d7e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IDashManifestSource.kt @@ -0,0 +1,5 @@ +package com.futo.platformplayer.api.media.models.streams.sources + +interface IDashManifestSource : IVideoSource { + val url : String; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IHLSManifestSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IHLSManifestSource.kt new file mode 100644 index 00000000..8993d044 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IHLSManifestSource.kt @@ -0,0 +1,8 @@ +package com.futo.platformplayer.api.media.models.streams.sources + +interface IHLSManifestSource : IVideoSource { + val url : String; +} +interface IHLSManifestAudioSource : IAudioSource { + val url : String; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IVideoSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IVideoSource.kt new file mode 100644 index 00000000..867c1ee5 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IVideoSource.kt @@ -0,0 +1,12 @@ +package com.futo.platformplayer.api.media.models.streams.sources + +interface IVideoSource { + val name : String; + val width : Int; + val height : Int; + val container : String; + val codec : String; + val bitrate : Int?; + val duration: Long; + val priority: Boolean; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IVideoUrlSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IVideoUrlSource.kt new file mode 100644 index 00000000..f5bc00d7 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IVideoUrlSource.kt @@ -0,0 +1,5 @@ +package com.futo.platformplayer.api.media.models.streams.sources + +interface IVideoUrlSource : IVideoSource { + fun getVideoUrl(): String; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/LocalAudioSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/LocalAudioSource.kt new file mode 100644 index 00000000..397c24a4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/LocalAudioSource.kt @@ -0,0 +1,48 @@ +package com.futo.platformplayer.api.media.models.streams.sources + +import com.futo.platformplayer.api.media.models.streams.sources.other.IStreamMetaDataSource +import com.futo.platformplayer.api.media.models.streams.sources.other.StreamMetaData +import com.futo.platformplayer.others.Language + +@kotlinx.serialization.Serializable +class LocalAudioSource : IAudioSource, IStreamMetaDataSource { + + override val name: String; + override val bitrate : Int; + override val container : String; + override val codec : String; + override val language: String; + override val duration: Long? = null; + + override var priority: Boolean = false; + + val filePath : String; + val fileSize: Long; + + //Only for particular videos + override var streamMetaData: StreamMetaData? = null; + + constructor(name: String?, filePath : String, fileSize: Long, bitrate : Int, container : String = "", codec: String = "", language: String = Language.UNKNOWN) { + this.name = name ?: "${container} ${bitrate}"; + this.bitrate = bitrate; + this.container = container; + this.codec = codec; + this.filePath = filePath; + this.language = language; + this.fileSize = fileSize; + } + + companion object { + fun fromSource(source: IAudioSource, path: String, fileSize: Long): LocalAudioSource { + return LocalAudioSource( + source.name, + path, + fileSize, + source.bitrate, + source.container, + source.codec, + source.language + ); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/LocalSubtitleSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/LocalSubtitleSource.kt new file mode 100644 index 00000000..0c866af4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/LocalSubtitleSource.kt @@ -0,0 +1,40 @@ +package com.futo.platformplayer.api.media.models.streams.sources + +import android.net.Uri +import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource +import java.io.File + +@kotlinx.serialization.Serializable +class LocalSubtitleSource : ISubtitleSource { + override val name: String; + override val url: String?; + override val format: String?; + override val hasFetch: Boolean get() = false; + + val filePath: String; + + constructor(name: String, format: String?, filePath: String) { + this.name = name; + this.format = format; + this.filePath = filePath; + this.url = Uri.fromFile(File(filePath)).toString(); + } + + override fun getSubtitles(): String? { + return null; + } + + override suspend fun getSubtitlesURI(): Uri? { + return null; + } + + companion object { + fun fromSource(source: SubtitleRawSource, path: String): LocalSubtitleSource { + return LocalSubtitleSource( + source.name, + source.format, + path + ); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/LocalVideoSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/LocalVideoSource.kt new file mode 100644 index 00000000..43455a50 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/LocalVideoSource.kt @@ -0,0 +1,52 @@ +package com.futo.platformplayer.api.media.models.streams.sources + +import com.futo.platformplayer.api.media.models.streams.sources.other.IStreamMetaDataSource +import com.futo.platformplayer.api.media.models.streams.sources.other.StreamMetaData + +@kotlinx.serialization.Serializable +class LocalVideoSource : IVideoSource, IStreamMetaDataSource { + + override val width : Int; + override val height : Int; + override val container : String; + override val codec : String; + override val name : String; + override val bitrate : Int; + override val duration : Long; + + override var priority: Boolean = false; + + val filePath : String; + val fileSize : Long; + + //Only for particular videos + override var streamMetaData: StreamMetaData? = null; + + constructor(name : String, filePath : String, fileSize: Long, width : Int = 0, height : Int = 0, duration: Long = 0, container : String = "", codec : String = "", bitrate : Int = 0) { + this.name = name; + this.width = width; + this.height = height; + this.container = container; + this.codec = codec; + this.duration = duration; + this.filePath = filePath; + this.fileSize = fileSize; + this.bitrate = bitrate; + } + + companion object { + fun fromSource(source: IVideoSource, path: String, fileSize: Long): LocalVideoSource { + return LocalVideoSource( + source.name, + path, + fileSize, + source.width, + source.height, + source.duration, + source.container, + source.codec, + source.bitrate?:0 + ); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/SubtitleRawSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/SubtitleRawSource.kt new file mode 100644 index 00000000..f4dc29a2 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/SubtitleRawSource.kt @@ -0,0 +1,21 @@ +package com.futo.platformplayer.api.media.models.streams.sources + +import android.net.Uri +import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource + +@kotlinx.serialization.Serializable +class SubtitleRawSource( + override val name: String, + override val format: String?, + val _subtitles: String, + override val url: String? = null, + override val hasFetch: Boolean = true +) : ISubtitleSource { + override fun getSubtitles(): String? { + return _subtitles; + } + + override suspend fun getSubtitlesURI(): Uri? { + return null; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/VideoUrlSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/VideoUrlSource.kt new file mode 100644 index 00000000..490b8d4c --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/VideoUrlSource.kt @@ -0,0 +1,48 @@ +package com.futo.platformplayer.api.media.models.streams.sources + +import com.futo.platformplayer.api.media.models.streams.sources.other.IStreamMetaDataSource +import com.futo.platformplayer.api.media.models.streams.sources.other.StreamMetaData + +@kotlinx.serialization.Serializable +open class VideoUrlSource( + override val name : String, + val url : String, + override val width : Int = 0, + override val height : Int = 0, + override val duration: Long = 0, + override val container : String = "", + override val codec : String = "", + override val bitrate : Int? = 0, + + override var priority: Boolean = false +) : IVideoUrlSource, IStreamMetaDataSource { + override var streamMetaData: StreamMetaData? = null; + + override fun getVideoUrl() : String { + return url; + } + + companion object { + fun fromUrlSource(source: IVideoUrlSource?): VideoUrlSource? { + if(source == null) + return null; + + val streamData = if(source is IStreamMetaDataSource) + source.streamMetaData else null; + + val ret = VideoUrlSource( + source.name, + source.getVideoUrl(), + source.width, + source.height, + source.duration, + source.container, + source.codec, + source.bitrate + ); + ret.streamMetaData = streamData; + + return ret; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/other/IStreamMetaDataSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/other/IStreamMetaDataSource.kt new file mode 100644 index 00000000..489f59fd --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/other/IStreamMetaDataSource.kt @@ -0,0 +1,5 @@ +package com.futo.platformplayer.api.media.models.streams.sources.other + +interface IStreamMetaDataSource { + val streamMetaData: StreamMetaData?; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/other/StreamMetaData.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/other/StreamMetaData.kt new file mode 100644 index 00000000..13545acb --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/other/StreamMetaData.kt @@ -0,0 +1,9 @@ +package com.futo.platformplayer.api.media.models.streams.sources.other + +@kotlinx.serialization.Serializable +data class StreamMetaData( + var fileInitStart: Int? = null, + var fileInitEnd: Int? = null, + var fileIndexStart: Int? = null, + var fileIndexEnd: Int? = null +) {} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/subtitles/ISubtitleSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/subtitles/ISubtitleSource.kt new file mode 100644 index 00000000..e210774d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/subtitles/ISubtitleSource.kt @@ -0,0 +1,16 @@ +package com.futo.platformplayer.api.media.models.subtitles + +import android.net.Uri +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred + +interface ISubtitleSource { + val name: String; + val url: String?; + val format: String?; + val hasFetch: Boolean; + + fun getSubtitles(): String?; + + suspend fun getSubtitlesURI(): Uri?; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/IPlatformVideo.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/IPlatformVideo.kt new file mode 100644 index 00000000..69717eee --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/IPlatformVideo.kt @@ -0,0 +1,16 @@ +package com.futo.platformplayer.api.media.models.video + +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.contents.IPlatformContent + +/** + * A search result representing a video (overview data) + */ +interface IPlatformVideo : IPlatformContent { + val thumbnails: Thumbnails; + + val duration: Long; + val viewCount: Long; + + val isLive : Boolean; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/IPlatformVideoDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/IPlatformVideoDetails.kt new file mode 100644 index 00000000..433b44c4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/IPlatformVideoDetails.kt @@ -0,0 +1,28 @@ +package com.futo.platformplayer.api.media.models.video + +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.sources.* +import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource +import com.futo.platformplayer.api.media.structures.IPager + +/** + * A detailed video model with data including video/audio sources + * TODO:TBD if it should be a derived of IPlatformSearchVideo (to cover identical fields) + */ +interface IPlatformVideoDetails : IPlatformVideo, IPlatformContentDetails { + val rating : IRating; + + val description : String; + + val video : IVideoSourceDescriptor; + val preview : IVideoSourceDescriptor?; + val live : IVideoSource?; + val dash: IDashManifestSource?; + val hls: IHLSManifestSource?; + + val subtitles: List; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/ISerializedVideoSourceDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/ISerializedVideoSourceDescriptor.kt new file mode 100644 index 00000000..a5498ac3 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/ISerializedVideoSourceDescriptor.kt @@ -0,0 +1,27 @@ +package com.futo.platformplayer.api.media.models.video + +import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.sources.* +import com.futo.platformplayer.serializers.VideoDescriptorSerializer + +@kotlinx.serialization.Serializable(with = VideoDescriptorSerializer::class) +interface ISerializedVideoSourceDescriptor: IVideoSourceDescriptor { + + companion object { + fun fromDescriptor(descriptor: IVideoSourceDescriptor): ISerializedVideoSourceDescriptor { + val videoSources = descriptor.videoSources + .filter { it is IVideoUrlSource } + .map { VideoUrlSource.fromUrlSource(it as IVideoUrlSource)!! } + .toTypedArray(); + + if(descriptor !is VideoUnMuxedSourceDescriptor) + return SerializedVideoMuxedSourceDescriptor(videoSources); + else + return SerializedVideoNonMuxedSourceDescriptor(videoSources, descriptor.audioSources + .filter { it is IAudioUrlSource } + .map { AudioUrlSource.fromUrlSource(it as IAudioUrlSource)!! } + .toTypedArray()); + } + } +}; \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformContent.kt new file mode 100644 index 00000000..30c6c9bd --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformContent.kt @@ -0,0 +1,25 @@ +package com.futo.platformplayer.api.media.models.video + +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent +import com.futo.platformplayer.api.media.models.post.IPlatformPost +import com.futo.platformplayer.serializers.PlatformContentSerializer + +@kotlinx.serialization.Serializable(with = PlatformContentSerializer::class) +interface SerializedPlatformContent: IPlatformContent { + fun toJson() : String; + fun fromJson(str : String) : SerializedPlatformContent; + fun fromJsonArray(str : String) : Array; + + companion object { + fun fromContent(content : IPlatformContent) : SerializedPlatformContent { + return when(content.contentType) { + ContentType.MEDIA -> SerializedPlatformVideo.fromVideo(content as IPlatformVideo); + ContentType.NESTED_VIDEO -> SerializedPlatformNestedContent.fromNested(content as IPlatformNestedContent); + ContentType.POST -> SerializedPlatformPost.fromPost(content as IPlatformPost); + else -> throw NotImplementedError("Content type ${content.contentType} not implemented"); + }; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformNestedContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformNestedContent.kt new file mode 100644 index 00000000..6ab033a5 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformNestedContent.kt @@ -0,0 +1,66 @@ +package com.futo.platformplayer.api.media.models.video + +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.Serializer +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent +import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer +import com.futo.platformplayer.states.StatePlatform +import com.futo.polycentric.core.combineHashCodes +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.time.OffsetDateTime + +@kotlinx.serialization.Serializable +open class SerializedPlatformNestedContent( + override val id: PlatformID, + override val name: String, + override val author: PlatformAuthorLink, + @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) + override val datetime: OffsetDateTime?, + override val url: String, + override val shareUrl: String, + override val nestedContentType: ContentType, + override val contentUrl: String, + override val contentName: String?, + override val contentDescription: String?, + override val contentProvider: String?, + override val contentThumbnails: Thumbnails +) : IPlatformNestedContent, SerializedPlatformContent { + final override val contentType: ContentType get() = ContentType.MEDIA; + + override val contentPlugin: String? = StatePlatform.instance.getContentClientOrNull(contentUrl)?.id; + override val contentSupported: Boolean get() = contentPlugin != null; + + override fun toJson() : String { + return Json.encodeToString(this); + } + override fun fromJson(str : String) : SerializedPlatformNestedContent { + return Serializer.json.decodeFromString(str); + } + override fun fromJsonArray(str : String) : Array { + return Serializer.json.decodeFromString>(str); + } + + companion object { + fun fromNested(content: IPlatformNestedContent) : SerializedPlatformNestedContent { + return SerializedPlatformNestedContent( + content.id, + content.name, + content.author, + content.datetime, + content.url, + content.shareUrl, + content.nestedContentType, + content.contentUrl, + content.contentName, + content.contentDescription, + content.contentProvider, + content.contentThumbnails + ); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformPost.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformPost.kt new file mode 100644 index 00000000..8100fea7 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformPost.kt @@ -0,0 +1,56 @@ +package com.futo.platformplayer.api.media.models.video + +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.Serializer +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.post.IPlatformPost +import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer +import com.futo.polycentric.core.combineHashCodes +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.time.OffsetDateTime + +@kotlinx.serialization.Serializable +open class SerializedPlatformPost( + override val id: PlatformID, + override val name: String, + override val url: String, + override val shareUrl: String, + @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) + override val datetime: OffsetDateTime?, + override val author: PlatformAuthorLink, + override val description: String, + override val thumbnails: List, + override val images: List +) : IPlatformPost, SerializedPlatformContent { + final override val contentType: ContentType get() = ContentType.POST; + + override fun toJson() : String { + return Json.encodeToString(this); + } + override fun fromJson(str : String) : SerializedPlatformVideo { + return Serializer.json.decodeFromString(str); + } + override fun fromJsonArray(str : String) : Array { + return Serializer.json.decodeFromString>(str); + } + + companion object { + fun fromPost(post: IPlatformPost) : SerializedPlatformPost { + return SerializedPlatformPost( + post.id, + post.name, + post.url, + post.shareUrl, + post.datetime, + post.author, + post.description, + post.thumbnails, + post.images + ); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt new file mode 100644 index 00000000..12dd9e78 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt @@ -0,0 +1,58 @@ +package com.futo.platformplayer.api.media.models.video + +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.Serializer +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer +import com.futo.polycentric.core.combineHashCodes +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.time.OffsetDateTime + +@kotlinx.serialization.Serializable +open class SerializedPlatformVideo( + override val id: PlatformID, + override val name: String, + override val thumbnails: Thumbnails, + override val author: PlatformAuthorLink, + @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) + override val datetime: OffsetDateTime?, + override val url: String, + override val shareUrl: String, + + override val duration: Long, + override val viewCount: Long, +) : IPlatformVideo, SerializedPlatformContent { + final override val contentType: ContentType get() = ContentType.MEDIA; + + override val isLive: Boolean = false; + + override fun toJson() : String { + return Json.encodeToString(this); + } + override fun fromJson(str : String) : SerializedPlatformVideo { + return Serializer.json.decodeFromString(str); + } + override fun fromJsonArray(str : String) : Array { + return Serializer.json.decodeFromString>(str); + } + + companion object { + fun fromVideo(video: IPlatformVideo) : SerializedPlatformVideo { + return SerializedPlatformVideo( + video.id, + video.name, + video.thumbnails, + video.author, + video.datetime, + video.url, + video.shareUrl, + video.duration, + video.viewCount + ); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideoDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideoDetails.kt new file mode 100644 index 00000000..39851441 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideoDetails.kt @@ -0,0 +1,81 @@ +package com.futo.platformplayer.api.media.models.video + +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.Serializer +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.streams.sources.* +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.time.OffsetDateTime + +@kotlinx.serialization.Serializable +open class SerializedPlatformVideoDetails( + override val id: PlatformID, + override val name: String, + override val thumbnails: Thumbnails, + override val author: PlatformAuthorLink, + @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) + override val datetime: OffsetDateTime?, + override val url: String, + override val shareUrl: String, + + override val duration: Long, + override val viewCount: Long, + + override val rating: IRating, + override val description: String, + + override val video: ISerializedVideoSourceDescriptor, + override val preview: ISerializedVideoSourceDescriptor?, + + override val subtitles: List = listOf() +) : IPlatformVideo, IPlatformVideoDetails { + final override val contentType: ContentType get() = ContentType.MEDIA; + + override val isLive: Boolean get() = false; + + override val dash: IDashManifestSource? get() = null; + override val hls: IHLSManifestSource? get() = null; + override val live: IVideoSource? get() = null; + + fun toJson() : String { + return Json.encodeToString(this); + } + fun fromJson(str : String) : SerializedPlatformVideoDetails { + return Serializer.json.decodeFromString(str); + } + + override fun getComments(client: IPlatformClient): IPager? = null; + override fun getPlaybackTracker(): IPlaybackTracker? = null; + + companion object { + fun fromVideo(video : IPlatformVideoDetails, subtitleSources: List) : SerializedPlatformVideoDetails { + val descriptor = ISerializedVideoSourceDescriptor.fromDescriptor(video.video); + return SerializedPlatformVideoDetails( + video.id, + video.name, + video.thumbnails, + video.author, + video.datetime, + video.url, + video.shareUrl, + video.duration, + video.viewCount, + video.rating, + video.description, + descriptor, + video.preview?.let { descriptor }, + subtitleSources + ); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedVideoMuxedSourceDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedVideoMuxedSourceDescriptor.kt new file mode 100644 index 00000000..ad2d14ec --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedVideoMuxedSourceDescriptor.kt @@ -0,0 +1,13 @@ +package com.futo.platformplayer.api.media.models.video + +import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource + +@kotlinx.serialization.Serializable +class SerializedVideoMuxedSourceDescriptor( + val _videoSources: Array +): VideoMuxedSourceDescriptor(), ISerializedVideoSourceDescriptor { + @kotlinx.serialization.Transient + override val videoSources: Array get() = _videoSources.map { it }.toTypedArray(); +}; \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedVideoUnmuxedSourceDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedVideoUnmuxedSourceDescriptor.kt new file mode 100644 index 00000000..f7a84c05 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedVideoUnmuxedSourceDescriptor.kt @@ -0,0 +1,15 @@ +package com.futo.platformplayer.api.media.models.video + +import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.sources.* + +@kotlinx.serialization.Serializable +class SerializedVideoNonMuxedSourceDescriptor( + val _videoSources: Array, + val _audioSources: Array +): VideoUnMuxedSourceDescriptor(), ISerializedVideoSourceDescriptor { + @kotlinx.serialization.Transient + override val videoSources: Array get() = _videoSources.map { it }.toTypedArray(); + @kotlinx.serialization.Transient + override val audioSources: Array get() = _audioSources.map { it }.toTypedArray(); +}; \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/DevJSClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/DevJSClient.kt new file mode 100644 index 00000000..f1167586 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/DevJSClient.kt @@ -0,0 +1,189 @@ +package com.futo.platformplayer.api.media.platforms.js + +import android.content.Context +import com.futo.platformplayer.states.StateDeveloper +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.structures.IPager +import java.util.* + +class DevJSClient : JSClient { + override val id: String + get() = StateDeveloper.DEV_ID; + + private val _devScript: String; + private var _auth: SourceAuth? = null; + + val devID: String; + + constructor(context: Context, config: SourcePluginConfig, script: String, auth: SourceAuth? = null, devID: String? = null): super(context, SourcePluginDescriptor(config, auth?.toEncrypted(), listOf("DEV")), null, script) { + _devScript = script; + _auth = auth; + this.devID = devID ?: UUID.randomUUID().toString().substring(0, 5); + } + constructor(context: Context, descriptor: SourcePluginDescriptor, script: String, auth: SourceAuth? = null, savedState: String? = null, devID: String? = null): super(context, descriptor, savedState, script) { + _devScript = script; + _auth = auth; + this.devID = devID ?: UUID.randomUUID().toString().substring(0, 5); + } + + fun setAuth(auth: SourceAuth? = null) { + _auth = auth; + } + fun recreate(context: Context): DevJSClient { + return DevJSClient(context, config, _devScript, _auth, devID); + } + + override fun getCopy(): JSClient { + return DevJSClient(_context, descriptor, _script, _auth, saveState(), devID); + } + + override fun initialize() { + return StateDeveloper.instance.handleDevCall(devID, "enable"){ + super.initialize(); + }; + } + override fun disable() { + return StateDeveloper.instance.handleDevCall(devID, "disable"){ + super.disable() + }; + } + + //Home + override fun getHome(): IPager { + return StateDeveloper.instance.handleDevCall(devID, "getHome"){ + DevPlatformVideoPager(devID, "homePager", super.getHome()); + }; + } + + //Search + override fun searchSuggestions(query: String): Array { + return StateDeveloper.instance.handleDevCall(devID, "searchSuggestions"){ + super.searchSuggestions(query); + }; + } + override fun search( + query: String, + type: String?, + order: String?, + filters: Map>? + ): IPager { + return StateDeveloper.instance.handleDevCall(devID, "search"){ + DevPlatformVideoPager(devID, "searchPager", super.search(query, type, order, filters)); + }; + } + + //Channel + override fun isChannelUrl(url: String): Boolean { + return StateDeveloper.instance.handleDevCall(devID, "isChannelUrl"){ + super.isChannelUrl(url); + }; + } + override fun getChannel(channelUrl: String): IPlatformChannel { + return StateDeveloper.instance.handleDevCall(devID, "getChannel"){ + super.getChannel(channelUrl); + }; + } + override fun getChannelContents( + channelUrl: String, + type: String?, + order: String?, + filters: Map>? + ): IPager { + return StateDeveloper.instance.handleDevCall(devID, "getChannelVideos"){ + DevPlatformVideoPager(devID, "channelPager", super.getChannelContents(channelUrl, type, order, filters)); + }; + } + + //Video + override fun isContentDetailsUrl(url: String): Boolean { + return StateDeveloper.instance.handleDevCall(devID, "isVideoDetailsUrl"){ + super.isContentDetailsUrl(url); + }; + } + override fun getContentDetails(url: String): IPlatformContentDetails { + return StateDeveloper.instance.handleDevCall(devID, "getVideoDetails"){ + super.getContentDetails(url); + }; + } + + //Comments + override fun getComments(url: String): IPager { + return StateDeveloper.instance.handleDevCall(devID, "getComments"){ + DevPlatformCommentPager(devID, "commentPager", super.getComments(url)); + }; + } + override fun getSubComments(comment: IPlatformComment): IPager { + return StateDeveloper.instance.handleDevCall(devID, "getSubComments"){ + DevPlatformCommentPager(devID, "subCommentPager", super.getSubComments(comment)); + }; + } + + override fun searchPlaylists( + query: String, + type: String?, + order: String?, + filters: Map>? + ): IPager { + return StateDeveloper.instance.handleDevCall(devID, "searchPlaylists"){ + DevPlatformVideoPager(devID, "searchPlaylists", super.searchPlaylists(query, type, order, filters)); + }; + } + + class DevPlatformVideoPager( + private val _devId: String, + private val _contextName: String, + private val _pager: IPager + ) : IPager { + + private var _morePagesWasFalse = false; + + override fun hasMorePages(): Boolean { + if(_morePagesWasFalse) + return false; + return StateDeveloper.instance.handleDevCall(_devId, "${_contextName}.hasMorePages", true) { + val result = _pager.hasMorePages(); + if(!result) + _morePagesWasFalse = true; + return@handleDevCall result; + } + } + + override fun nextPage() { + return StateDeveloper.instance.handleDevCall(_devId, "${_contextName}.nextPage") { + _pager.nextPage(); + } + } + + override fun getResults(): List { + return StateDeveloper.instance.handleDevCall(_devId, "${_contextName}.getResults", true) { + _pager.getResults(); + } + } + } + class DevPlatformCommentPager( + private val _devId: String, + private val _contextName: String, + private val _pager: IPager) : IPager { + + override fun hasMorePages(): Boolean { + return StateDeveloper.instance.handleDevCall(_devId, "${_contextName}.hasMorePages") { + _pager.hasMorePages(); + } + } + + override fun nextPage() { + return StateDeveloper.instance.handleDevCall(_devId, "${_contextName}.nextPage") { + _pager.nextPage(); + } + } + + override fun getResults(): List { + return StateDeveloper.instance.handleDevCall(_devId, "${_contextName}.getResults") { + _pager.getResults(); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt new file mode 100644 index 00000000..926ff063 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt @@ -0,0 +1,593 @@ +package com.futo.platformplayer.api.media.platforms.js + +import android.content.Context +import com.caoccao.javet.values.V8Value +import com.caoccao.javet.values.primitive.V8ValueBoolean +import com.caoccao.javet.values.primitive.V8ValueInteger +import com.caoccao.javet.values.primitive.V8ValueString +import com.caoccao.javet.values.reference.V8ValueArray +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePlugins +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.PlatformClientCapabilities +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.ResultCapabilities +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor +import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent +import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails +import com.futo.platformplayer.api.media.platforms.js.internal.* +import com.futo.platformplayer.api.media.platforms.js.models.* +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.engine.V8Plugin +import com.futo.platformplayer.engine.exceptions.ScriptImplementationException +import com.futo.platformplayer.engine.exceptions.ScriptValidationException +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.ImageVariable +import com.futo.platformplayer.states.AnnouncementType +import com.futo.platformplayer.states.StateAnnouncement +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.time.OffsetDateTime +import kotlin.reflect.full.findAnnotations +import kotlin.reflect.jvm.kotlinFunction + +open class JSClient : IPlatformClient { + val config: SourcePluginConfig; + protected val _context: Context; + private val _plugin: V8Plugin; + private val plugin: V8Plugin get() = _plugin ?: throw IllegalStateException("Client not enabled"); + + var descriptor: SourcePluginDescriptor + private set; + + private val _client: JSHttpClient; + private val _clientAuth: JSHttpClient?; + private var _searchCapabilities: ResultCapabilities? = null; + private var _searchChannelContentsCapabilities: ResultCapabilities? = null; + private var _channelCapabilities: ResultCapabilities? = null; + + protected val _script: String; + + private var _initialized: Boolean = false; + private var _enabled: Boolean = false; + + private val _auth: SourceAuth?; + + private val _injectedSaveState: String?; + + override val id: String get() = config.id; + override val name: String get() = config.name; + override val icon: ImageVariable; + override var capabilities: PlatformClientCapabilities = PlatformClientCapabilities(); + + private val _busyLock = Object(); + private var _busyCounter = 0; + val isBusy: Boolean get() = _busyCounter > 0; + + val settings: HashMap get() = descriptor.settings; + + val flags: Array; + + var channelClaimTemplates: Map>? = null + private set; + + val isLoggedIn: Boolean get() = _auth != null; + val isEnabled: Boolean get() = _enabled; + + val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true + val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true + + val onDisabled = Event1(); + + constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String? = null) { + this._context = context; + this.config = descriptor.config; + icon = StatePlatform.instance.getPlatformIcon(config.id) ?: ImageVariable(config.absoluteIconUrl, null, null); + this.descriptor = descriptor; + _injectedSaveState = saveState; + _auth = descriptor.getAuth(); + flags = descriptor.flags.toTypedArray(); + + _client = JSHttpClient(this); + _clientAuth = JSHttpClient(this, _auth); + _plugin = V8Plugin(context, descriptor.config, null, _client, _clientAuth); + _plugin.withDependency(context, "scripts/polyfil.js"); + _plugin.withDependency(context, "scripts/source.js"); + + val script = StatePlugins.instance.getScript(descriptor.config.id); + if(script != null) { + _script = script; + _plugin.withScript(script); + } + else + throw IllegalStateException("Script for plugin [${descriptor.config.name}] was not available"); + } + constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String?, script: String) { + this._context = context; + this.config = descriptor.config; + icon = StatePlatform.instance.getPlatformIcon(config.id) ?: ImageVariable(config.absoluteIconUrl, null, null); + this.descriptor = descriptor; + _injectedSaveState = saveState; + _auth = descriptor.getAuth(); + flags = descriptor.flags.toTypedArray(); + + _client = JSHttpClient(this); + _clientAuth = JSHttpClient(this, _auth); + _plugin = V8Plugin(context, descriptor.config, script, _client, _clientAuth); + _plugin.withDependency(context, "scripts/polyfil.js"); + _plugin.withDependency(context, "scripts/source.js"); + _plugin.withScript(script); + _script = script; + } + + open fun getCopy(): JSClient { + return JSClient(_context, descriptor, saveState(), _script); + } + + fun getUnderlyingPlugin(): V8Plugin { + return _plugin; + } + + override fun initialize() { + Logger.i(TAG, "Plugin [${config.name}] initializing"); + plugin.start(); + plugin.execute("plugin.config = ${Json.encodeToString(config)}"); + plugin.execute("plugin.settings = parseSettings(${Json.encodeToString(descriptor.getSettingsWithDefaults())})"); + + descriptor.appSettings.loadDefaults(descriptor.config); + + _initialized = true; + + capabilities = PlatformClientCapabilities( + hasChannelSearch = plugin.executeBoolean("!!source.searchChannels") ?: false, + hasGetUserSubscriptions = plugin.executeBoolean("!!source.getUserSubscriptions") ?: false, + hasGetComments = plugin.executeBoolean("!!source.getComments") ?: false, + hasSearchPlaylists = (plugin.executeBoolean("!!source.searchPlaylists") ?: false), + hasGetPlaylist = (plugin.executeBoolean("!!source.getPlaylist") ?: false) && (plugin.executeBoolean("!!source.isPlaylistUrl") ?: false), + hasGetUserPlaylists = plugin.executeBoolean("!!source.getUserPlaylists") ?: false, + hasSearchChannelContents = plugin.executeBoolean("!!source.searchChannelContents") ?: false, + hasSaveState = plugin.executeBoolean("!!source.saveState") ?: false, + hasGetPlaybackTracker = plugin.executeBoolean("!!source.getPlaybackTracker") ?: false, + hasGetChannelUrlByClaim = plugin.executeBoolean("!!source.getChannelUrlByClaim") ?: false, + hasGetChannelTemplateByClaimMap = plugin.executeBoolean("!!source.getChannelTemplateByClaimMap") ?: false, + hasGetSearchCapabilities = plugin.executeBoolean("!!source.getSearchCapabilities") ?: false, + hasGetChannelCapabilities = plugin.executeBoolean("!!source.getChannelCapabilities") ?: false, + hasGetLiveEvents = plugin.executeBoolean("!!source.getLiveEvents") ?: false, + hasGetLiveChatWindow = plugin.executeBoolean("!!source.getLiveChatWindow") ?: false, + ); + + try { + if (capabilities.hasGetChannelTemplateByClaimMap) + getChannelTemplateByClaimMap(); + } + catch(ex: Throwable) { } + } + fun ensureEnabled() { + if(!_enabled) + enable(); + } + + @JSDocs(0, "source.enable()", "Called when the plugin is enabled/started") + fun enable() { + if(!_initialized) + initialize(); + plugin.execute("source.enable(${Json.encodeToString(config)}, parseSettings(${Json.encodeToString(descriptor.getSettingsWithDefaults())}), ${Json.encodeToString(_injectedSaveState)})"); + _enabled = true; + } + @JSDocs(1, "source.saveState()", "Provide a string that is passed to enable for quicker startup of multiple instances") + fun saveState(): String? { + ensureEnabled(); + if(!capabilities.hasSaveState) + return null; + val resp = plugin.executeTyped("source.saveState()").value; + return resp; + } + + @JSDocs(1, "source.disable()", "Called before the plugin is disabled/stopped") + override fun disable() { + Logger.i(TAG, "Disabling plugin [${name}] (Enabled: ${_enabled}, Initialized: ${_initialized})"); + if(_enabled) + ;//TODO: Disable? + _enabled = false; + if(_initialized) + _plugin.stop(); + _initialized = false; + + onDisabled.emit(this); + } + + @JSDocs(2, "source.getHome()", "Gets the HomeFeed of the platform") + override fun getHome(): IPager = isBusyWith { + ensureEnabled(); + return@isBusyWith JSContentPager(config, plugin, + plugin.executeTyped("source.getHome()")); + } + + @JSDocs(3, "source.searchSuggestions(query)", "Gets search suggestions for a given query") + @JSDocsParameter("query", "Query to complete suggestions for") + override fun searchSuggestions(query: String): Array = isBusyWith { + ensureEnabled(); + return@isBusyWith plugin.executeTyped("source.searchSuggestions(${Json.encodeToString(query)})") + .toArray() + .map { (it as V8ValueString).value } + .toTypedArray(); + } + @JSDocs(4, "source.getSearchCapabilities()", "Gets capabilities this plugin has for search contents") + override fun getSearchCapabilities(): ResultCapabilities { + if(!capabilities.hasGetSearchCapabilities) + return ResultCapabilities(listOf(ResultCapabilities.TYPE_MIXED)); + try { + if (_searchCapabilities != null) { + return _searchCapabilities!!; + } + + _searchCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getSearchCapabilities()")); + return _searchCapabilities!!; + } + catch(ex: Throwable) { + announcePluginUnhandledException("getSearchCapabilities", ex); + return ResultCapabilities(listOf(ResultCapabilities.TYPE_MIXED)); + } + } + @JSDocs(5, "source.search(query)", "Searches for contents on the platform") + @JSDocsParameter("query", "Query that search results should match") + @JSDocsParameter("type", "(optional) Type of contents to get from search ") + @JSDocsParameter("order", "(optional) Order in which contents should be returned") + @JSDocsParameter("filters", "(optional) Filters to apply on contents") + @JSDocsParameter("channelId", "(optional) Channel id to search in") + override fun search(query: String, type: String?, order: String?, filters: Map>?): IPager = isBusyWith { + ensureEnabled(); + return@isBusyWith JSContentPager(config, plugin, + plugin.executeTyped("source.search(${Json.encodeToString(query)}, ${Json.encodeToString(type)}, ${Json.encodeToString(order)}, ${Json.encodeToString(filters)})")); + } + + @JSDocs(4, "source.getSearchChannelContentsCapabilities()", "Gets capabilities this plugin has for search videos") + override fun getSearchChannelContentsCapabilities(): ResultCapabilities { + ensureEnabled(); + if (_searchChannelContentsCapabilities != null) + return _searchChannelContentsCapabilities!!; + + _searchChannelContentsCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getSearchChannelContentsCapabilities()")); + return _searchChannelContentsCapabilities!!; + } + @JSDocs(5, "source.searchChannelContents(query)", "Searches for videos on the platform") + @JSDocsParameter("channelUrl", "Channel url to search") + @JSDocsParameter("query", "Query that search results should match") + @JSDocsParameter("type", "(optional) Type of contents to get from search ") + @JSDocsParameter("order", "(optional) Order in which contents should be returned") + @JSDocsParameter("filters", "(optional) Filters to apply on contents") + override fun searchChannelContents(channelUrl: String, query: String, type: String?, order: String?, filters: Map>?): IPager = isBusyWith { + ensureEnabled(); + if(!capabilities.hasSearchChannelContents) + throw IllegalStateException("This plugin does not support channel search"); + + return@isBusyWith JSContentPager(config, plugin, + plugin.executeTyped("source.searchChannelContents(${Json.encodeToString(channelUrl)}, ${Json.encodeToString(query)}, ${Json.encodeToString(type)}, ${Json.encodeToString(order)}, ${Json.encodeToString(filters)})")); + } + + @JSOptional + @JSDocs(5, "source.searchChannels(query)", "Searches for channels on the platform") + @JSDocsParameter("query", "Query that channels should match") + override fun searchChannels(query: String): IPager = isBusyWith { + ensureEnabled(); + return@isBusyWith JSChannelPager(config, plugin, + plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})")); + } + + @JSDocs(6, "source.isChannelUrl(url)", "Validates if an channel url is for this platform") + @JSDocsParameter("url", "A channel url (May not be your platform)") + override fun isChannelUrl(url: String): Boolean { + try { + return plugin.executeTyped("source.isChannelUrl(${Json.encodeToString(url)})") + .value; + } + catch(ex: Throwable) { + announcePluginUnhandledException("isChannelUrl", ex); + return false; + } + } + @JSDocs(7, "source.getChannel(channelUrl)", "Gets a channel by its url") + @JSDocsParameter("channelUrl", "A channel url (this platform)") + override fun getChannel(channelUrl: String): IPlatformChannel = isBusyWith { + ensureEnabled(); + return@isBusyWith JSChannel(config, + plugin.executeTyped("source.getChannel(${Json.encodeToString(channelUrl)})")); + } + @JSDocs(8, "source.getChannelCapabilities()", "Gets capabilities this plugin has for channel contents") + override fun getChannelCapabilities(): ResultCapabilities { + if(!capabilities.hasGetChannelCapabilities) + return ResultCapabilities(listOf(ResultCapabilities.TYPE_MIXED)); + try { + if (_channelCapabilities != null) { + return _channelCapabilities!!; + } + + _channelCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getChannelCapabilities()")); + return _channelCapabilities!!; + } + catch(ex: Throwable) { + announcePluginUnhandledException("getChannelCapabilities", ex); + return ResultCapabilities(listOf(ResultCapabilities.TYPE_MIXED)); + } + } + @JSDocs(10, "source.getChannelContents(url, type, order, filters)", "Gets contents of a channel (reverse chronological order)") + @JSDocsParameter("channelUrl", "A channel url (this platform)") + @JSDocsParameter("type", "(optional) Type of contents to get from channel") + @JSDocsParameter("order", "(optional) Order in which contents should be returned") + @JSDocsParameter("filters", "(optional) Filters to apply on contents") + override fun getChannelContents(channelUrl: String, type: String?, order: String?, filters: Map>?): IPager = isBusyWith { + ensureEnabled(); + return@isBusyWith JSContentPager(config, plugin, + plugin.executeTyped("source.getChannelContents(${Json.encodeToString(channelUrl)}, ${Json.encodeToString(type)}, ${Json.encodeToString(order)}, ${Json.encodeToString(filters)})")); + } + + @JSOptional + @JSDocs(11, "source.getChannelUrlByClaim(claimType, claimValues)", "Gets the channel url that should be used to fetch a given polycentric claim") + @JSDocsParameter("claimType", "Polycentric claimtype id") + @JSDocsParameter("claimValues", "A map of values associated with the claim") + override fun getChannelUrlByClaim(claimType: Int, claimValues: Map): String? { + if(!capabilities.hasGetChannelUrlByClaim) + throw IllegalStateException("This plugin does not support channel url by claim"); + + val value = plugin.executeTyped("source.getChannelUrlByClaim(${claimType}, ${Json.encodeToString(claimValues)})"); + if(value !is V8ValueString) + return null; + return value.value; + } + @JSOptional + @JSDocs(12, "source.getChannelTemplateByClaimMap()", "Get a map for every supported claimtype mapping field to urls") + @JSDocsParameter("claimType", "Polycentric claimtype id") + @JSDocsParameter("claimValues", "A map of values associated with the claim") + fun getChannelTemplateByClaimMap(): Map>{ + if(!capabilities.hasGetChannelTemplateByClaimMap) + throw IllegalStateException("This plugin does not support channel template by claim map"); + + val value = plugin.executeTyped("source.getChannelTemplateByClaimMap()"); + if(value !is V8ValueObject) + return mapOf(); + + val claimTypes = mutableMapOf>(); + + val keys = value.ownPropertyNames; + for(key in keys.toArray()) { + if(key is V8ValueInteger) { + val map = value.get(key); + val mapKeys = map.ownPropertyNames; + + claimTypes[key.value] = mapKeys.toArray().filter { + it is V8ValueInteger + }.associate { + val mapKey = (it as V8ValueInteger).value; + return@associate Pair(mapKey, map.getString(mapKey)); + }; + } + } + channelClaimTemplates = claimTypes.toMap(); + return claimTypes; + } + + + @JSDocs(13, "source.isContentDetailsUrl(url)", "Validates if an content url is for this platform") + @JSDocsParameter("url", "A content url (May not be your platform)") + override fun isContentDetailsUrl(url: String): Boolean { + try { + return plugin.executeTyped("source.isContentDetailsUrl(${Json.encodeToString(url)})") + .value; + } + catch(ex: Throwable) { + announcePluginUnhandledException("isContentDetailsUrl", ex); + return false; + } + } + @JSDocs(14, "source.getContentDetails(url)", "Gets content details by its url") + @JSDocsParameter("url", "A content url (this platform)") + override fun getContentDetails(url: String): IPlatformContentDetails = isBusyWith { + ensureEnabled(); + return@isBusyWith IJSContentDetails.fromV8(config, + plugin.executeTyped("source.getContentDetails(${Json.encodeToString(url)})")); + } + + @JSOptional + @JSDocs(15, "source.getPlaybackTracker(url)", "Gets a playback tracker for given content url") + @JSDocsParameter("url", "A content url (this platform)") + override fun getPlaybackTracker(url: String): IPlaybackTracker? = isBusyWith { + if(!capabilities.hasGetPlaybackTracker) + return@isBusyWith null; + ensureEnabled(); + Logger.i(TAG, "JSClient.getPlaybackTracker(${url})"); + val tracker = plugin.executeTyped("source.getPlaybackTracker(${Json.encodeToString(url)})"); + if(tracker is V8ValueObject) + return@isBusyWith JSPlaybackTracker(config, tracker); + else + return@isBusyWith null; + } + + @JSDocs(16, "source.getComments(url)", "Gets comments for a content by its url") + @JSDocsParameter("url", "A content url (this platform)") + override fun getComments(url: String): IPager = isBusyWith { + ensureEnabled(); + return@isBusyWith JSCommentPager(config, plugin, + plugin.executeTyped("source.getComments(${Json.encodeToString(url)})")); + } + @JSDocs(17, "source.getSubComments(comment)", "Gets replies for a given comment") + @JSDocsParameter("comment", "Comment object that was returned by getComments") + override fun getSubComments(comment: IPlatformComment): IPager { + ensureEnabled(); + return comment.getReplies(this) ?: JSCommentPager(config, plugin, + plugin.executeTyped("source.getSubComments(${Json.encodeToString(comment as JSComment)})")); + } + + @JSDocs(16, "source.getLiveChatWindow(url)", "Gets live events for a livestream") + @JSDocsParameter("url", "Url of live stream") + override fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor? = isBusyWith { + if(!capabilities.hasGetLiveChatWindow) + return@isBusyWith null; + ensureEnabled(); + return@isBusyWith JSLiveChatWindowDescriptor(config, + plugin.executeTyped("source.getLiveChatWindow(${Json.encodeToString(url)})")); + } + @JSDocs(16, "source.getLiveEvents(url)", "Gets live events for a livestream") + @JSDocsParameter("url", "Url of live stream") + override fun getLiveEvents(url: String): IPager? = isBusyWith { + if(!capabilities.hasGetLiveEvents) + return@isBusyWith null; + ensureEnabled(); + return@isBusyWith JSLiveEventPager(config, plugin, + plugin.executeTyped("source.getLiveEvents(${Json.encodeToString(url)})")); + } + @JSDocs(19, "source.searchPlaylists(query)", "Searches for playlists on the platform") + @JSDocsParameter("query", "Query that search results should match") + @JSDocsParameter("type", "(optional) Type of contents to get from search ") + @JSDocsParameter("order", "(optional) Order in which contents should be returned") + @JSDocsParameter("filters", "(optional) Filters to apply on contents") + @JSDocsParameter("channelId", "(optional) Channel id to search in") + override fun searchPlaylists(query: String, type: String?, order: String?, filters: Map>?): IPager = isBusyWith { + ensureEnabled(); + if(!capabilities.hasSearchPlaylists) + throw IllegalStateException("This plugin does not support playlist search"); + return@isBusyWith JSContentPager(config, plugin, plugin.executeTyped("source.searchPlaylists(${Json.encodeToString(query)}, ${Json.encodeToString(type)}, ${Json.encodeToString(order)}, ${Json.encodeToString(filters)})")); + } + @JSOptional + @JSDocs(20, "source.isPlaylistUrl(url)", "Validates if a playlist url is for this platform") + @JSDocsParameter("url", "Url of playlist") + override fun isPlaylistUrl(url: String): Boolean { + ensureEnabled(); + if (!capabilities.hasGetPlaylist) + return false; + return plugin.executeBoolean("source.isPlaylistUrl(${Json.encodeToString(url)})") ?: false; + } + @JSOptional + @JSDocs(21, "source.getPlaylist(url)", "Gets the playlist of the current user") + @JSDocsParameter("url", "Url of playlist") + override fun getPlaylist(url: String): IPlatformPlaylistDetails = isBusyWith { + ensureEnabled(); + return@isBusyWith JSPlaylistDetails(plugin, plugin.config as SourcePluginConfig, plugin.executeTyped("source.getPlaylist(${Json.encodeToString(url)})")); + } + + @JSOptional + @JSDocs(22, "source.getUserPlaylists()", "Gets the playlist of the current user") + override fun getUserPlaylists(): Array { + ensureEnabled(); + return plugin.executeTyped("source.getUserPlaylists()") + .toArray() + .map { (it as V8ValueString).value } + .toTypedArray(); + } + + @JSOptional + @JSDocs(23, "source.getUserSubscriptions()", "Gets the subscriptions of the current user") + override fun getUserSubscriptions(): Array { + ensureEnabled(); + return plugin.executeTyped("source.getUserSubscriptions()") + .toArray() + .map { (it as V8ValueString).value } + .toTypedArray(); + } + + fun validate() { + try { + plugin.start(); + + validateFunction("source.getHome"); + //validateFunction("source.getSearchCapabilities"); + validateFunction("source.search"); + validateFunction("source.isChannelUrl"); + validateFunction("source.getChannel"); + //validateFunction("source.getChannelCapabilities"); + validateFunction("source.getChannelContents"); + validateFunction("source.isContentDetailsUrl"); + validateFunction("source.getContentDetails"); + } + finally { + plugin.stop() + } + } + private fun validateFunction(funcName: String) { + if(plugin.executeBoolean("typeof ${funcName} == 'function'") != true) + throw ScriptValidationException("Validation\n[function ${funcName} not available]"); + } + + + fun validateUrlOrThrow(url: String) { + val allowed = config.isUrlAllowed(url); + if(!allowed) + throw ScriptImplementationException(config, "Attempted to access non-whitelisted url: ${url}"); + } + + override fun isClaimTypeSupported(claimType: Int): Boolean { + return capabilities.hasGetChannelTemplateByClaimMap && config.supportedClaimTypes.contains(claimType) + } + fun isClaimTemplateMapSupported(claimType: Int, map: Map): Boolean { + return capabilities.hasGetChannelTemplateByClaimMap && channelClaimTemplates?.let { + it.containsKey(claimType) && it[claimType]!!.any { map.containsKey(it.key) }; + } ?: false; + } + + fun resolveChannelUrlByClaimTemplates(claimType: Int, values: Map): String? { + return channelClaimTemplates?.let { + if(it.containsKey(claimType)) { + val templates = it[claimType]; + if(templates != null) + for(value in values.keys.sortedBy { it }) { + if(templates.containsKey(value)) { + return templates[value]!!.replace("{{CLAIMVALUE}}", values[value]!!); + } + } + } + return null; + }; + } + + + private fun isBusyWith(handle: ()->T): T { + try { + synchronized(_busyLock) { + _busyCounter++; + } + return handle(); + } + finally { + synchronized(_busyLock) { + _busyCounter--; + } + } + } + + private fun announcePluginUnhandledException(method: String, ex: Throwable) { + try { + StateAnnouncement.instance.registerAnnouncement("PluginUnhandled_${config.id}_${method}", + "Plugin ${config.name} encountered an error in [${method}]", + "${ex.message}\nPlease contact the plugin developer", + AnnouncementType.RECURRING, + OffsetDateTime.now()); + } + catch(_: Throwable) {} + } + + companion object { + val TAG = "JSClient"; + + fun getJSDocs(): List { + val docs = mutableListOf(); + val methods = JSClient::class.java.declaredMethods.filter { it.getAnnotation(JSDocs::class.java) != null } + for(method in methods.sortedBy { it.getAnnotation(JSDocs::class.java)?.order }) { + val doc = method.getAnnotation(JSDocs::class.java); + val parameters = method.kotlinFunction!!.findAnnotations(); + val isOptional = method.kotlinFunction!!.findAnnotations().isNotEmpty(); + + docs.add(JSCallDocs(method.name, doc.code, doc.description, parameters + .sortedBy { it.order } + .map{ JSParameterDocs(it.name, it.description) } + .toList(), isOptional)); + } + return docs; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourceAuth.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourceAuth.kt new file mode 100644 index 00000000..0bf0d96d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourceAuth.kt @@ -0,0 +1,50 @@ +package com.futo.platformplayer.api.media.platforms.js + +import com.futo.platformplayer.encryption.EncryptionProvider +import com.futo.platformplayer.logging.Logger +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + + +data class SourceAuth(val cookieMap: HashMap>? = null, val headers: Map> = mapOf()) { + override fun toString(): String { + return "(headers: '$headers', cookieString: '$cookieMap')"; + } + + fun toEncrypted(): String{ + return EncryptionProvider.instance.encrypt(serialize()); + } + + private fun serialize(): String { + return Json.encodeToString(SerializedAuth(cookieMap, headers)); + } + + companion object { + val TAG = "SourceAuth"; + + fun fromEncrypted(encrypted: String?): SourceAuth? { + if(encrypted == null) + return null; + + val decrypted = EncryptionProvider.instance.decrypt(encrypted); + try { + return deserialize(decrypted); + } + catch(ex: Throwable) { + Logger.e(TAG, "Failed to deserialize authentication", ex); + return null; + } + } + + fun deserialize(str: String): SourceAuth { + val data = Json.decodeFromString(str); + return SourceAuth(data.cookieMap, data.headers); + } + } + + @Serializable + data class SerializedAuth(val cookieMap: HashMap>?, + val headers: Map> = mapOf()) +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginAuthConfig.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginAuthConfig.kt new file mode 100644 index 00000000..69abaa0b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginAuthConfig.kt @@ -0,0 +1,14 @@ +package com.futo.platformplayer.api.media.platforms.js + +@kotlinx.serialization.Serializable +class SourcePluginAuthConfig( + val loginUrl: String, + val completionUrl: String? = null, + val allowedDomains: List? = null, + val headersToFind: List? = null, + val cookiesToFind: List? = null, + val cookiesExclOthers: Boolean = true, + val userAgent: String? = null, + val loginButton: String? = null, + val domainHeadersToFind: Map>? = null, +) { } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt new file mode 100644 index 00000000..1d9bd749 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt @@ -0,0 +1,138 @@ +package com.futo.platformplayer.api.media.platforms.js + +import android.net.Uri +import com.futo.platformplayer.SignatureProvider +import com.futo.platformplayer.api.media.Serializer +import com.futo.platformplayer.engine.IV8PluginConfig +import kotlinx.serialization.decodeFromString +import java.net.URL +import java.util.* + +@kotlinx.serialization.Serializable +class SourcePluginConfig( + override val name: String, + val description: String = "", + + //Author + val author: String = "", + val authorUrl: String = "", + + //Script + val repositoryUrl: String? = null, + val scriptUrl: String = "", + val version: Int = -1, + + val iconUrl: String? = null, + var id: String = UUID.randomUUID().toString(), + + val scriptSignature: String? = null, + val scriptPublicKey: String? = null, + + override val allowEval: Boolean = false, + override val allowUrls: List = listOf(), + override val packages: List = listOf(), + + val settings: List = listOf(), + + val authentication: SourcePluginAuthConfig? = null, + var sourceUrl: String? = null, + val constants: HashMap = hashMapOf(), + + //TODO: These should be vals...but prob for serialization reasons cannot be changed. + var enableInSearch: Boolean = true, + var enableInHome: Boolean = true, + var supportedClaimTypes: List = listOf() +) : IV8PluginConfig { + + val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl); + val absoluteScriptUrl: String get() = resolveAbsoluteUrl(scriptUrl, sourceUrl)!!; + + private fun resolveAbsoluteUrl(url: String?, sourceUrl: String?): String? { + if(url == null) + return null; + val uri = Uri.parse(url); + if(uri.isAbsolute) + return url; + else if(sourceUrl.isNullOrEmpty()) + return url; + // throw IllegalStateException("Attempted to get absolute script url from relative, without base url"); + else { + val sourceUrlParsed = URL(sourceUrl); + return URL(sourceUrlParsed, uri.path).toString(); + } + } + + private var _allowAnywhereVal: Boolean? = null; + private val _allowAnywhere: Boolean get() { + if(_allowAnywhereVal == null) + _allowAnywhereVal = allowUrls.any { it.lowercase() == "everywhere" }; + return _allowAnywhereVal!!; + }; + private var _allowUrlsLowerVal: List? = null; + private val _allowUrlsLower: List get() { + if(_allowUrlsLowerVal == null) + _allowUrlsLowerVal = allowUrls.map { it.lowercase() }; + return _allowUrlsLowerVal!!; + }; + + fun getWarnings(scriptToCheck: String? = null) : List> { + val list = mutableListOf>(); + + if(scriptPublicKey.isNullOrEmpty() || scriptSignature.isNullOrEmpty()) + list.add(Pair( + "Missing Signature", + "This plugin does not have a signature. This makes updating the plugin less safe as it makes it easier for a malicious actor besides the developer to update a malicious version.")); + else if(scriptToCheck != null && !this.validate(scriptToCheck)) + list.add(Pair( + "Invalid Signature", + "This plugin does not have a signature. This makes updating the plugin less safe as it makes it easier for a malicious actor besides the developer to update a malicious version.")); + if(allowEval) + list.add(Pair( + "Eval Access", + "Eval allows injection of unsure code, and should be avoided when possible.")); + if(allowUrls.any { it == "everywhere" }) + list.add(Pair( + "Unrestricted Web Access", + "This plugin requires access to all URLs, this may include malicious URLs.")); + + return list; + } + + fun validate(text: String): Boolean { + if(scriptPublicKey.isNullOrEmpty()) + throw IllegalStateException("No public key present"); + if(scriptSignature.isNullOrEmpty()) + throw IllegalStateException("No signature present"); + + return SignatureProvider.verify(text, scriptSignature, scriptPublicKey); + } + + fun isUrlAllowed(url: String): Boolean { + if(_allowAnywhere) + return true; + val uri = Uri.parse(url); + val host = uri.host?.lowercase() ?: ""; + return _allowUrlsLower.any { it == host }; + } + + companion object { + fun fromJson(json: String, sourceUrl: String? = null): SourcePluginConfig { + val obj = Serializer.json.decodeFromString(json); + if(obj.sourceUrl == null) + obj.sourceUrl = sourceUrl; + return obj; + } + } + + @kotlinx.serialization.Serializable + data class Setting( + val name: String, + val description: String, + val type: String, + val default: String? = null, + val variable: String? = null + ) { + @kotlinx.serialization.Transient + val variableOrName: String get() = variable ?: name; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt new file mode 100644 index 00000000..172ebb23 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt @@ -0,0 +1,81 @@ +package com.futo.platformplayer.api.media.platforms.js + +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.serializers.FlexibleBooleanSerializer +import com.futo.platformplayer.views.fields.FieldForm +import com.futo.platformplayer.views.fields.FormField +import kotlinx.serialization.Serializable + +@Serializable +class SourcePluginDescriptor { + val config: SourcePluginConfig; + var settings: HashMap = hashMapOf(); + + var appSettings: AppPluginSettings = AppPluginSettings(); + + var authEncrypted: String? + private set; + + val flags: List; + + @kotlinx.serialization.Transient + val onAuthChanged = Event0(); + + constructor(config :SourcePluginConfig, authEncrypted: String? = null) { + this.config = config; + this.authEncrypted = authEncrypted; + this.flags = listOf(); + } + constructor(config :SourcePluginConfig, authEncrypted: String? = null, flags: List) { + this.config = config; + this.authEncrypted = authEncrypted; + this.flags = flags; + } + + fun getSettingsWithDefaults(): HashMap { + val map = HashMap(settings); + for(field in config.settings) { + if(!map.containsKey(field.variableOrName) || map[field.variableOrName] == null) + map.put(field.variableOrName, field.default); + } + return map; + } + + + fun updateAuth(str: SourceAuth?) { + authEncrypted = str?.toEncrypted(); + onAuthChanged.emit(); + } + fun getAuth(): SourceAuth? { + return SourceAuth.fromEncrypted(authEncrypted); + } + + @Serializable + class AppPluginSettings { + + @FormField("Visibility", "group", "Enable where this plugin's content are visible.", 2) + var tabEnabled = TabEnabled(); + @Serializable + class TabEnabled { + @FormField("Home", FieldForm.TOGGLE, "Show content in home tab", 1) + var enableHome: Boolean? = null; + + + @FormField("Search", FieldForm.TOGGLE, "Show content in search results", 2) + var enableSearch: Boolean? = null; + } + + + + fun loadDefaults(config: SourcePluginConfig) { + if(tabEnabled.enableHome == null) + tabEnabled.enableHome = config.enableInHome ?: true; + if(tabEnabled.enableSearch == null) + tabEnabled.enableSearch = config.enableInSearch ?: true; + } + } + + companion object { + const val FLAG_EMBEDDED = "EMBEDDED"; + } +} diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSDocs.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSDocs.kt new file mode 100644 index 00000000..4c1dc22d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSDocs.kt @@ -0,0 +1,19 @@ +package com.futo.platformplayer.api.media.platforms.js.internal + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class JSDocs(val order: Int, val code: String, val description: String) + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class JSOptional() + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@Repeatable +annotation class JSDocsParameter(val name: String, val description: String, val order: Int = 0) + +@kotlinx.serialization.Serializable +data class JSCallDocs(val title: String, val code: String, val description: String, val parameters: List, val isOptional: Boolean = false); +@kotlinx.serialization.Serializable +data class JSParameterDocs(val name: String, val description: String); \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt new file mode 100644 index 00000000..6f1e3b1b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt @@ -0,0 +1,158 @@ +package com.futo.platformplayer.api.media.platforms.js.internal + +import android.net.Uri +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.api.media.platforms.js.SourceAuth +import com.futo.platformplayer.matchesDomain + +class JSHttpClient : ManagedHttpClient { + private val _jsClient: JSClient?; + private val _auth: SourceAuth?; + + var doUpdateCookies: Boolean = true; + var doApplyCookies: Boolean = true; + var doAllowNewCookies: Boolean = true; + val isLoggedIn: Boolean get() = _auth != null; + + private var _currentCookieMap: HashMap>?; + + constructor(jsClient: JSClient?, auth: SourceAuth? = null) : super() { + _jsClient = jsClient; + _auth = auth; + + if(!auth?.cookieMap.isNullOrEmpty()) { + _currentCookieMap = hashMapOf(); + for(domainCookies in auth!!.cookieMap!!) + _currentCookieMap!!.put(domainCookies.key, HashMap(domainCookies.value)); + } + else _currentCookieMap = null; + } + + override fun clone(): ManagedHttpClient { + val newClient = JSHttpClient(_jsClient, _auth); + newClient._currentCookieMap = if(_currentCookieMap != null) + HashMap(_currentCookieMap!!.toList().associate { Pair(it.first, HashMap(it.second)) }) + else + null; + return newClient; + } + + override fun beforeRequest(request: Request) { + val auth = _auth; + if (auth != null) { + val domain = Uri.parse(request.url).host!!.lowercase(); + + //TODO: Possibly add doApplyHeaders + for (header in auth.headers.filter { domain.matchesDomain(it.key) }.flatMap { it.value.entries }) + request.headers[header.key] = header.value; + + if(doApplyCookies) { + if (!_currentCookieMap.isNullOrEmpty()) { + val cookiesToApply = hashMapOf(); + synchronized(_currentCookieMap!!) { + for(cookie in _currentCookieMap!! + .filter { domain.matchesDomain(it.key) } + .flatMap { it.value.toList() }) + cookiesToApply[cookie.first] = cookie.second; + }; + + if(cookiesToApply.size > 0) { + val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; "); + request.headers["Cookie"] = cookieString; + } + //printTestCode(request.url, request.body, auth.headers, cookieString, request.headers.filter { !auth.headers.containsKey(it.key) }); + } + } + } + + _jsClient?.validateUrlOrThrow(request.url); + super.beforeRequest(request) + } + + override fun afterRequest(request: Request, resp: Response) { + super.afterRequest(request, resp) + + if(doUpdateCookies) { + val domain = Uri.parse(request.url).host!!.lowercase(); + val domainParts = domain!!.split("."); + val defaultCookieDomain = + "." + domainParts.drop(domainParts.size - 2).joinToString("."); + for (header in resp.headers) { + if (_currentCookieMap != null && header.key.lowercase() == "set-cookie") { + val newCookies = cookieStringToMap(header.value); + for (cookie in newCookies) { + val endIndex = cookie.value.indexOf(";"); + var cookieValue = cookie.value; + var domainToUse = domain; + + if (endIndex > 0) { + val cookieParts = cookie.value.split(";"); + if (cookieParts.size == 0) + continue; + cookieValue = cookieParts[0].trim(); + + val cookieVariables = cookieParts.drop(1).map { + val splitIndex = it.indexOf("="); + if (splitIndex < 0) + return@map Pair(it.trim().lowercase(), ""); + return@map Pair( + it.substring(0, splitIndex).lowercase().trim(), + it.substring(splitIndex + 1).trim() + ); + }.toMap(); + domainToUse = if (cookieVariables.containsKey("domain")) + cookieVariables["domain"]!!.lowercase(); + else defaultCookieDomain; + } + + val cookieMap = if (_currentCookieMap!!.containsKey(domainToUse)) + _currentCookieMap!![domainToUse]!!; + else { + val newMap = hashMapOf(); + _currentCookieMap!!.put(domainToUse, newMap) + newMap; + } + if(cookieMap.containsKey(cookie.key) || doAllowNewCookies) + cookieMap.put(cookie.key, cookieValue); + } + } + } + } + } + + + private fun cookieStringToMap(parts: List): Map { + val map = hashMapOf(); + for(cookie in parts) { + val cookieKey = cookie.substring(0, cookie.indexOf("=")); + val cookieVal = cookie.substring(cookie.indexOf("=") + 1); + map.put(cookieKey.trim(), cookieVal.trim()); + } + return map; + } + + //Prints out code for test reproduction.. + fun printTestCode(url: String, body: ByteArray?, headers: Map, cookieString: String, allHeaders: Map? = null) { + var code = "Code: \n"; + code += "\nurl = \"${url}\";"; + if(body != null) + code += "\nbody = \"${String(body).replace("\"", "\\\"")}\";"; + if(headers != null) + for(header in headers) { + code += "\nclient.Headers.Add(\"${header.key}\", \"${header.value}\");"; + } + if(cookieString != null) + code += "\nclient.Headers.Add(\"Cookie\", \"${cookieString}\");"; + + if(allHeaders != null) { + code += "\n//OTHER HEADERS:" + for (header in allHeaders) { + code += "\nclient.Headers.Add(\"${header.key}\", \"${header.value}\");"; + } + } + + Logger.i("Testing", code); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContent.kt new file mode 100644 index 00000000..2746b56c --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContent.kt @@ -0,0 +1,30 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.getOrThrow + +interface IJSContent: IPlatformContent { + + companion object { + fun fromV8(config: SourcePluginConfig, obj: V8ValueObject): IPlatformContent { + val type: Int = obj.getOrThrow(config, "contentType", "ContentItem"); + val pluginType: String? = obj.getOrDefault(config, "plugin_type", "ContentItem", null); + + //TODO: Temporary workaround for intercepting details in lists + if(pluginType != null && pluginType.endsWith("Details")) + return IJSContentDetails.fromV8(config, obj); + + return when(ContentType.fromInt(type)) { + ContentType.MEDIA -> JSVideo(config, obj); + ContentType.POST -> JSPost(config, obj); + ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj); + ContentType.PLAYLIST -> JSPlaylist(config, obj); + else -> throw NotImplementedError("Unknown content type ${type}"); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContentDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContentDetails.kt new file mode 100644 index 00000000..fad34868 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContentDetails.kt @@ -0,0 +1,22 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.getOrThrow + +interface IJSContentDetails: IPlatformContent { + + companion object { + fun fromV8(config: SourcePluginConfig, obj: V8ValueObject): IPlatformContentDetails { + val type: Int = obj.getOrThrow(config, "contentType", "ContentDetails"); + return when(ContentType.fromInt(type)) { + ContentType.MEDIA -> JSVideoDetails(config, obj); + ContentType.POST -> JSPostDetails(config, obj); + else -> throw NotImplementedError("Unknown content type ${type}"); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSChannel.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSChannel.kt new file mode 100644 index 00000000..01436008 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSChannel.kt @@ -0,0 +1,46 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.getOrDefaultList +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.getOrThrowNullable + +class JSChannel : IPlatformChannel { + private val _pluginConfig: SourcePluginConfig; + private val _channel : V8ValueObject; + + override val id: PlatformID; + override val name: String; + override val thumbnail: String?; + override val banner: String?; + override val subscribers: Long; + override val description: String?; + override val url: String; + override val links: Map; + override val urlAlternatives: List; + + constructor(config: SourcePluginConfig, obj: V8ValueObject) { + _pluginConfig = config; + _channel = obj; + val contextName = "PlatformChannel"; + id = PlatformID.fromV8(_pluginConfig, _channel.getOrThrow(config, "id", contextName)); + name = _channel.getOrThrow(config, "name", contextName); + thumbnail = _channel.getOrThrowNullable(config, "thumbnail", contextName); + banner = _channel.getOrThrowNullable(config, "banner", contextName); + subscribers = _channel.getOrThrow(config, "subscribers", contextName).toLong(); + description = _channel.getOrThrowNullable(config, "description", contextName); + url = _channel.getOrThrow(config, "url", contextName); + urlAlternatives = _channel.getOrDefaultList(config, "urlAlternatives", contextName, listOf()) ?: listOf(); + links = HashMap(); + } + + override fun getContents(client: IPlatformClient): IPager { + return client.getChannelContents(url); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSChannelPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSChannelPager.kt new file mode 100644 index 00000000..01253896 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSChannelPager.kt @@ -0,0 +1,16 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.engine.V8Plugin + +class JSChannelPager : JSPager, IPager { + + constructor(config: SourcePluginConfig, plugin: V8Plugin, pager: V8ValueObject) : super(config, plugin, pager) {} + + override fun convertResult(obj: V8ValueObject): PlatformAuthorLink { + return PlatformAuthorLink.fromV8(config, obj); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSComment.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSComment.kt new file mode 100644 index 00000000..04146582 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSComment.kt @@ -0,0 +1,65 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.engine.V8Plugin +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.getOrThrowNullable +import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset + +@kotlinx.serialization.Serializable +class JSComment : IPlatformComment { + @kotlinx.serialization.Transient + private var _hasGetReplies: Boolean = false; + + @kotlinx.serialization.Transient + private var _config: SourcePluginConfig? = null; + @kotlinx.serialization.Transient + private var _comment: V8ValueObject? = null; + @kotlinx.serialization.Transient + private var _plugin: V8Plugin? = null; + + override val contextUrl: String; + override val author: PlatformAuthorLink; + override val message: String; + override val rating: IRating; + @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) + override val date: OffsetDateTime?; + override val replyCount: Int?; + + val context: Map; + + + constructor(config: SourcePluginConfig, plugin: V8Plugin, obj: V8ValueObject) { + _config = config; + _comment = obj; + _plugin = plugin; + + val contextName = "Comment"; + contextUrl = _comment!!.getOrThrow(config, "contextUrl", contextName); + author = PlatformAuthorLink.fromV8(_config!!, _comment!!.getOrThrow(config, "author", contextName)); + message = _comment!!.getOrThrow(config, "message", contextName); + rating = IRating.fromV8(config, _comment!!.getOrThrow(config, "rating", contextName)); + date = _comment!!.getOrThrowNullable(config, "date", contextName)?.let { OffsetDateTime.of(LocalDateTime.ofEpochSecond(it.toLong(), 0, ZoneOffset.UTC), ZoneOffset.UTC) } + replyCount = _comment!!.getOrThrowNullable(config, "replyCount", contextName); + context = _comment!!.getOrDefault(config, "context", contextName, hashMapOf()) ?: hashMapOf(); + _hasGetReplies = _comment!!.has("getReplies"); + } + + override fun getReplies(client: IPlatformClient): IPager? { + if(!_hasGetReplies) + return null; + + val obj = _comment!!.invoke("getReplies", arrayOf()); + return JSCommentPager(_config!!, _plugin!!, obj); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSCommentPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSCommentPager.kt new file mode 100644 index 00000000..13d44fe9 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSCommentPager.kt @@ -0,0 +1,16 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.engine.V8Plugin + +class JSCommentPager : JSPager, IPager { + + constructor(config: SourcePluginConfig, plugin: V8Plugin, pager: V8ValueObject) : super(config, plugin, pager) { } + + override fun convertResult(obj: V8ValueObject): IPlatformComment { + return JSComment(config, plugin, obj); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt new file mode 100644 index 00000000..c79223ca --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt @@ -0,0 +1,54 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.IPluginSourced +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.getOrThrow +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset + +open class JSContent : IPlatformContent, IPluginSourced { + protected val _pluginConfig: SourcePluginConfig; + protected val _content : V8ValueObject; + + protected val _hasGetDetails: Boolean; + + override val contentType: ContentType get() = ContentType.UNKNOWN; + + override val id: PlatformID; + override val name: String; + override val author: PlatformAuthorLink; + override val datetime: OffsetDateTime?; + + override val url: String; + override val shareUrl: String; + + override val sourceConfig: SourcePluginConfig get() = _pluginConfig; + + constructor(config: SourcePluginConfig, obj: V8ValueObject) { + _pluginConfig = config; + _content = obj; + + val contextName = "PlatformContent"; + + id = PlatformID.fromV8(_pluginConfig, _content.getOrThrow(config, "id", contextName)); + name = _content.getOrThrow(config, "name", contextName); + author = PlatformAuthorLink.fromV8(_pluginConfig, _content.getOrThrow(config, "author", contextName)); + + val datetimeInt = _content.getOrThrow(config, "datetime", contextName).toLong(); + if(datetimeInt == 0.toLong()) + datetime = null; + else + datetime = OffsetDateTime.of(LocalDateTime.ofEpochSecond(datetimeInt, 0, ZoneOffset.UTC), ZoneOffset.UTC); + url = _content.getOrThrow(config, "url", contextName); + shareUrl = _content.getOrDefault(config, "shareUrl", contextName, null) ?: url; + + _hasGetDetails = _content.has("getDetails"); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContentPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContentPager.kt new file mode 100644 index 00000000..f7a8fbbc --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContentPager.kt @@ -0,0 +1,17 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.IPluginSourced +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.engine.V8Plugin + +class JSContentPager : JSPager, IPluginSourced { + override val sourceConfig: SourcePluginConfig get() = config; + + constructor(config: SourcePluginConfig, plugin: V8Plugin, pager: V8ValueObject) : super(config, plugin, pager) {} + + override fun convertResult(obj: V8ValueObject): IPlatformContent { + return IJSContent.fromV8(config, obj); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSLiveChatWindowDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSLiveChatWindowDescriptor.kt new file mode 100644 index 00000000..abb6e5e5 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSLiveChatWindowDescriptor.kt @@ -0,0 +1,25 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.getOrThrow +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset + +class JSLiveChatWindowDescriptor: ILiveChatWindowDescriptor { + override val url: String; + override val removeElements: List; + + + constructor(config: SourcePluginConfig, obj: V8ValueObject) { + val contextName = "LiveChatWindowDescriptor"; + + url = obj.getOrThrow(config, "url", contextName); + removeElements = obj.getOrDefault(config, "removeElements", contextName, listOf()) ?: listOf(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSLiveEventPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSLiveEventPager.kt new file mode 100644 index 00000000..1d8ead85 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSLiveEventPager.kt @@ -0,0 +1,25 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.structures.IPlatformLiveEventPager +import com.futo.platformplayer.engine.V8Plugin +import com.futo.platformplayer.getOrThrow + +class JSLiveEventPager : JSPager, IPlatformLiveEventPager { + override var nextRequest: Int; + + constructor(config: SourcePluginConfig, plugin: V8Plugin, pager: V8ValueObject) : super(config, plugin, pager) { + nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager"); + } + + override fun nextPage() { + super.nextPage(); + nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager"); + } + + override fun convertResult(obj: V8ValueObject): IPlatformLiveEvent { + return IPlatformLiveEvent.fromV8(config, obj, "LiveEventPager"); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSNestedMediaContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSNestedMediaContent.kt new file mode 100644 index 00000000..eff81f18 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSNestedMediaContent.kt @@ -0,0 +1,39 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.states.StatePlatform + +//TODO: Refactor into video-only +class JSNestedMediaContent: IPlatformNestedContent, JSContent { + + override val contentType: ContentType get() = ContentType.NESTED_VIDEO; + override val nestedContentType: ContentType get() = ContentType.MEDIA; + + override val contentUrl: String; + override val contentName: String?; + override val contentDescription: String?; + override val contentProvider: String?; + override val contentThumbnails: Thumbnails; + + override val contentPlugin: String?; + override val contentSupported: Boolean get() = contentPlugin != null && StatePlatform.instance.isClientEnabled(contentPlugin); + + constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) { + val contextName = "PlatformNestedContent"; + + this.contentUrl = obj.getOrThrow(config, "contentUrl", contextName); + this.contentName = obj.getOrDefault(config, "contentName", contextName, null); + this.contentDescription = obj.getOrDefault(config, "contentName", contextName, null); + this.contentProvider = obj.getOrDefault(config, "contentName", contextName, null); + this.contentThumbnails = obj.getOrDefault(config, "contentThumbnails", contextName, null)?.let { + return@let Thumbnails.fromV8(config, it); + } ?: Thumbnails(); + this.contentPlugin = StatePlatform.instance.getContentClientOrNull(this.contentUrl)?.id; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPager.kt new file mode 100644 index 00000000..31b0a9e5 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPager.kt @@ -0,0 +1,75 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueArray +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.engine.V8Plugin +import com.futo.platformplayer.getOrThrow + +abstract class JSPager : IPager { + protected val plugin: V8Plugin; + protected val config: SourcePluginConfig; + protected var pager: V8ValueObject; + + private var _lastResults: List? = null; + private var _resultChanged: Boolean = true; + private var _hasMorePages: Boolean = false; + //private var _morePagesWasFalse: Boolean = false; + + val isAvailable get() = plugin._runtime?.let { !it.isClosed && !it.isDead } ?: false; + + constructor(config: SourcePluginConfig, plugin: V8Plugin, pager: V8ValueObject) { + this.plugin = plugin; + this.pager = pager; + this.config = config; + + _hasMorePages = pager.getOrThrow(config, "hasMore", "Pager"); + getResults(); + } + + fun getPluginConfig(): SourcePluginConfig { + return config; + } + + override fun hasMorePages(): Boolean { + return _hasMorePages; + } + + override fun nextPage() { + pager = plugin.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage()") { + pager.invoke("nextPage", arrayOf()); + }; + _hasMorePages = pager.getOrThrow(config, "hasMore", "Pager"); + _resultChanged = true; + /* + try { + } + catch(ex: Throwable) { + Logger.e("JSPager", "[${plugin.config.name}] Failed to load next page", ex); + _lastResults = listOf(); + UIDialogs.toast("Failed to get more results for plugin [${plugin.config.name}]\n${ex.message}"); + }*/ + } + + override fun getResults(): List { + val previousResults = _lastResults?.let { + if(!_resultChanged) + return@let it; + else + null; + }; + if(previousResults != null) + return previousResults; + + val items = pager.getOrThrow(config, "results", "JSPager"); + val newResults = items.toArray() + .map { convertResult(it as V8ValueObject) } + .toList(); + _lastResults = newResults; + _resultChanged = false; + return newResults; + } + + abstract fun convertResult(obj: V8ValueObject): T; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaybackTracker.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaybackTracker.kt new file mode 100644 index 00000000..9c62e7db --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaybackTracker.kt @@ -0,0 +1,60 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.engine.exceptions.ScriptImplementationException +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.logging.Logger + +class JSPlaybackTracker: IPlaybackTracker { + private val _config: IV8PluginConfig; + private val _obj: V8ValueObject; + + private var _hasCalledInit: Boolean = false; + private val _hasInit: Boolean; + + private var _lastRequest: Long = Long.MIN_VALUE; + + override var nextRequest: Int = 1000 + private set; + + constructor(config: IV8PluginConfig, obj: V8ValueObject) { + if(!obj.has("onProgress")) + throw ScriptImplementationException(config, "Missing onProgress on PlaybackTracker"); + if(!obj.has("nextRequest")) + throw ScriptImplementationException(config, "Missing nextRequest on PlaybackTracker"); + + this._config = config; + this._obj = obj; + this._hasInit = obj.has("onInit"); + } + + override fun onInit(seconds: Double) { + synchronized(_obj) { + if(_hasCalledInit) + return; + if (_hasInit) { + Logger.i("JSPlaybackTracker", "onInit (${seconds})"); + _obj.invokeVoid("onInit", seconds); + } + nextRequest = Math.max(100, _obj.getOrThrow(_config, "nextRequest", "PlaybackTracker", false)); + _hasCalledInit = true; + } + } + + override fun onProgress(seconds: Double, isPlaying: Boolean) { + synchronized(_obj) { + if(!_hasCalledInit && _hasInit) + onInit(seconds); + else { + Logger.i("JSPlaybackTracker", "onProgress (${seconds}, ${isPlaying})"); + _obj.invokeVoid("onProgress", Math.floor(seconds), isPlaying); + nextRequest = Math.max(100, _obj.getOrThrow(_config, "nextRequest", "PlaybackTracker", false)); + _lastRequest = System.currentTimeMillis(); + } + } + } + + override fun shouldUpdate(): Boolean = (_lastRequest < 0 || (System.currentTimeMillis() - _lastRequest) > nextRequest); +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaylist.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaylist.kt new file mode 100644 index 00000000..b47ae9ea --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaylist.kt @@ -0,0 +1,19 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.getOrDefault + +open class JSPlaylist : JSContent, IPlatformPlaylist { + override val contentType: ContentType get() = ContentType.PLAYLIST; + override val thumbnail: String?; + override val videoCount: Int; + + constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) { + val contextName = "Playlist"; + thumbnail = obj.getOrDefault(config, "thumbnail", contextName, null); + videoCount = obj.getOrDefault(config, "videoCount", contextName, 0)!!; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaylistDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaylistDetails.kt new file mode 100644 index 00000000..97b3f29b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaylistDetails.kt @@ -0,0 +1,37 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.engine.V8Plugin +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.models.Playlist + +class JSPlaylistDetails: JSPlaylist, IPlatformPlaylistDetails { + override val contents: IPager; + + constructor(plugin: V8Plugin, config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) { + contents = JSVideoPager(config, plugin, obj.getOrThrow(config, "contents", "PlaylistDetails")); + } + + override fun toPlaylist(): Playlist { + val videos = contents.getResults().toMutableList(); + + //Download all pages + var allowedEmptyCount = 2; + while(contents.hasMorePages()) { + contents.nextPage(); + if(!videos.addAll(contents.getResults())) { + allowedEmptyCount--; + if(allowedEmptyCount <= 0) + break; + } + else allowedEmptyCount = 2; + } + + return Playlist(id.toString(), name, videos.map { SerializedPlatformVideo.fromVideo(it)}); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaylistPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaylistPager.kt new file mode 100644 index 00000000..a0df057b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaylistPager.kt @@ -0,0 +1,16 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.engine.V8Plugin + +class JSPlaylistPager : JSPager, IPager { + + constructor(config: SourcePluginConfig, plugin: V8Plugin, pager: V8ValueObject) : super(config, plugin, pager) {} + + override fun convertResult(obj: V8ValueObject): IPlatformPlaylist { + return JSPlaylist(config, obj); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPost.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPost.kt new file mode 100644 index 00000000..49838a36 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPost.kt @@ -0,0 +1,33 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.IPluginSourced +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.post.IPlatformPost +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.getOrThrowNullableList + +open class JSPost : JSContent, IPlatformPost, IPluginSourced { + final override val contentType: ContentType get() = ContentType.POST; + + final override val description: String; + final override val thumbnails: List; + final override val images: List; + + constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) { + val contextName = "PlatformPost"; + + description = _content.getOrThrow(config, "description", contextName); + thumbnails = _content.getOrThrowNullableList(config, "thumbnails", contextName) + ?.map { + if(it != null) + Thumbnails.fromV8(config, it); + else + null; + } ?: listOf(); + + images = _content.getOrThrowNullableList(config, "images", contextName) ?: listOf(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPostDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPostDetails.kt new file mode 100644 index 00000000..e8982889 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPostDetails.kt @@ -0,0 +1,59 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker +import com.futo.platformplayer.api.media.models.post.IPlatformPost +import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails +import com.futo.platformplayer.api.media.models.post.TextType +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.api.media.platforms.js.DevJSClient +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.states.StateDeveloper + +class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails { + private val _hasGetComments: Boolean; + + override val rating: IRating; + + override val textType: TextType; + override val content: String; + + + + constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) { + val contextName = "PlatformPostDetails"; + + rating = obj.getOrDefault(config, "rating", contextName, null)?.let { IRating.fromV8(config, it, contextName) } ?: RatingLikes(0); + textType = TextType.fromInt((obj.getOrDefault(config, "textType", contextName, null) ?: 0)); + content = obj.getOrDefault(config, "content", contextName, "") ?: ""; + + _hasGetComments = _content.has("getComments"); + } + + override fun getComments(client: IPlatformClient): IPager? { + if(!_hasGetComments || _content.isClosed) + return null; + + if(client is DevJSClient) + return StateDeveloper.instance.handleDevCall(client.devID, "videoDetail.getComments()") { + return@handleDevCall getCommentsJS(client); + } + else if(client is JSClient) + return getCommentsJS(client); + + return null; + } + override fun getPlaybackTracker(): IPlaybackTracker? = null; + + + private fun getCommentsJS(client: JSClient): JSCommentPager { + val commentPager = _content.invoke("getComments", arrayOf()); + return JSCommentPager(_pluginConfig, client.getUnderlyingPlugin(), commentPager); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequest.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequest.kt new file mode 100644 index 00000000..960982bf --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequest.kt @@ -0,0 +1,18 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.logging.Logger + +@kotlinx.serialization.Serializable +class JSRequest : JSRequestModifier.IRequest { + override val url: String; + override val headers: Map; + + constructor(config: IV8PluginConfig, obj: V8ValueObject) { + val contextName = "ModifyRequestResponse"; + url = obj.getOrThrow(config, "url", contextName); + headers = obj.getOrThrow(config, "headers", contextName); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestModifier.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestModifier.kt new file mode 100644 index 00000000..0d71057a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestModifier.kt @@ -0,0 +1,42 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.engine.V8Plugin +import com.futo.platformplayer.engine.exceptions.ScriptImplementationException +import com.futo.platformplayer.getOrNull + +class JSRequestModifier { + private val _config: IV8PluginConfig; + private var _modifier: V8ValueObject; + val allowByteSkip: Boolean; + + constructor(config: IV8PluginConfig, modifier: V8ValueObject) { + this._modifier = modifier; + this._config = config; + + allowByteSkip = modifier.getOrNull(config, "allowByteSkip", "JSRequestModifier") ?: true; + + if(!modifier.has("modifyRequest")) + throw ScriptImplementationException(config, "RequestModifier is missing modifyRequest", null); + } + + fun modifyRequest(url: String, headers: Map): IRequest { + if (_modifier.isClosed) { + return Request(url, headers); + } + + val result = V8Plugin.catchScriptErrors(_config, "[${_config.name}] JSRequestModifier", "builder.modifyRequest()") { + _modifier.invoke("modifyRequest", url, headers); + }; + + return JSRequest(_config, result as V8ValueObject); + } + + interface IRequest { + val url: String; + val headers: Map; + } + + data class Request(override val url: String, override val headers: Map) : IRequest; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSSubtitleSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSSubtitleSource.kt new file mode 100644 index 00000000..bb4650f6 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSSubtitleSource.kt @@ -0,0 +1,64 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import android.net.Uri +import com.caoccao.javet.values.primitive.V8ValueString +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.states.StateApp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +class JSSubtitleSource : ISubtitleSource { + private val _obj: V8ValueObject; + + private val _lockSub = Object(); + private var _fileSubtitle: File? = null; + + override val name: String; + override val url: String?; + override val format: String?; + override val hasFetch: Boolean; + + constructor(config: SourcePluginConfig, v8Value: V8ValueObject) { + _obj = v8Value; + + val context = "JSSubtitles"; + name = v8Value.getOrThrow(config, "name", context, false); + url = v8Value.getOrThrow(config, "url", context, true); + format = v8Value.getOrThrow(config, "format", context, true); + hasFetch = v8Value.has("getSubtitles"); + } + + override fun getSubtitles(): String { + if(!hasFetch) + throw IllegalStateException("This subtitle doesn't support getSubtitles.."); + val v8String = _obj.invoke("getSubtitles", arrayOf()); + return v8String.value; + } + + override suspend fun getSubtitlesURI(): Uri? { + if(_fileSubtitle != null) + return Uri.fromFile(_fileSubtitle); + if(!hasFetch) + return Uri.parse(url); + + return withContext(Dispatchers.IO) { + return@withContext synchronized(_lockSub) { + val subtitleText = getSubtitles(); + val subFile = StateApp.instance.getTempFile(); + subFile.writeText(subtitleText, Charsets.UTF_8); + _fileSubtitle = subFile; + return@synchronized Uri.fromFile(subFile); + }; + } + } + + companion object { + fun fromV8(config: SourcePluginConfig, value: V8ValueObject): JSSubtitleSource { + return JSSubtitleSource(config, value); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideo.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideo.kt new file mode 100644 index 00000000..ef0f2fee --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideo.kt @@ -0,0 +1,30 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.IPluginSourced +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.getOrThrow + +open class JSVideo : JSContent, IPlatformVideo, IPluginSourced { + final override val contentType: ContentType get() = ContentType.MEDIA; + + final override val thumbnails: Thumbnails; + + final override val duration: Long; + final override val viewCount: Long; + + final override val isLive: Boolean; + + constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) { + val contextName = "PlatformVideo"; + + thumbnails = Thumbnails.fromV8(config, _content.getOrThrow(config, "thumbnails", contextName)); + + duration = _content.getOrThrow(config, "duration", contextName).toLong(); + viewCount = _content.getOrThrow(config, "viewCount", contextName); + isLive = _content.getOrThrow(config, "isLive", contextName); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideoDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideoDetails.kt new file mode 100644 index 00000000..6fbd07ed --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideoDetails.kt @@ -0,0 +1,106 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.V8Value +import com.caoccao.javet.values.reference.V8ValueArray +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.sources.* +import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.api.media.platforms.js.DevJSClient +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoSourceDescriptor +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.engine.V8Plugin +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.getOrThrowNullable +import com.futo.platformplayer.states.StateDeveloper + +class JSVideoDetails : JSVideo, IPlatformVideoDetails { + private val _hasGetComments: Boolean; + private val _hasGetPlaybackTracker: Boolean; + + //Details + override val description : String; + override val rating : IRating; + + override val video: IVideoSourceDescriptor; + override val preview : IVideoSourceDescriptor? = null; + + override val dash: IDashManifestSource?; + override val hls: IHLSManifestSource?; + + override val live: IVideoSource?; + + override val subtitles: List; + + + constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) { + val contextName = "VideoDetails"; + description = _content.getOrThrow(config, "description", contextName); + video = JSVideoSourceDescriptor.fromV8(config, _content.getOrThrow(config, "video", contextName)); + dash = JSSource.fromV8DashNullable(config, _content.getOrThrowNullable(config, "dash", contextName)); + hls = JSSource.fromV8HLSNullable(config, _content.getOrThrowNullable(config, "hls", contextName)); + live = JSSource.fromV8VideoNullable(config, _content.getOrThrowNullable(config, "live", contextName)); + rating = IRating.fromV8OrDefault(config, _content.getOrDefault(config, "rating", contextName, null), RatingLikes(0)); + + if(!_content.has("subtitles")) + subtitles = listOf(); + else { + val subArrs = _content.getOrThrowNullable(config, "subtitles", contextName); + if(subArrs != null) + subtitles = subArrs.keys.map { JSSubtitleSource.fromV8(config, subArrs.get(it)) }; + else + subtitles = listOf(); + } + + _hasGetComments = _content.has("getComments"); + _hasGetPlaybackTracker = _content.has("getPlaybackTracker"); + } + + override fun getPlaybackTracker(): IPlaybackTracker? { + if(!_hasGetPlaybackTracker || _content.isClosed) + return null; + if(_pluginConfig.id == StateDeveloper.DEV_ID) + return StateDeveloper.instance.handleDevCall(_pluginConfig.id, "videoDetail.getComments()") { + return@handleDevCall getPlaybackTrackerJS(); + } + else + return getPlaybackTrackerJS(); + } + private fun getPlaybackTrackerJS(): IPlaybackTracker? { + return V8Plugin.catchScriptErrors(_pluginConfig, "VideoDetails", "videoDetails.getPlaybackTracker()") { + val tracker = _content.invoke("getPlaybackTracker", arrayOf()) + ?: return@catchScriptErrors null; + if(tracker is V8ValueObject) + return@catchScriptErrors JSPlaybackTracker(_pluginConfig, tracker); + else + return@catchScriptErrors null; + }; + } + + override fun getComments(client: IPlatformClient): IPager? { + if(client !is JSClient || !_hasGetComments || _content.isClosed) + return null; + + if(client is DevJSClient) + return StateDeveloper.instance.handleDevCall(client.devID, "videoDetail.getComments()") { + return@handleDevCall getCommentsJS(client); + } + else + return getCommentsJS(client); + } + + private fun getCommentsJS(client: JSClient): JSCommentPager { + val commentPager = _content.invoke("getComments", arrayOf()); + return JSCommentPager(_pluginConfig, client.getUnderlyingPlugin(), commentPager); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideoPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideoPager.kt new file mode 100644 index 00000000..1cc24a2d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideoPager.kt @@ -0,0 +1,15 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.engine.V8Plugin + +class JSVideoPager : JSPager { + + constructor(config: SourcePluginConfig, plugin: V8Plugin, pager: V8ValueObject) : super(config, plugin, pager) {} + + override fun convertResult(obj: V8ValueObject): IPlatformVideo { + return JSVideo(config, obj); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSAudioUrlSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSAudioUrlSource.kt new file mode 100644 index 00000000..39d80032 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSAudioUrlSource.kt @@ -0,0 +1,44 @@ +package com.futo.platformplayer.api.media.platforms.js.models.sources + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.getOrThrow + +open class JSAudioUrlSource : IAudioUrlSource, JSSource { + override val name: String; + override val bitrate : Int; + override val container : String; + override val codec: String; + private val url : String; + + override val language: String; + + override val duration: Long?; + + override var priority: Boolean = false; + + constructor(config: IV8PluginConfig, obj: V8ValueObject) : super(TYPE_AUDIOURL, config, obj) { + val contextName = "AudioUrlSource"; + + bitrate = _obj.getOrThrow(config, "bitrate", contextName); + container = _obj.getOrThrow(config, "container", contextName); + codec = _obj.getOrThrow(config, "codec", contextName); + url = _obj.getOrThrow(config, "url", contextName); + language = _obj.getOrThrow(config, "language", contextName); + duration = _obj.getOrDefault(config, "duration", contextName, null); + + name = _obj.getOrDefault(config, "name", contextName, "${container} ${bitrate}") ?: "${container} ${bitrate}"; + + priority = if(_obj.has("priority")) obj.getOrThrow(config, "priority", contextName) else false; + } + + override fun getAudioUrl() : String { + return url; + } + + override fun toString(): String { + return "(name=$name, container=$container, bitrate=$bitrate, codec=$codec, url=$url, language=$language, duration=$duration)"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSAudioWithMetadataSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSAudioWithMetadataSource.kt new file mode 100644 index 00000000..9eafafee --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSAudioWithMetadataSource.kt @@ -0,0 +1,35 @@ +package com.futo.platformplayer.api.media.platforms.js.models.sources + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.streams.sources.other.IStreamMetaDataSource +import com.futo.platformplayer.api.media.models.streams.sources.other.StreamMetaData +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrDefault + +class JSAudioUrlRangeSource : JSAudioUrlSource, IStreamMetaDataSource { + val hasItag: Boolean get() = itagId != null && initStart != null && initEnd != null && indexStart != null && indexEnd != null; + val itagId: Int?; + val initStart: Int?; + val initEnd: Int?; + + val indexStart: Int?; + val indexEnd: Int?; + val audioChannels: Int; + + override val streamMetaData get() = if(initStart != null + && initEnd != null + && indexStart != null + && indexEnd != null) + StreamMetaData(initStart, initEnd, indexStart, indexEnd) else null; + + constructor(config: IV8PluginConfig, obj: V8ValueObject) : super(config, obj) { + val contextName = "JSAudioUrlRangeSource"; + + itagId = _obj.getOrDefault(config, "itagId", contextName, null); + initStart = _obj.getOrDefault(config, "initStart", contextName, null); + initEnd = _obj.getOrDefault(config, "initEnd", contextName, null); + indexStart = _obj.getOrDefault(config, "indexStart", contextName, null); + indexEnd = _obj.getOrDefault(config, "indexEnd", contextName, null); + audioChannels = _obj.getOrDefault(config, "audioChannels", contextName, 2) ?: 2; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestSource.kt new file mode 100644 index 00000000..4f50cbf6 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestSource.kt @@ -0,0 +1,35 @@ +package com.futo.platformplayer.api.media.platforms.js.models.sources + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrNull +import com.futo.platformplayer.getOrThrow + +class JSDashManifestSource : IVideoUrlSource, IDashManifestSource, JSSource { + override val width : Int = 0; + override val height : Int = 0; + override val container : String = "application/dash+xml"; + override val codec : String = "Dash"; + override val name : String; + override val bitrate: Int? = null; + override val url : String; + override val duration: Long; + + override var priority: Boolean = false; + + constructor(config: IV8PluginConfig, obj: V8ValueObject) : super(TYPE_DASH, config, obj) { + val contextName = "DashSource"; + + name = _obj.getOrThrow(config, "name", contextName); + url = _obj.getOrThrow(config, "url", contextName); + duration = _obj.getOrThrow(config, "duration", contextName); + + priority = obj.getOrNull(config, "priority", contextName) ?: false; + } + + override fun getVideoUrl(): String { + return url; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt new file mode 100644 index 00000000..c006f2ec --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt @@ -0,0 +1,42 @@ +package com.futo.platformplayer.api.media.platforms.js.models.sources + +import com.caoccao.javet.values.V8Value +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource +import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrNull +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.orNull + +class JSHLSManifestAudioSource : IAudioUrlSource, IHLSManifestAudioSource, JSSource { + override val container : String get() = "application/vnd.apple.mpegurl"; + override val codec: String = "HLS"; + override val name : String; + override val bitrate : Int = 0; + override val url : String; + override val duration: Long; + override val language: String; + + override var priority: Boolean = false; + + constructor(config: IV8PluginConfig, obj: V8ValueObject) : super(TYPE_HLS, config, obj) { + val contextName = "HLSAudioSource"; + + name = _obj.getOrThrow(config, "name", contextName); + url = _obj.getOrThrow(config, "url", contextName); + duration = _obj.getOrThrow(config, "duration", contextName).toLong(); + language = _obj.getOrThrow(config, "language", contextName); + + priority = obj.getOrNull(config, "priority", contextName) ?: false; + } + + override fun getAudioUrl(): String { + return url; + } + + companion object { + fun fromV8HLSNullable(config: IV8PluginConfig, obj: V8Value?) : JSHLSManifestAudioSource? = obj.orNull { fromV8HLS(config, it as V8ValueObject) }; + fun fromV8HLS(config: IV8PluginConfig, obj: V8ValueObject) : JSHLSManifestAudioSource = JSHLSManifestAudioSource(config, obj); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestSource.kt new file mode 100644 index 00000000..27ba3352 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestSource.kt @@ -0,0 +1,35 @@ +package com.futo.platformplayer.api.media.platforms.js.models.sources + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrNull +import com.futo.platformplayer.getOrThrow + +class JSHLSManifestSource : IVideoUrlSource, IHLSManifestSource, JSSource { + override val width : Int = 0; + override val height : Int = 0; + override val container : String get() = "application/vnd.apple.mpegurl"; + override val codec: String = "HLS"; + override val name : String; + override val bitrate : Int? = null; + override val url : String; + override val duration: Long; + + override var priority: Boolean = false; + + constructor(config: IV8PluginConfig, obj: V8ValueObject) : super(TYPE_HLS, config, obj) { + val contextName = "HLSSource"; + + name = _obj.getOrThrow(config, "name", contextName); + url = _obj.getOrThrow(config, "url", contextName); + duration = _obj.getOrThrow(config, "duration", contextName).toLong(); + + priority = obj.getOrNull(config, "priority", contextName) ?: false; + } + + override fun getVideoUrl(): String { + return url; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt new file mode 100644 index 00000000..c4cae894 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt @@ -0,0 +1,89 @@ +package com.futo.platformplayer.api.media.platforms.js.models.sources + +import com.caoccao.javet.values.V8Value +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.platforms.js.models.JSRequestModifier +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.engine.V8Plugin +import com.futo.platformplayer.orNull +import com.futo.platformplayer.views.video.datasources.JSHttpDataSource +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource +import com.google.android.exoplayer2.upstream.HttpDataSource + +abstract class JSSource { + protected val _config: IV8PluginConfig; + protected val _obj: V8ValueObject; + private val _hasRequestModifier: Boolean; + + val type : String; + + constructor(type: String, config: IV8PluginConfig, obj: V8ValueObject) { + this._config = config; + this._obj = obj; + this.type = type; + + _hasRequestModifier = obj.has("getRequestModifier"); + } + + fun getRequestModifier(): JSRequestModifier? { + if (!_hasRequestModifier || _obj.isClosed) { + return null; + } + + val result = V8Plugin.catchScriptErrors(_config, "[${_config.name}] JSVideoUrlSource", "obj.getRequestModifier()") { + _obj.invoke("getRequestModifier", arrayOf()); + }; + + if (result !is V8ValueObject) { + return null; + } + + return JSRequestModifier(_config, result) + } + + fun getHttpDataSourceFactory(): HttpDataSource.Factory { + val requestModifier = getRequestModifier(); + return if (requestModifier != null) { + JSHttpDataSource.Factory().setRequestModifier(requestModifier); + } else { + DefaultHttpDataSource.Factory(); + } + } + + companion object { + const val TYPE_AUDIOURL = "AudioUrlSource"; + const val TYPE_VIDEOURL = "VideoUrlSource"; + const val TYPE_AUDIO_WITH_METADATA = "AudioUrlRangeSource"; + const val TYPE_VIDEO_WITH_METADATA = "VideoUrlRangeSource"; + const val TYPE_DASH = "DashSource"; + const val TYPE_HLS = "HLSSource"; + + fun fromV8VideoNullable(config: IV8PluginConfig, obj: V8Value?) : IVideoSource? = obj.orNull { fromV8Video(config, it as V8ValueObject) }; + fun fromV8Video(config: IV8PluginConfig, obj: V8ValueObject) : IVideoSource { + val type = obj.getString("plugin_type"); + return when(type) { + TYPE_VIDEOURL -> JSVideoUrlSource(config, obj); + TYPE_VIDEO_WITH_METADATA -> JSVideoUrlRangeSource(config, obj); + TYPE_HLS -> fromV8HLS(config, obj); + TYPE_DASH -> fromV8Dash(config, obj); + else -> throw NotImplementedError("Unknown type ${type}"); + } + } + fun fromV8DashNullable(config: IV8PluginConfig, obj: V8Value?) : JSDashManifestSource? = obj.orNull { fromV8Dash(config, it as V8ValueObject) }; + fun fromV8Dash(config: IV8PluginConfig, obj: V8ValueObject) : JSDashManifestSource = JSDashManifestSource(config, obj); + fun fromV8HLSNullable(config: IV8PluginConfig, obj: V8Value?) : JSHLSManifestSource? = obj.orNull { fromV8HLS(config, it as V8ValueObject) }; + fun fromV8HLS(config: IV8PluginConfig, obj: V8ValueObject) : JSHLSManifestSource = JSHLSManifestSource(config, obj); + + fun fromV8Audio(config: IV8PluginConfig, obj: V8ValueObject) : IAudioSource { + val type = obj.getString("plugin_type"); + return when(type) { + TYPE_HLS -> JSHLSManifestAudioSource.fromV8HLS(config, obj); + TYPE_AUDIOURL -> JSAudioUrlSource(config, obj); + TYPE_AUDIO_WITH_METADATA -> JSAudioUrlRangeSource(config, obj); + else -> throw NotImplementedError("Unknown type ${type}"); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSUnMuxVideoSourceDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSUnMuxVideoSourceDescriptor.kt new file mode 100644 index 00000000..08d4911d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSUnMuxVideoSourceDescriptor.kt @@ -0,0 +1,29 @@ +package com.futo.platformplayer.api.media.platforms.js.models.sources + +import com.caoccao.javet.values.reference.V8ValueArray +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrThrow + +class JSUnMuxVideoSourceDescriptor: VideoUnMuxedSourceDescriptor { + protected val _obj: V8ValueObject; + + override val isUnMuxed: Boolean; + override val videoSources: Array; + override val audioSources: Array; + + constructor(config: IV8PluginConfig, obj: V8ValueObject) { + this._obj = obj; + val contextName = "UnMuxVideoSource" + this.isUnMuxed = obj.getOrThrow(config, "isUnMuxed", contextName); + this.videoSources = obj.getOrThrow(config, "videoSources", contextName).toArray() + .map { JSSource.fromV8Video(config, it as V8ValueObject) } + .toTypedArray(); + this.audioSources = obj.getOrThrow(config, "audioSources", contextName).toArray() + .map { JSSource.fromV8Audio(config, it as V8ValueObject) } + .toTypedArray(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoSourceDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoSourceDescriptor.kt new file mode 100644 index 00000000..463100b0 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoSourceDescriptor.kt @@ -0,0 +1,40 @@ +package com.futo.platformplayer.api.media.platforms.js.models.sources + +import com.caoccao.javet.values.reference.V8ValueArray +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrThrow + +class JSVideoSourceDescriptor: VideoMuxedSourceDescriptor { + protected val _obj: V8ValueObject; + + override val isUnMuxed: Boolean; + override val videoSources: Array; + + constructor(config: IV8PluginConfig, obj: V8ValueObject) { + this._obj = obj; + val contextName = "VideoSourceDescriptor"; + this.isUnMuxed = obj.getOrThrow(config, "isUnMuxed", contextName); + this.videoSources = obj.getOrThrow(config, "videoSources", contextName).toArray() + .map { JSSource.fromV8Video(config, it as V8ValueObject) } + .toTypedArray(); + } + + companion object { + const val TYPE_MUXED = "MuxVideoSourceDescriptor"; + const val TYPE_UNMUXED = "UnMuxVideoSourceDescriptor"; + + + fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : IVideoSourceDescriptor { + val type = obj.getString("plugin_type") + return when(type) { + TYPE_MUXED -> JSVideoSourceDescriptor(config, obj); + TYPE_UNMUXED -> JSUnMuxVideoSourceDescriptor(config, obj); + else -> throw NotImplementedError("Unknown type: ${type}"); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoUrlSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoUrlSource.kt new file mode 100644 index 00000000..a4fbf0e8 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoUrlSource.kt @@ -0,0 +1,43 @@ +package com.futo.platformplayer.api.media.platforms.js.models.sources + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrNull +import com.futo.platformplayer.getOrThrow + +open class JSVideoUrlSource : IVideoUrlSource, JSSource { + override val width : Int; + override val height : Int; + override val container : String; + override val codec: String; + override val name : String; + override val bitrate : Int; + override val duration: Long; + private val url : String; + + override var priority: Boolean = false; + + constructor(config: IV8PluginConfig, obj: V8ValueObject): super(TYPE_VIDEOURL, config, obj) { + val contextName = "JSVideoUrlSource"; + + width = _obj.getOrThrow(config, "width", contextName); + height = _obj.getOrThrow(config, "height", contextName); + container = _obj.getOrThrow(config, "container", contextName); + codec = _obj.getOrThrow(config, "codec", contextName); + name = _obj.getOrThrow(config, "name", contextName); + bitrate = _obj.getOrThrow(config, "bitrate", contextName); + duration = _obj.getOrThrow(config, "duration", contextName).toLong(); + url = _obj.getOrThrow(config, "url", contextName); + + priority = obj.getOrNull(config, "priority", contextName) ?: false; + } + + override fun getVideoUrl() : String { + return url; + } + + override fun toString(): String { + return "(width=$width, height=$height, container=$container, codec=$codec, name=$name, bitrate=$bitrate, duration=$duration, url=$url)" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoWithMetadataSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoWithMetadataSource.kt new file mode 100644 index 00000000..90b6edee --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoWithMetadataSource.kt @@ -0,0 +1,33 @@ +package com.futo.platformplayer.api.media.platforms.js.models.sources + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.streams.sources.other.IStreamMetaDataSource +import com.futo.platformplayer.api.media.models.streams.sources.other.StreamMetaData +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrDefault + +class JSVideoUrlRangeSource : JSVideoUrlSource, IStreamMetaDataSource { + val hasItag: Boolean get() = itagId != null && initStart != null && initEnd != null && indexStart != null && indexEnd != null; + val itagId: Int?; + val initStart: Int?; + val initEnd: Int?; + + val indexStart: Int?; + val indexEnd: Int?; + + override val streamMetaData get() = if(initStart != null + && initEnd != null + && indexStart != null + && indexEnd != null) + StreamMetaData(initStart, initEnd, indexStart, indexEnd) else null; + + constructor(config: IV8PluginConfig, obj: V8ValueObject) : super(config, obj) { + val contextName = "JSVideoUrlRangeSource"; + + itagId = _obj.getOrDefault(config, "itagId", contextName, null); + initStart = _obj.getOrDefault(config, "initStart", contextName, null); + initEnd = _obj.getOrDefault(config, "initEnd", contextName, null); + indexStart = _obj.getOrDefault(config, "indexStart", contextName, null); + indexEnd = _obj.getOrDefault(config, "indexEnd", contextName, null); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/DedupContentPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/DedupContentPager.kt new file mode 100644 index 00000000..033f737e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/DedupContentPager.kt @@ -0,0 +1,86 @@ +package com.futo.platformplayer.api.media.structures + +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.PlatformContentPlaceholder +import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.getDiffDays +import com.futo.platformplayer.getNowDiffDays +import com.futo.platformplayer.logging.Logger +import com.futo.polycentric.core.combineHashCodes +import kotlin.math.abs + +//TODO: If common pattern, create ModifierPager that implements all this composition +class DedupContentPager : IPager, IAsyncPager, IReplacerPager { + private val _basePager: IPager; + private val _pastResults: ArrayList = arrayListOf(); + private var _currentResults: List; + + private val _preferredPlatform: List; + + override val onReplaced = Event2(); + + constructor(basePager: IPager, preferredPlatform: List? = null) { + _preferredPlatform = preferredPlatform ?: listOf(); + _basePager = basePager; + _currentResults = dedupResults(_basePager.getResults()); + } + + override fun hasMorePages(): Boolean = _basePager.hasMorePages(); + override fun nextPage() { + _basePager.nextPage() + _currentResults = dedupResults(_basePager.getResults()); + } + + override suspend fun nextPageAsync() { + if(_basePager is IAsyncPager<*>) + _basePager.nextPageAsync(); + else + _basePager.nextPage(); + _currentResults = dedupResults(_basePager.getResults()); + } + override fun getResults(): List = _currentResults; + + private fun dedupResults(results: List): List { + val resultsToRemove = arrayListOf(); + + for(result in results) { + if(resultsToRemove.contains(result) || result is PlatformContentPlaceholder) + continue; + + //TODO: Map allocation can prob be simplified to just index based. + val sameItems = results.filter { isSameItem(result, it) }; + val platformItemMap = sameItems.groupBy { it.id.pluginId }.mapValues { (_, items) -> items.first() } + val bestPlatform = _preferredPlatform.map { it.lowercase() }.firstOrNull { platformItemMap.containsKey(it) } + val bestItem = platformItemMap[bestPlatform] ?: sameItems.first() + + resultsToRemove.addAll(sameItems.filter { it != bestItem }); + } + val toReturn = results.filter { !resultsToRemove.contains(it) }.mapNotNull { item -> + val olderItemIndex = _pastResults.indexOfFirst { isSameItem(item, it) }; + if(olderItemIndex >= 0) { + val olderItem = _pastResults[olderItemIndex]; + val olderItemPriority = _preferredPlatform.indexOf(olderItem.id.pluginId); + val newItemPriority = _preferredPlatform.indexOf(item.id.pluginId); + if(newItemPriority < olderItemPriority) { + _pastResults[olderItemIndex] = item; + onReplaced.emit(olderItem, item); + } + return@mapNotNull null; + } + else + return@mapNotNull item; + }; + _pastResults.addAll(toReturn); + return toReturn; + } + private fun isSameItem(item: IPlatformContent, item2: IPlatformContent): Boolean { + return item.name == item2.name && (item.datetime == null || item2.datetime == null || abs(item.datetime!!.getDiffDays(item2.datetime!!)) < 2); + } + private fun calculateHash(item: IPlatformContent): Int { + return combineHashCodes(listOf(item.name.hashCode(), item.datetime?.hashCode())); + } + + companion object { + private const val TAG = "DedupContentPager"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/EmptyPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/EmptyPager.kt new file mode 100644 index 00000000..31d1f0fb --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/EmptyPager.kt @@ -0,0 +1,19 @@ +package com.futo.platformplayer.api.media.structures + +/** + * A pager without results + */ +open class EmptyPager : IPager { + override fun hasMorePages(): Boolean { + return false; + } + + override fun nextPage() { + + } + + override fun getResults(): List { + return listOf(); + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/IAsyncPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/IAsyncPager.kt new file mode 100644 index 00000000..9a9ebd65 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/IAsyncPager.kt @@ -0,0 +1,12 @@ +package com.futo.platformplayer.api.media.structures + +import kotlinx.coroutines.CoroutineScope + +/** + * A Pager interface that implements a suspended manner of nextPage + */ +interface IAsyncPager { + fun hasMorePages() : Boolean; + suspend fun nextPageAsync(); + fun getResults() : List; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/INestedPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/INestedPager.kt new file mode 100644 index 00000000..42e1d1eb --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/INestedPager.kt @@ -0,0 +1,8 @@ +package com.futo.platformplayer.api.media.structures + +/** + * Interface extension for some pagers that allow you to find nested pagers if needed + */ +interface INestedPager { + fun findPager(query: (IPager) -> Boolean): IPager?; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/IPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/IPager.kt new file mode 100644 index 00000000..77d49acf --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/IPager.kt @@ -0,0 +1,10 @@ +package com.futo.platformplayer.api.media.structures + +/** + * Base pager used for all paging in the app, often wrapped by various other pagers to modified behavior + */ +interface IPager { + fun hasMorePages() : Boolean; + fun nextPage(); + fun getResults() : List; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/IPlatformLiveEventPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/IPlatformLiveEventPager.kt new file mode 100644 index 00000000..3048a6e9 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/IPlatformLiveEventPager.kt @@ -0,0 +1,10 @@ +package com.futo.platformplayer.api.media.structures + +import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent + +/** + * A special pager intended for live chat implementation. Extended if required based on the JS implementations + */ +interface IPlatformLiveEventPager: IPager { + val nextRequest: Int; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/IRefreshPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/IRefreshPager.kt new file mode 100644 index 00000000..390895bd --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/IRefreshPager.kt @@ -0,0 +1,15 @@ +package com.futo.platformplayer.api.media.structures + +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.constructs.Event1 + +/** + * A RefreshPager represents a pager that can be modified overtime (eg. By getting more results later, by recreating the pager) + * When the onPagerChanged event is emitted, a new pager instance is passed, or requested via getCurrentPager + */ +interface IRefreshPager { + val onPagerChanged: Event1>; + val onPagerError: Event1; + + fun getCurrentPager(): IPager; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/IReplacerPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/IReplacerPager.kt new file mode 100644 index 00000000..613bc255 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/IReplacerPager.kt @@ -0,0 +1,7 @@ +package com.futo.platformplayer.api.media.structures + +import com.futo.platformplayer.constructs.Event2 + +interface IReplacerPager { + val onReplaced: Event2; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiAsyncPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiAsyncPager.kt new file mode 100644 index 00000000..8ab8e5d5 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiAsyncPager.kt @@ -0,0 +1,165 @@ +package com.futo.platformplayer.api.media.structures + +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.api.media.exceptions.search.NoNextPageException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import java.util.stream.IntStream +import kotlin.system.measureTimeMillis + +/** + * Async MultiPager is a multipager that calls all pagers in a deferred (promised) manner. + * Unlike its normal counterpart which waits for results as they are needed. + * The benefit is that if multiple pagers need to request a new page, its done in parallel and awaited together. + * The downside is that pager results cannot be consumed based on their contents, as their contents may still be unknown. + * (eg. example Chronological pagers cannot be done without reordering the results every refresh, which causes bad UX) + */ +abstract class MultiAsyncPager : IPager, IAsyncPager { + protected val _pagerLock = Object(); + + protected val _pagers : MutableList>; + protected val _subSinglePagers : MutableList>; + protected val _failedPagers: ArrayList> = arrayListOf(); + + private val _pageSize : Int = 9; + + private var _didInitialize = false; + + private var _currentResults : List = listOf(); + private var _currentResultExceptions: Map, Throwable> = mapOf(); + + val allowFailure: Boolean; + + val totalPagers: Int get() = _pagers.size; + + constructor(pagers : List>, allowFailure: Boolean = false) { + this.allowFailure = allowFailure; + _pagers = pagers.toMutableList(); + _subSinglePagers = _pagers.map { SingleAsyncItemPager(it) }.toMutableList(); + } + suspend fun initialize() { + withContext(Dispatchers.IO) { + _currentResults = loadNextPage(this, true); + } + _didInitialize = true; + } + + override fun hasMorePages(): Boolean { + synchronized(_pagerLock) { + return _subSinglePagers.any { it.hasMoreItems() } || _pagers.any { it.hasMorePages() } + } + } + override fun nextPage() { + Logger.i(TAG, "Load next page"); + if(!_didInitialize) + throw IllegalStateException("Call initialize on MultiVideoPager before using it"); + runBlocking { loadNextPage(this) }; + Logger.i(TAG, "New results: ${_currentResults.size}"); + } + + override suspend fun nextPageAsync() { + Logger.i(TAG, "Load next page (async)"); + if(!_didInitialize) + throw IllegalStateException("Call initialize on MultiVideoPager before using it"); + withContext(Dispatchers.IO) { + } + Logger.i(TAG, "New results: ${_currentResults.size}"); + } + + override fun getResults(): List { + if(!_didInitialize) + throw IllegalStateException("Call initialize on MultiVideoPager before using it"); + return _currentResults; + } + fun getResultExceptions(): Map, Throwable> { + if(!_didInitialize) + throw IllegalStateException("Call initialize on MultiVideoPager before using it"); + return _currentResultExceptions; + } + + @Synchronized + private fun loadNextPage(scope: CoroutineScope, isInitial: Boolean = false) : List { + synchronized(_pagerLock) { + if (_subSinglePagers.size == 0) + return listOf(); + } + if(!isInitial && !hasMorePages()) + throw NoNextPageException(); + + val results = ArrayList>(); + val exceptions: MutableMap, Throwable> = mutableMapOf(); + for(i in IntStream.range(0, _pageSize)) { + val validPagers = synchronized(_pagerLock) { + _subSinglePagers.filter { !_failedPagers.contains(it.getPager()) && (it.hasMoreItems() || it.getPager().hasMorePages()) } + }; + val options: ArrayList> = arrayListOf(); + for (pager in validPagers) { + val item: Deferred? = if (allowFailure) { + try { + pager.getCurrentItem(scope); + } catch (ex: NoNextPageException) { + //TODO: This should never happen, has to be fixed later + Logger.i(TAG, "Expected item from pager but no page found?"); + null; + } catch (ex: Throwable) { + Logger.e(TAG, "Failed to fetch page for pager, exception: ${ex.message}", ex); + _failedPagers.add(pager.getPager()); + exceptions.put(pager.getPager(), ex); + null; + } + } else { + try { + pager.getCurrentItem(scope); + } catch (ex: NoNextPageException) { + //TODO: This should never happen, has to be fixed later + Logger.i(TAG, "Expected item from pager but no page found?"); + null; + } + }; + if (item != null) + options.add(SelectionOption(pager, item)); + } + + if (options.size == 0) + break; + val bestIndex = selectItemIndex(options.toTypedArray()); + if (bestIndex >= 0) { + + val consumed = options[bestIndex].pager.consumeItem(scope); + if (consumed != null) + results.add(consumed); + } + } + + _currentResults = results.mapNotNull { convertItem(it) }; + + _currentResultExceptions = exceptions; + return _currentResults; + } + + protected abstract fun convertItem(def: Deferred): R?; + + protected abstract fun selectItemIndex(options : Array>) : Int; + + protected class SelectionOption(val pager : SingleAsyncItemPager, val item : Deferred?); + + fun setExceptions(exs: Map, Throwable>) { + _currentResultExceptions = exs; + } + fun findPager(query: (IPager)->Boolean): IPager<*>? { + for(pager in _pagers) { + if(query(pager)) + return pager; + if(pager is MultiAsyncPager<*,*>) + return pager.findPager(query as (IPager) -> Boolean); + } + return null; + } + + companion object { + val TAG = "MultiPager"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiChronoContentPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiChronoContentPager.kt new file mode 100644 index 00000000..cc799cc9 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiChronoContentPager.kt @@ -0,0 +1,25 @@ +package com.futo.platformplayer.api.media.structures + +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import java.util.stream.IntStream + +/** + * A PlatformContent MultiPager that orders the results of a page based on the datetime of a content item + */ +class MultiChronoContentPager : MultiPager { + constructor(pagers : Array>, allowFailure: Boolean = false) : super(pagers.map { it }.toList(), allowFailure) {} + + @Synchronized + override fun selectItemIndex(options: Array>): Int { + if(options.size == 0) + return -1; + var bestIndex = 0; + for(i in IntStream.range(1, options.size)) { + val best = options[bestIndex].item!!; + val cur = options[i].item!!; + if(best.datetime == null || (cur.datetime != null && cur.datetime!! > best.datetime!!)) + bestIndex = i; + } + return bestIndex; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionChannelPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionChannelPager.kt new file mode 100644 index 00000000..a262af30 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionChannelPager.kt @@ -0,0 +1,48 @@ +package com.futo.platformplayer.api.media.structures + +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import java.util.stream.IntStream + +/** + * A Channel MultiPager that returns results based on a specified distribution + * TODO: Merge all basic distribution pagers + */ +class MultiDistributionChannelPager : MultiPager { + + private val dist : HashMap, Float>; + private val distConsumed : HashMap, Float>; + + constructor(pagers : Map, Float>) : super(pagers.keys.toMutableList()) { + val distTotal = pagers.values.sum(); + dist = HashMap(); + + //Convert distribution values to inverted percentages + for(kv in pagers) + dist[kv.key] = 1f - (kv.value / distTotal); + distConsumed = HashMap(); + for(kv in dist) + distConsumed[kv.key] = 0f; + } + + @Synchronized + override fun selectItemIndex(options: Array>): Int { + if(options.size == 0) + return -1; + var bestIndex = 0; + var bestConsumed = distConsumed[options[0].pager.getPager()]!! + dist[options[0].pager.getPager()]!!; + for(i in IntStream.range(1, options.size)) { + val pager = options[i].pager.getPager(); + val valueAfterAdd = distConsumed[pager]!! + dist[pager]!!; + + if(valueAfterAdd < bestConsumed) { + bestIndex = i; + bestConsumed = valueAfterAdd; + } + } + distConsumed[options[bestIndex].pager.getPager()] = bestConsumed; + return bestIndex; + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentAsyncPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentAsyncPager.kt new file mode 100644 index 00000000..25f79331 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentAsyncPager.kt @@ -0,0 +1,56 @@ +package com.futo.platformplayer.api.media.structures + +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.PlatformContentDeferred +import kotlinx.coroutines.Deferred +import java.util.stream.IntStream + + +/** + * A Content AsyncMultiPager that returns results based on a specified distribution + * Unlike its non-async counterpart, this one uses parallel nextPage requests + */ +class MultiDistributionContentAsyncPager : MultiAsyncPager { + + private val dist : HashMap, Float>; + private val distConsumed : HashMap, Float>; + + constructor(pagers: Map, Float>) : super(pagers.keys.toMutableList()) { + val distTotal = pagers.values.sum(); + dist = HashMap(); + + //Convert distribution values to inverted percentages + for(kv in pagers) + dist[kv.key] = 1f - (kv.value / distTotal); + distConsumed = HashMap(); + for(kv in dist) + distConsumed[kv.key] = 0f; + } + + override fun convertItem(def: Deferred): IPlatformContent? { + if(def.isCompleted) + return def.getCompleted(); + else + return PlatformContentDeferred(null); + } + + @Synchronized + override fun selectItemIndex(options: Array>): Int { + if(options.size == 0) + return -1; + + var bestIndex = 0; + var bestConsumed = distConsumed[options[0].pager.getPager()]!! + dist[options[0].pager.getPager()]!!; + for(i in IntStream.range(1, options.size)) { + val pager = options[i].pager.getPager(); + val valueAfterAdd = distConsumed[pager]!! + dist[pager]!!; + + if(valueAfterAdd < bestConsumed) { + bestIndex = i; + bestConsumed = valueAfterAdd; + } + } + distConsumed[options[bestIndex].pager.getPager()] = bestConsumed; + return bestIndex; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt new file mode 100644 index 00000000..e86c9ba6 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt @@ -0,0 +1,47 @@ +package com.futo.platformplayer.api.media.structures + +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import java.util.stream.IntStream + +/** + * A Content MultiPager that returns results based on a specified distribution + * TODO: Merge all basic distribution pagers + */ +class MultiDistributionContentPager : MultiPager { + + private val dist : HashMap, Float>; + private val distConsumed : HashMap, Float>; + + constructor(pagers : Map, Float>) : super(pagers.keys.toMutableList()) { + val distTotal = pagers.values.sum(); + dist = HashMap(); + + //Convert distribution values to inverted percentages + for(kv in pagers) + dist[kv.key] = 1f - (kv.value / distTotal); + distConsumed = HashMap(); + for(kv in dist) + distConsumed[kv.key] = 0f; + } + + @Synchronized + override fun selectItemIndex(options: Array>): Int { + if(options.size == 0) + return -1; + var bestIndex = 0; + var bestConsumed = distConsumed[options[0].pager.getPager()]!! + dist[options[0].pager.getPager()]!!; + for(i in IntStream.range(1, options.size)) { + val pager = options[i].pager.getPager(); + val valueAfterAdd = distConsumed[pager]!! + dist[pager]!!; + + if(valueAfterAdd < bestConsumed) { + bestIndex = i; + bestConsumed = valueAfterAdd; + } + } + distConsumed[options[bestIndex].pager.getPager()] = bestConsumed; + return bestIndex; + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentParallelPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentParallelPager.kt new file mode 100644 index 00000000..4cfdd764 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentParallelPager.kt @@ -0,0 +1,49 @@ +package com.futo.platformplayer.api.media.structures + +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import java.util.stream.IntStream + + +/** + * A Content AsyncMultiPager that returns results based on a specified distribution + * Unlike its non-async counterpart, this one uses parallel nextPage requests + */ +class MultiDistributionContentParallelPager : MultiParallelPager { + + private val dist : HashMap, Float>; + private val distConsumed : HashMap, Float>; + + constructor(pagers: Map, Float>) : super(pagers.keys.toMutableList()) { + val distTotal = pagers.values.sum(); + dist = HashMap(); + + //Convert distribution values to inverted percentages + for(kv in pagers) + dist[kv.key] = 1f - (kv.value / distTotal); + distConsumed = HashMap(); + for(kv in dist) + distConsumed[kv.key] = 0f; + } + + @Synchronized + override fun selectItemIndex(options: Array>): Int { + if(options.size == 0) + return -1; + + var bestIndex = 0; + var bestConsumed = distConsumed[options[0].pager.getPager()]!! + dist[options[0].pager.getPager()]!!; + for(i in IntStream.range(1, options.size)) { + val pager = options[i].pager.getPager(); + val valueAfterAdd = distConsumed[pager]!! + dist[pager]!!; + + if(valueAfterAdd < bestConsumed) { + bestIndex = i; + bestConsumed = valueAfterAdd; + } + } + distConsumed[options[bestIndex].pager.getPager()] = bestConsumed; + return bestIndex; + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiPager.kt new file mode 100644 index 00000000..0917805b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiPager.kt @@ -0,0 +1,141 @@ +package com.futo.platformplayer.api.media.structures + +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.api.media.exceptions.search.NoNextPageException +import java.util.stream.IntStream + +/** + * A MultiPager combines several pagers of the same types, and merges them in some manner. + * Implementations of this abstract class require to implement which item is the next one, by choosing between each provided pager + * (eg. Implementation of MultiPager is MultiChronoContentPager which orders multiple pager contents by their datetime) + */ +abstract class MultiPager : IPager { + protected val _pagerLock = Object(); + + protected val _pagers : MutableList>; + protected val _subSinglePagers : MutableList>; + protected val _failedPagers: ArrayList> = arrayListOf(); + + private val _pageSize : Int = 9; + + private var _didInitialize = false; + + private var _currentResults : List = listOf(); + private var _currentResultExceptions: Map, Throwable> = mapOf(); + + val allowFailure: Boolean; + + val totalPagers: Int get() = _pagers.size; + + constructor(pagers : List>, allowFailure: Boolean = false) { + this.allowFailure = allowFailure; + _pagers = pagers.toMutableList(); + _subSinglePagers = _pagers.map { SingleItemPager(it) }.toMutableList(); + } + fun initialize() { + _currentResults = loadNextPage(true); + _didInitialize = true; + } + + override fun hasMorePages(): Boolean { + synchronized(_pagerLock) { + return _subSinglePagers.any { it.hasMoreItems() } || _pagers.any { it.hasMorePages() } + } + } + override fun nextPage() { + Logger.i(TAG, "Load next page"); + if(!_didInitialize) + throw IllegalStateException("Call initialize on MultiVideoPager before using it"); + loadNextPage(); + Logger.i(TAG, "New results: ${_currentResults.size}"); + } + override fun getResults(): List { + if(!_didInitialize) + throw IllegalStateException("Call initialize on MultiVideoPager before using it"); + return _currentResults; + } + fun getResultExceptions(): Map, Throwable> { + if(!_didInitialize) + throw IllegalStateException("Call initialize on MultiVideoPager before using it"); + return _currentResultExceptions; + } + + @Synchronized + private fun loadNextPage(isInitial: Boolean = false) : List { + synchronized(_pagerLock) { + if (_subSinglePagers.size == 0) + return listOf(); + } + if(!isInitial && !hasMorePages()) + throw NoNextPageException(); + + val results = ArrayList(); + val exceptions: MutableMap, Throwable> = mutableMapOf(); + for(i in IntStream.range(0, _pageSize)) { + val validPagers = synchronized(_pagerLock) { + _subSinglePagers.filter { !_failedPagers.contains(it.getPager()) && (it.hasMoreItems() || it.getPager().hasMorePages()) } + }; + val options: ArrayList> = arrayListOf(); + for (pager in validPagers) { + val item: T? = if (allowFailure) { + try { + pager.getCurrentItem(); + } catch (ex: NoNextPageException) { + //TODO: This should never happen, has to be fixed later + Logger.i(TAG, "Expected item from pager but no page found?"); + null; + } catch (ex: Throwable) { + Logger.e(TAG, "Failed to fetch page for pager, exception: ${ex.message}", ex); + _failedPagers.add(pager.getPager()); + exceptions.put(pager.getPager(), ex); + null; + } + } else { + try { + pager.getCurrentItem(); + } catch (ex: NoNextPageException) { + //TODO: This should never happen, has to be fixed later + Logger.i(TAG, "Expected item from pager but no page found?"); + null; + } + }; + if (item != null) + options.add(SelectionOption(pager, item)); + } + + if (options.size == 0) + break; + val bestIndex = selectItemIndex(options.toTypedArray()); + if (bestIndex >= 0) { + + val consumed = options[bestIndex].pager.consumeItem(); + if (consumed != null) + results.add(consumed); + } + } + _currentResults = results; + _currentResultExceptions = exceptions; + return _currentResults; + } + + protected abstract fun selectItemIndex(options : Array>) : Int; + + protected class SelectionOption(val pager : SingleItemPager, val item : T?); + + fun setExceptions(exs: Map, Throwable>) { + _currentResultExceptions = exs; + } + fun findPager(query: (IPager)->Boolean): IPager<*>? { + for(pager in _pagers) { + if(query(pager)) + return pager; + if(pager is MultiPager<*>) + return pager.findPager(query as (IPager) -> Boolean); + } + return null; + } + + companion object { + val TAG = "MultiPager"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiParallelPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiParallelPager.kt new file mode 100644 index 00000000..9c782e29 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiParallelPager.kt @@ -0,0 +1,170 @@ +package com.futo.platformplayer.api.media.structures + +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.api.media.exceptions.search.NoNextPageException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import java.util.stream.IntStream +import kotlin.system.measureTimeMillis + +/** + * Async MultiPager is a multipager that calls all pagers in a deferred (promised) manner. + * Unlike its normal counterpart which waits for results as they are needed. + * The benefit is that if multiple pagers need to request a new page, its done in parallel and awaited together. + * The downside is that pager results cannot be consumed based on their contents, as their contents may still be unknown. + * (eg. example Chronological pagers cannot be done without reordering the results every refresh, which causes bad UX) + */ +abstract class MultiParallelPager : IPager, IAsyncPager { + protected val _pagerLock = Object(); + + protected val _pagers : MutableList>; + protected val _subSinglePagers : MutableList>; + protected val _failedPagers: ArrayList> = arrayListOf(); + + private val _pageSize : Int = 9; + + private var _didInitialize = false; + + private var _currentResults : List = listOf(); + private var _currentResultExceptions: Map, Throwable> = mapOf(); + + val allowFailure: Boolean; + + val totalPagers: Int get() = _pagers.size; + + constructor(pagers : List>, allowFailure: Boolean = false) { + this.allowFailure = allowFailure; + _pagers = pagers.toMutableList(); + _subSinglePagers = _pagers.map { SingleAsyncItemPager(it) }.toMutableList(); + } + suspend fun initialize() { + withContext(Dispatchers.IO) { + _currentResults = loadNextPage(this, true); + } + _didInitialize = true; + } + + override fun hasMorePages(): Boolean { + synchronized(_pagerLock) { + return _subSinglePagers.any { it.hasMoreItems() } || _pagers.any { it.hasMorePages() } + } + } + + override fun nextPage() { + Logger.i(TAG, "Load next page"); + if(!_didInitialize) + throw IllegalStateException("Call initialize on MultiVideoPager before using it"); + runBlocking { loadNextPage(this) }; + Logger.i(TAG, "New results: ${_currentResults.size}"); + } + + override suspend fun nextPageAsync() { + Logger.i(TAG, "Load next page (async)"); + if(!_didInitialize) + throw IllegalStateException("Call initialize on MultiVideoPager before using it"); + withContext(Dispatchers.IO) { + loadNextPage(this); + } + Logger.i(TAG, "New results: ${_currentResults.size}"); + } + + override fun getResults(): List { + if(!_didInitialize) + throw IllegalStateException("Call initialize on MultiVideoPager before using it"); + return _currentResults; + } + fun getResultExceptions(): Map, Throwable> { + if(!_didInitialize) + throw IllegalStateException("Call initialize on MultiVideoPager before using it"); + return _currentResultExceptions; + } + + private suspend fun loadNextPage(scope: CoroutineScope, isInitial: Boolean = false) : List { + synchronized(_pagerLock) { + if (_subSinglePagers.size == 0) + return listOf(); + } + + if(!isInitial && !hasMorePages()) + throw NoNextPageException(); + + val results = ArrayList>(); + val exceptions: MutableMap, Throwable> = mutableMapOf(); + val timeForPage = measureTimeMillis { + for(i in IntStream.range(0, _pageSize)) { + val validPagers = synchronized(_pagerLock) { + _subSinglePagers.filter { !_failedPagers.contains(it.getPager()) && (it.hasMoreItems() || it.getPager().hasMorePages()) } + }; + val options: ArrayList> = arrayListOf(); + for (pager in validPagers) { + val item: Deferred? = if (allowFailure) { + try { + pager.getCurrentItem(scope); + } catch (ex: NoNextPageException) { + //TODO: This should never happen, has to be fixed later + Logger.i(TAG, "Expected item from pager but no page found?"); + null; + } catch (ex: Throwable) { + Logger.e(TAG, "Failed to fetch page for pager, exception: ${ex.message}", ex); + _failedPagers.add(pager.getPager()); + exceptions.put(pager.getPager(), ex); + null; + } + } else { + try { + pager.getCurrentItem(scope); + } catch (ex: NoNextPageException) { + //TODO: This should never happen, has to be fixed later + Logger.i(TAG, "Expected item from pager but no page found?"); + null; + } + }; + if (item != null) + options.add(SelectionOption(pager, item)); + } + + if (options.size == 0) + break; + val bestIndex = selectItemIndex(options.toTypedArray()); + if (bestIndex >= 0) { + + val consumed = options[bestIndex].pager.consumeItem(scope); + if (consumed != null) + results.add(consumed); + } + } + } + Logger.i(TAG, "Pager prepare in ${timeForPage}ms"); + val timeAwait = measureTimeMillis { + _currentResults = results.map { it.await() }.mapNotNull { it }; + }; + Logger.i(TAG, "Pager load in ${timeAwait}ms"); + + _currentResultExceptions = exceptions; + return _currentResults; + } + + protected abstract fun selectItemIndex(options : Array>) : Int; + + protected class SelectionOption(val pager : SingleAsyncItemPager, val item : Deferred?); + + fun setExceptions(exs: Map, Throwable>) { + _currentResultExceptions = exs; + } + fun findPager(query: (IPager)->Boolean): IPager<*>? { + for(pager in _pagers) { + if(query(pager)) + return pager; + if(pager is MultiParallelPager<*>) + return pager.findPager(query as (IPager) -> Boolean); + } + return null; + } + + companion object { + val TAG = "MultiPager"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiRefreshPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiRefreshPager.kt new file mode 100644 index 00000000..af18e817 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiRefreshPager.kt @@ -0,0 +1,92 @@ +package com.futo.platformplayer.api.media.structures + +import com.futo.platformplayer.api.media.structures.ReusablePager.Companion.asReusable +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.logging.Logger +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.runBlocking + +/** + * Refresh pager is a managed multiple pagers of which some are promised/deferred, and optionally inserts placeholdesr for not yet finished promised pagers. + * RefreshMultiPager has no inherit logic on how the pagers are read, and solely manages awaiting pagers, and "refreshing" them when new ones become available. + * The abstract recreatePager method is intended to implement the exact pager used that is given to then consumer. + * (Eg. RefreshDistributionContentPager returns the pagers as a equal distribution from each pager) + */ +abstract class MultiRefreshPager: IRefreshPager, IPager { + override val onPagerChanged: Event1> = Event1(); + override val onPagerError: Event1 = Event1(); + + private val _pagersReusable: MutableList>; + private var _currentPager: IPager; + private val _addPlaceholders = false; + private val _totalPagers: Int; + private val _placeHolderPagersPaired: Map?>, IPager>; + + private val _pending: MutableList?>>; + + constructor(pagers: List>, pendingPagers: List?>>, placeholderPagers: List>? = null) { + _pagersReusable = pagers.map { ReusablePager(it) }.toMutableList(); + _totalPagers = pagers.size + pendingPagers.size; + _placeHolderPagersPaired = placeholderPagers?.take(pendingPagers.size)?.mapIndexed { i, pager -> + return@mapIndexed Pair(pendingPagers[i], pager); + }?.toMap() ?: mapOf(); + _pending = pendingPagers.toMutableList(); + + for(pendingPager in pendingPagers) + pendingPager.invokeOnCompletion { error -> + synchronized(_pending) { + _pending.remove(pendingPager); + } + if(error != null) + onPagerError.emit(error); + else + updatePager(pendingPager.getCompleted()); + } + synchronized(_pagersReusable) { + _currentPager = recreatePager(getCurrentSubPagers()); + + if(_currentPager is MultiParallelPager<*>) + runBlocking { (_currentPager as MultiParallelPager).initialize(); }; + else if(_currentPager is MultiPager<*>) + (_currentPager as MultiPager).initialize(); + + onPagerChanged.emit(_currentPager); + } + } + + abstract fun recreatePager(pagers: List>): IPager; + + override fun hasMorePages(): Boolean = synchronized(_pagersReusable){ _currentPager.hasMorePages() }; + override fun nextPage() = synchronized(_pagersReusable){ _currentPager.nextPage() }; + override fun getResults(): List = synchronized(_pagersReusable){ _currentPager.getResults() }; + + private fun updatePager(pagerToAdd: IPager?) { + if(pagerToAdd == null) + return; + synchronized(_pagersReusable) { + Logger.i("RefreshMultiDistributionContentPager", "Received new pager for RefreshPager") + _pagersReusable.add(pagerToAdd.asReusable()); + + _currentPager = recreatePager(getCurrentSubPagers()); + + if(_currentPager is MultiParallelPager<*>) + runBlocking { (_currentPager as MultiParallelPager).initialize(); }; + else if(_currentPager is MultiPager<*>) + (_currentPager as MultiPager).initialize(); + + onPagerChanged.emit(_currentPager); + } + } + + private fun getCurrentSubPagers(): List> { + val reusableWindows = _pagersReusable.map { it.getWindow() as IPager }; + val placeholderWindows = synchronized(_pending) { + _placeHolderPagersPaired.filter { _pending.contains(it.key) }.values + } + return reusableWindows + placeholderWindows; + } + + override fun getCurrentPager(): IPager { + return synchronized(_pagersReusable) { _currentPager }; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/PlaceholderPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/PlaceholderPager.kt new file mode 100644 index 00000000..c0bd8d6e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/PlaceholderPager.kt @@ -0,0 +1,25 @@ +package com.futo.platformplayer.api.media.structures + +import com.futo.platformplayer.api.media.models.contents.IPlatformContent + +/** + * A placeholder pager simply generates PlatformContent by some creator function. + */ +class PlaceholderPager : IPager { + private val _creator: ()->IPlatformContent; + private val _pageSize: Int; + + constructor(pageSize: Int, placeholderCreator: ()->IPlatformContent) { + _creator = placeholderCreator; + _pageSize = pageSize; + } + + override fun nextPage() {}; + override fun getResults(): List { + val pages = ArrayList(); + for(item in 1.._pageSize) + pages.add(_creator()); + return pages; + } + override fun hasMorePages(): Boolean = true; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/PlatformContentPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/PlatformContentPager.kt new file mode 100644 index 00000000..12127a88 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/PlatformContentPager.kt @@ -0,0 +1,39 @@ +package com.futo.platformplayer.api.media.structures + +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import kotlin.streams.toList + +/** + * Old wrapper for Platform Content + * Marked for deletion (?) + */ +class PlatformContentPager : IPager { + private val _items : List; + private var _page = 0; + private val _pageSize : Int; + private var _currentItems : List; + + constructor(items : List, itemsPerPage : Int = 20) { + _items = items; + _pageSize = itemsPerPage; + _currentItems = items.take(itemsPerPage).toList(); + } + + override fun hasMorePages(): Boolean { + return _items.size > (_page + 1) * _pageSize; + } + + override fun nextPage() { + _page++; + _currentItems = _items.stream() + .skip((_page * _pageSize).toLong()) + .toList() + .take(_pageSize) + .toList(); + } + + override fun getResults(): List { + return _currentItems; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/RefreshDedupContentPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/RefreshDedupContentPager.kt new file mode 100644 index 00000000..f57473ab --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/RefreshDedupContentPager.kt @@ -0,0 +1,36 @@ +package com.futo.platformplayer.api.media.structures + +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.constructs.Event1 + +class RefreshDedupContentPager: IRefreshPager, IPager { + private val _basePager: MultiRefreshPager; + private var _currentPage: IPager; + + override val onPagerChanged = Event1>(); + override val onPagerError = Event1(); + + + constructor(refreshPager: MultiRefreshPager, preferredPlatform: List? = null) : super() { + _basePager = refreshPager; + _currentPage = DedupContentPager(_basePager.getCurrentPager(), preferredPlatform); + _basePager.onPagerError.subscribe(onPagerError::emit); + _basePager.onPagerChanged.subscribe { + _currentPage = DedupContentPager(it, preferredPlatform); + onPagerChanged.emit(_currentPage); + }; + } + + override fun getCurrentPager(): IPager = _currentPage; + override fun hasMorePages(): Boolean { + return _basePager.hasMorePages(); + } + + override fun nextPage() { + return _basePager.nextPage(); + } + + override fun getResults(): List { + return _basePager.getResults(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/RefreshDistributionContentPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/RefreshDistributionContentPager.kt new file mode 100644 index 00000000..abce481f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/RefreshDistributionContentPager.kt @@ -0,0 +1,18 @@ +package com.futo.platformplayer.api.media.structures + +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import kotlinx.coroutines.Deferred + +/** + * A RefreshMultiPager that simply returns all respective pagers in equal distribution, optionally inserting PlaceholderPager results as provided for their respective promised pagers + * (Eg. Pager A is completed, Pager [B,C,D] are promised/deferred. placeholderPagers [1,2,3] will map B=>1, C=>2, D=>3 until promised pagers are completed) + * Uses wrapped MultiDistributionContentAsyncPager for inidivual pagers. + */ +class RefreshDistributionContentPager(pagers: List>, pendingPagers: List?>>, placeholderPagers: List>? = null) + : MultiRefreshPager(pagers, pendingPagers, placeholderPagers) { + + override fun recreatePager(pagers: List>): IPager { + return MultiDistributionContentParallelPager(pagers.associateWith { 1f }); + //return MultiDistributionContentPager(pagers.associateWith { 1f }); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/ReusablePager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/ReusablePager.kt new file mode 100644 index 00000000..45f6aea5 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/ReusablePager.kt @@ -0,0 +1,98 @@ +package com.futo.platformplayer.api.media.structures + +import com.futo.platformplayer.logging.Logger + +/** + * A wrapper pager that stores previous pages of results, and provides child-pagers that read from the source. + * This allows for a pager to be re-used in various scenarios where previous results need to be reloaded. + * (Eg. Subscriptions feed uses it to batch requests, and respond multiple pagers with the same source without duplicate requests) + * A "Window" is effectively a pager that just reads previous results from the shared results, but when the end is reached, it will call nextPage on the parent if possible for new results. + * This allows multiple Windows to exist of the same pager, without messing with position, or duplicate requests + */ +class ReusablePager: INestedPager, IPager { + private val _pager: IPager; + val previousResults = arrayListOf(); + + constructor(subPager: IPager) { + this._pager = subPager; + synchronized(previousResults) { + previousResults.addAll(subPager.getResults()); + } + } + + override fun findPager(query: (IPager) -> Boolean): IPager? { + if(query(_pager)) + return _pager; + else if(_pager is INestedPager<*>) + return (_pager as INestedPager).findPager(query); + return null; + } + + override fun hasMorePages(): Boolean { + return _pager.hasMorePages(); + } + + override fun nextPage() { + _pager.nextPage(); + } + + override fun getResults(): List { + val results = _pager.getResults(); + synchronized(previousResults) { + previousResults.addAll(results); + } + return previousResults; + } + + fun getWindow(): Window { + return Window(this); + } + + + class Window: IPager, INestedPager { + private val _parent: ReusablePager; + private var _position: Int = 0; + private var _read: Int = 0; + + private var _currentResults: List; + + + constructor(parent: ReusablePager) { + _parent = parent; + synchronized(_parent.previousResults) { + _currentResults = _parent.previousResults.toList(); + _read += _currentResults.size; + } + } + + override fun hasMorePages(): Boolean { + return _parent.previousResults.size > _read || _parent.hasMorePages(); + } + + override fun nextPage() { + synchronized(_parent.previousResults) { + if(_parent.previousResults.size <= _read) { + _parent.nextPage(); + _parent.getResults(); + } + _currentResults = _parent.previousResults.drop(_read).toList(); + _read += _currentResults.size; + } + } + + override fun getResults(): List { + return _currentResults; + } + + override fun findPager(query: (IPager) -> Boolean): IPager? { + return _parent.findPager(query); + } + + } + + companion object { + fun IPager.asReusable(): ReusablePager { + return ReusablePager(this); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/SingleAsyncItemPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/SingleAsyncItemPager.kt new file mode 100644 index 00000000..4c0bc621 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/SingleAsyncItemPager.kt @@ -0,0 +1,113 @@ +package com.futo.platformplayer.api.media.structures + +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlin.system.measureTimeMillis + +/** + * SingleItemPagers are used to wrap any IPager and consume items 1 at a time. + * Often used by MultiPagers to add items to a page one at a time from multiple pagers + * Unlike its non-async counterpart, It returns a Deferred item which is either constant, or a future page response if a nextPage has to be called on the base pager. + */ +class SingleAsyncItemPager { + private val _pager : IPager; + private var _currentResultPos : Int; + private var _currentPagerStartPos: Int = 0; + private var _currentPagerEndPos: Int = 0; + + private var _requestedPageItems: ArrayList?> = arrayListOf(); + + private var _isRequesting = false; + + constructor(pager: IPager) { + _pager = pager; + val results = _pager.getResults() + for(result in results) + _requestedPageItems.add(CompletableDeferred(result)); + _currentResultPos = 0; + _currentPagerEndPos = results.size; + } + + fun getPager() : IPager = _pager; + + fun hasMoreItems() : Boolean = _currentResultPos < _currentPagerEndPos; + + @Synchronized + fun getCurrentItem(scope: CoroutineScope) : Deferred? { + synchronized(_requestedPageItems) { + if (_currentResultPos >= _requestedPageItems.size) { + val startPos = fillDeferredUntil(_currentResultPos); + if(!_pager.hasMorePages()) { + completeRemainder { it?.complete(null) }; + } + if(_isRequesting) + return _requestedPageItems[_currentResultPos]; + _isRequesting = true; + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + try { + Logger.i("SingleAsyncItemPager", "Started Pager"); + val timeForPage = measureTimeMillis { _pager.nextPage() }; + val newResults = _pager.getResults(); + Logger.i("SingleAsyncItemPager", "Finished Pager (${timeForPage}ms)"); + _currentPagerStartPos = _currentPagerEndPos; + _currentPagerEndPos = _currentPagerStartPos + newResults.size; + synchronized(_requestedPageItems) { + fillDeferredUntil(_currentPagerEndPos) + for (i in newResults.indices) + _requestedPageItems[_currentPagerStartPos + i]!!.complete(newResults[i]); + completeRemainder { + it?.complete(null); + }; + } + } + catch(ex: Throwable) { + Logger.e("SingleAsyncItemPager", "Pager exception", ex); + synchronized(_requestedPageItems) { + fillDeferredUntil(_currentPagerEndPos); + + completeRemainder { + it?.completeExceptionally(ex); + }; + } + } + finally { + synchronized(_requestedPageItems) { + _isRequesting = false; + } + } + } + + return _requestedPageItems[_currentResultPos]; + } + if (_requestedPageItems.size > _currentResultPos) + return _requestedPageItems[_currentResultPos]; + else return null; + } + } + + @Synchronized + fun consumeItem(scope: CoroutineScope) : Deferred? { + val result = getCurrentItem(scope); + _currentResultPos++; + return result; + } + + private fun fillDeferredUntil(i: Int): Int { + val startPos = _requestedPageItems.size; + for(i in _requestedPageItems.size..i) { + _requestedPageItems.add(CompletableDeferred()); + } + return startPos; + } + private fun completeRemainder(completer: (CompletableDeferred?)->Unit) { + synchronized(_requestedPageItems) { + for(i in _currentPagerEndPos until _requestedPageItems.size) + completer(_requestedPageItems[i]); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/SingleItemPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/SingleItemPager.kt new file mode 100644 index 00000000..30b3bd53 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/SingleItemPager.kt @@ -0,0 +1,46 @@ +package com.futo.platformplayer.api.media.structures + +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import kotlinx.coroutines.Deferred + +/** + * SingleItemPagers are used to wrap any IPager and consume items 1 at a time. + * Often used by MultiPagers to add items to a page one at a time from multiple pagers + */ +class SingleItemPager { + private val _pager : IPager; + private var _currentResult : List; + private var _currentResultPos : Int; + + + + + constructor(pager: IPager) { + _pager = pager; + _currentResult = _pager.getResults(); + _currentResultPos = 0; + } + + fun getPager() : IPager = _pager; + + fun hasMoreItems() : Boolean = _currentResultPos < _currentResult.size; + + @Synchronized + fun getCurrentItem() : T? { + if(_currentResultPos >= _currentResult.size) { + _pager.nextPage(); + _currentResult = _pager.getResults(); + _currentResultPos = 0; + } + if(_currentResult.size > _currentResultPos) + return _currentResult[_currentResultPos]; + else return null; + } + + @Synchronized + fun consumeItem() : T? { + val result = getCurrentItem(); + _currentResultPos++; + return result; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/background/BackgroundWorker.kt b/app/src/main/java/com/futo/platformplayer/background/BackgroundWorker.kt new file mode 100644 index 00000000..c98646dd --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/background/BackgroundWorker.kt @@ -0,0 +1,114 @@ +package com.futo.platformplayer.background + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import androidx.concurrent.futures.CallbackToFutureAdapter +import androidx.concurrent.futures.ResolvableFuture +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.ListenableWorker +import androidx.work.WorkerParameters +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.getNowDiffSeconds +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.Subscription +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StateSubscriptions +import com.futo.platformplayer.views.adapters.viewholders.TabViewHolder +import com.google.common.util.concurrent.ListenableFuture +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import java.time.OffsetDateTime + +class BackgroundWorker(private val appContext: Context, workerParams: WorkerParameters) : + CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result { + if(StateApp.instance.isMainActive) { + Logger.i("BackgroundWorker", "CANCELLED"); + return Result.success(); + } + var exception: Throwable? = null; + + StateApp.instance.startBackground(appContext, true, true) { + Logger.i("BackgroundWorker", "STARTED"); + val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager; + val notificationChannel = NotificationChannel("backgroundWork", "Background Work", + NotificationManager.IMPORTANCE_HIGH).apply { + this.enableVibration(false); + this.setSound(null, null); + }; + notificationManager.createNotificationChannel(notificationChannel); + try { + doSubscriptionUpdating(notificationManager, notificationChannel); + } + catch(ex: Throwable) { + exception = ex; + Logger.e("BackgroundWorker", "FAILED: ${ex.message}", ex); + notificationManager.notify(14, NotificationCompat.Builder(appContext, notificationChannel.id) + .setSmallIcon(com.futo.platformplayer.R.drawable.foreground) + .setContentTitle("Grayjay") + .setContentText("Failed subscriptions update\n${ex.message}") + .setChannelId(notificationChannel.id).build()); + } + + } + + return if(exception == null) + Result.success() + else + Result.failure(); + } + + + suspend fun doSubscriptionUpdating(manager: NotificationManager, notificationChannel: NotificationChannel) { + val notif = NotificationCompat.Builder(appContext, notificationChannel.id) + .setSmallIcon(com.futo.platformplayer.R.drawable.foreground) + .setContentTitle("Grayjay") + .setContentText("Updating subscriptions...") + .setChannelId(notificationChannel.id) + .setProgress(1, 0, true); + + manager.notify(12, notif.build()); + + var lastNotifUpdate = OffsetDateTime.now(); + + val newSubChanges = hashSetOf(); + val newItems = mutableListOf(); + withContext(Dispatchers.IO) { + StateSubscriptions.instance.getSubscriptionsFeedWithExceptions(true, false,this, { progress, total -> + Logger.i("BackgroundWorker", "SUBSCRIPTION PROGRESS: ${progress}/${total}"); + + synchronized(manager) { + if (lastNotifUpdate.getNowDiffSeconds() > 1) { + notif.setContentText("Subscriptions (${progress}/${total})"); + notif.setProgress(total, progress, false); + manager.notify(12, notif.build()); + lastNotifUpdate = OffsetDateTime.now(); + } + } + }, { sub, content -> + synchronized(newSubChanges) { + if(!newSubChanges.contains(sub)) + newSubChanges.add(sub); + newItems.add(content); + } + }); + } + + manager.cancel(12); + + if(newItems.size > 0) + manager.notify(13, NotificationCompat.Builder(appContext, notificationChannel.id) + .setSmallIcon(com.futo.platformplayer.R.drawable.foreground) + .setContentTitle("Grayjay") + .setContentText("${newItems.size} new content from ${newSubChanges.size} creators") + .setChannelId(notificationChannel.id).build()); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/builders/DashBuilder.kt b/app/src/main/java/com/futo/platformplayer/builders/DashBuilder.kt new file mode 100644 index 00000000..3e1fcccd --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/builders/DashBuilder.kt @@ -0,0 +1,176 @@ +package com.futo.platformplayer.builders + +import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.models.streams.sources.other.IStreamMetaDataSource +import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource + +class DashBuilder : XMLBuilder { + + constructor(durationS: Long, profile: String) { + writeXmlHeader(); + writeTag("MPD", mapOf( + Pair("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"), + Pair("xmlns", "urn:mpeg:dash:schema:mpd:2011"), + Pair("xsi:schemaLocation", "urn:mpeg:dash:schema:mpd:2011 DASH-MPD.xsd"), + Pair("type", "static"), + Pair("mediaPresentationDuration", "PT${durationS}S"), + Pair("minBufferTime", "PT2S"), + Pair("profiles", profile) + ), false); + + //Temporary...always Period wrapped + writeTag("Period", mapOf(), false); + } + + //AdaptationSets + fun withAdaptationSet(parameters: Map, writeBody: (DashBuilder)->Unit) { + tag("AdaptationSet", parameters, { + writeBody(it as DashBuilder); + }); + } + + //Representation + fun withRepresentation(id: String, parameters: Map, writeBody: (DashBuilder)->Unit) { + val modParas = parameters.toMutableMap(); + modParas.put("id", id); + tag("Representation", modParas) { + writeBody(it as DashBuilder); + }; + } + fun withRepresentationOnDemand(id: String, audioSource: IAudioSource, audioUrl: String) { + if(audioSource !is IStreamMetaDataSource) + throw NotImplementedError("Currently onDemand dash only works with IStreamMetaDataSource"); + if (audioSource.streamMetaData == null) + throw Exception("Stream metadata information missing, the video will need to be redownloaded to be casted") + + withRepresentation(id, mapOf( + Pair("mimeType", audioSource.container), + Pair("codecs", audioSource.codec), + Pair("startWithSAP", "1"), + Pair("bandwidth", "100000") + ) + ) { + it.withSegmentBase( + audioUrl, + audioSource.streamMetaData!!.fileInitStart!!.toLong(), + audioSource.streamMetaData!!.fileInitEnd!!.toLong(), + audioSource.streamMetaData!!.fileIndexStart!!.toLong(), + audioSource.streamMetaData!!.fileIndexEnd!!.toLong() + ) + } + } + fun withRepresentationOnDemand(id: String, videoSource: IVideoSource, videoUrl: String) { + if(videoSource !is IStreamMetaDataSource || videoSource.streamMetaData == null) + throw NotImplementedError("Currently onDemand dash only works with IStreamMetaDataSource"); + if (videoSource.streamMetaData == null) + throw Exception("Stream metadata information missing, the video will need to be redownloaded to be casted") + + withRepresentation(id, mapOf( + Pair("mimeType", videoSource.container), + Pair("codecs", videoSource.codec), + Pair("width", videoSource.width.toString()), + Pair("height", videoSource.height.toString()), + Pair("startWithSAP", "1"), + Pair("bandwidth", "100000") + ) + ) { + it.withSegmentBase( + videoUrl, + videoSource.streamMetaData!!.fileInitStart!!.toLong(), + videoSource.streamMetaData!!.fileInitEnd!!.toLong(), + videoSource.streamMetaData!!.fileIndexStart!!.toLong(), + videoSource.streamMetaData!!.fileIndexEnd!!.toLong() + ) + } + } + + fun withRepresentationOnDemand(id: String, subtitleSource: ISubtitleSource, subtitleUrl: String) { + withRepresentation(id, mapOf( + Pair("mimeType", subtitleSource.format ?: "text/vtt"), + Pair("startWithSAP", "1"), + Pair("bandwidth", "1000") + )) { + it.withBaseURL(subtitleUrl) + } + } + + fun withBaseURL(url: String) { + valueTag("BaseURL", url) + } + + //Segments + fun withSegmentBase(url: String, initStart: Long, initEnd: Long, segStart: Long, segEnd: Long) { + valueTag("BaseURL", url); + + tag("SegmentBase", mapOf(Pair("indexRange", "${segStart}-${segEnd}"))) { + tagClosed("Initialization", Pair("sourceURL", url), Pair("range", "${initStart}-${initEnd}")); + } + } + + + override fun build() : String { + writeCloseTag("Period"); + writeCloseTag("MPD"); + + return super.build(); + } + + + companion object{ + val PROFILE_MAIN = "urn:mpeg:dash:profile:isoff-main:2011"; + val PROFILE_ON_DEMAND = "urn:mpeg:dash:profile:isoff-on-demand:2011"; + + fun generateOnDemandDash(vidSource: IVideoSource?, vidUrl: String?, audioSource: IAudioSource?, audioUrl: String?, subtitleSource: ISubtitleSource?, subtitleUrl: String?) : String { + val duration = vidSource?.duration ?: audioSource?.duration; + if (duration == null) { + throw Exception("Either video or audio source needs to be set."); + } + + val dashBuilder = DashBuilder(duration, PROFILE_ON_DEMAND); + + //Audio + if(audioSource != null && audioUrl != null) { + dashBuilder.withAdaptationSet(mapOf( + Pair("mimeType", audioSource.container), + Pair("codecs", audioSource.codec), + Pair("subsegmentAlignment", "true"), + Pair("subsegmentStartsWithSAP", "1") + )) { + //TODO: Verify if & really should be replaced like this? + it.withRepresentationOnDemand("1", audioSource, audioUrl.replace("&", "&")); + } + } + // Subtitles + if (subtitleSource != null && subtitleUrl != null) { + dashBuilder.withAdaptationSet( + mapOf( + Pair("mimeType", subtitleSource.format ?: "text/vtt"), + Pair("lang", "en"), + Pair("default", "true") + ) + ) { + //TODO: Verify if & really should be replaced like this? + it.withRepresentationOnDemand("1", subtitleSource, subtitleUrl.replace("&", "&")) + } + } + //Video + if (vidSource != null && vidUrl != null) { + dashBuilder.withAdaptationSet( + mapOf( + Pair("mimeType", vidSource.container), + Pair("codecs", vidSource.codec), + Pair("subsegmentAlignment", "true"), + Pair("subsegmentStartsWithSAP", "1") + ) + ) { + it.withRepresentationOnDemand("1", vidSource, vidUrl.replace("&", "&")); + } + } + + return dashBuilder.build(); + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/builders/HlsBuilder.kt b/app/src/main/java/com/futo/platformplayer/builders/HlsBuilder.kt new file mode 100644 index 00000000..2744e8a0 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/builders/HlsBuilder.kt @@ -0,0 +1,37 @@ +package com.futo.platformplayer.builders + +import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource +import java.io.PrintWriter +import java.io.StringWriter + +class HlsBuilder { + companion object{ + fun generateOnDemandHLS(vidSource: IVideoSource, vidUrl: String, audioSource: IAudioSource?, audioUrl: String?, subtitleSource: ISubtitleSource?, subtitleUrl: String?): String { + val hlsBuilder = StringWriter() + PrintWriter(hlsBuilder).use { writer -> + writer.println("#EXTM3U") + + // Audio + if (audioSource != null && audioUrl != null) { + val audioFormat = audioSource.container.substringAfter("/") + writer.println("#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audio\",LANGUAGE=\"en\",NAME=\"English\",AUTOSELECT=YES,DEFAULT=YES,URI=\"${audioUrl.replace("&", "&")}\",FORMAT=\"$audioFormat\"") + } + + // Subtitles + if (subtitleSource != null && subtitleUrl != null) { + val subtitleFormat = subtitleSource.format ?: "text/vtt" + writer.println("#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",LANGUAGE=\"en\",NAME=\"English\",AUTOSELECT=YES,DEFAULT=YES,URI=\"${subtitleUrl.replace("&", "&")}\",FORMAT=\"$subtitleFormat\"") + } + + // Video + val videoFormat = vidSource.container.substringAfter("/") + writer.println("#EXT-X-STREAM-INF:BANDWIDTH=100000,CODECS=\"${vidSource.codec}\",RESOLUTION=${vidSource.width}x${vidSource.height}${if (audioSource != null) ",AUDIO=\"audio\"" else ""}${if (subtitleSource != null) ",SUBTITLES=\"subs\"" else ""},FORMAT=\"$videoFormat\"") + writer.println(vidUrl.replace("&", "&")) + } + + return hlsBuilder.toString() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/builders/XMLBuilder.kt b/app/src/main/java/com/futo/platformplayer/builders/XMLBuilder.kt new file mode 100644 index 00000000..16dc4170 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/builders/XMLBuilder.kt @@ -0,0 +1,59 @@ +package com.futo.platformplayer.builders + +import java.io.StringWriter + +open class XMLBuilder { + protected val writer = StringWriter(); + private var _indentation = 0; + + fun writeXmlHeader(version: String = "1.0", encoding: String = "UTF-8") { + writer.write("\n"); + } + fun tagClosed(tagName: String, vararg parameters: Pair) { tagClosed(tagName, parameters.toMap()) } + fun tagClosed(tagName: String, parameters: Map) { + writeTag(tagName, parameters, true); + } + fun tag(tagName: String, parameters: Map, fill: (XMLBuilder)->Unit) { + writeTag(tagName, parameters, false); + fill(this); + writeCloseTag(tagName); + } + fun valueTag(tagName: String, value: String){ valueTag(tagName, mapOf(), value); } + fun valueTag(tagName: String, parameters: Map, value: String) { + writeTag(tagName, parameters, false, false); + writer.write(value); + writeCloseTag(tagName, false); + } + fun value(value: String) { + writeIndentation(_indentation); + writer.write(value + "\n"); + } + + protected fun writeTag(tagName: String, parameters: Map = mapOf(), closed: Boolean = true, withNewLine: Boolean = true) { + writeIndentation(_indentation) + writer.write("<${tagName}"); + for(parameter in parameters) + writer.write(" ${parameter.key}=\"${parameter.value}\""); + + if(closed) + writer.write("/>"); + else { + writer.write(">"); + _indentation++; + } + if(withNewLine) + writer.write("\n"); + } + protected fun writeCloseTag(tagName: String, withIndentation: Boolean = true) { + _indentation--; + if(withIndentation) + writeIndentation(_indentation); + writer.write("\n"); + } + protected fun writeIndentation(level: Int) { + writer.write("".padStart(level * 3, ' ')); + } + open fun build() : String { + return writer.toString(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/cache/ChannelContentCache.kt b/app/src/main/java/com/futo/platformplayer/cache/ChannelContentCache.kt new file mode 100644 index 00000000..d25333c9 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/cache/ChannelContentCache.kt @@ -0,0 +1,153 @@ +package com.futo.platformplayer.cache + +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent +import com.futo.platformplayer.api.media.structures.DedupContentPager +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.api.media.structures.PlatformContentPager +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.polycentric.PolycentricCache +import com.futo.platformplayer.resolveChannelUrl +import com.futo.platformplayer.serializers.PlatformContentSerializer +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StateSubscriptions +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.toSafeFileName +import com.futo.polycentric.core.toUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class ChannelContentCache { + val _channelCacheDir = FragmentedStorage.getOrCreateDirectory("channelCache"); + val _channelContents = HashMap(_channelCacheDir.listFiles() + .filter { it.isDirectory } + .associate { Pair(it.name, FragmentedStorage.storeJson(_channelCacheDir, it.name, PlatformContentSerializer()) + .withoutBackup() + .load()) }); + + fun getChannelCachePager(channelUrl: String): PlatformContentPager { + val validID = channelUrl.toSafeFileName(); + + val validStores = _channelContents + .filter { it.key == validID } + .map { it.value }; + + val items = validStores.flatMap { it.getItems() } + .sortedByDescending { it.datetime }; + return PlatformContentPager(items, Math.min(150, items.size)); + } + fun getSubscriptionCachePager(): DedupContentPager { + val subs = StateSubscriptions.instance.getSubscriptions(); + val allUrls = subs.map { + val otherUrls = PolycentricCache.instance.getCachedProfile(it.channel.url)?.profile?.ownedClaims?.mapNotNull { c -> c.claim.resolveChannelUrl() } ?: listOf(); + if(!otherUrls.contains(it.channel.url)) + return@map listOf(listOf(it.channel.url), otherUrls).flatten(); + else + return@map otherUrls; + }.flatten().distinct(); + val validSubIds = allUrls.map { it.toSafeFileName() }.toHashSet(); + + val validStores = _channelContents + .filter { validSubIds.contains(it.key) } + .map { it.value }; + + val items = validStores.flatMap { it.getItems() } + .sortedByDescending { it.datetime }; + + return DedupContentPager(PlatformContentPager(items, Math.min(150, items.size)), StatePlatform.instance.getEnabledClients().map { it.id }); + } + + fun cacheVideos(contents: List): List { + return contents.filter { cacheContent(it) }; + } + fun cacheContent(content: IPlatformContent, doUpdate: Boolean = false): Boolean { + if(content.author.url.isEmpty()) + return false; + + val channelId = content.author.url.toSafeFileName(); + val store = synchronized(_channelContents) { + var channelStore = _channelContents.get(channelId); + if(channelStore == null) { + Logger.i(TAG, "New Subscription Cache for channel ${content.author.name}"); + channelStore = FragmentedStorage.storeJson(_channelCacheDir, channelId, PlatformContentSerializer()).load(); + _channelContents.put(channelId, channelStore); + } + return@synchronized channelStore; + } + val serialized = SerializedPlatformContent.fromContent(content); + val existing = store.findItems { it.url == content.url }; + + if(existing.isEmpty() || doUpdate) { + if(existing.isNotEmpty()) + existing.forEach { store.delete(it) }; + + store.save(serialized); + } + + return existing.isEmpty(); + } + + companion object { + private val TAG = "ChannelCache"; + + private val _lock = Object(); + private var _instance: ChannelContentCache? = null; + val instance: ChannelContentCache get() { + synchronized(_lock) { + if(_instance == null) + _instance = ChannelContentCache(); + return _instance!!; + } + } + + fun cachePagerResults(scope: CoroutineScope, pager: IPager, onNewCacheHit: ((IPlatformContent)->Unit)? = null): IPager { + return ChannelVideoCachePager(pager, scope, onNewCacheHit); + } + } + + class ChannelVideoCachePager(val pager: IPager, private val scope: CoroutineScope, private val onNewCacheItem: ((IPlatformContent)->Unit)? = null): IPager { + + init { + val results = pager.getResults(); + + Logger.i(TAG, "Caching ${results.size} subscription initial results"); + scope.launch(Dispatchers.IO) { + try { + val newCacheItems = instance.cacheVideos(results); + if(onNewCacheItem != null) + newCacheItems.forEach { onNewCacheItem!!(it) } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to cache videos.", e); + } + } + } + + override fun hasMorePages(): Boolean { + return pager.hasMorePages(); + } + + override fun nextPage() { + pager.nextPage(); + val results = pager.getResults(); + + Logger.i(TAG, "Caching ${results.size} subscription results"); + scope.launch(Dispatchers.IO) { + try { + val newCacheItems = instance.cacheVideos(results); + if(onNewCacheItem != null) + newCacheItems.forEach { onNewCacheItem!!(it) } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to cache videos.", e); + } + } + } + + override fun getResults(): List { + val results = pager.getResults(); + + return results; + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt new file mode 100644 index 00000000..e8a8a573 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt @@ -0,0 +1,305 @@ +package com.futo.platformplayer.casting + +import android.os.Looper +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.casting.models.FastCastSetVolumeMessage +import com.futo.platformplayer.getConnectedSocket +import com.futo.platformplayer.models.CastingDeviceInfo +import com.futo.platformplayer.toInetAddress +import kotlinx.coroutines.* +import java.net.InetAddress +import java.util.UUID + +class AirPlayCastingDevice : CastingDevice { + //See for more info: https://nto.github.io/AirPlay + + override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY; + override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0; + override var usedRemoteAddress: InetAddress? = null; + override var localAddress: InetAddress? = null; + override val canSetVolume: Boolean get() = false; + + var addresses: Array? = null; + var port: Int = 0; + + private var _scopeIO: CoroutineScope? = null; + private var _started: Boolean = false; + private var _sessionId: String? = null; + private val _client = ManagedHttpClient(); + + constructor(name: String, addresses: Array, port: Int) : super() { + this.name = name; + this.addresses = addresses; + this.port = port; + } + + constructor(deviceInfo: CastingDeviceInfo) : super() { + this.name = deviceInfo.name; + this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray(); + this.port = deviceInfo.port; + } + + override fun getAddresses(): List { + return addresses?.toList() ?: listOf(); + } + + override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double) { + if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration) })) { + return; + } + + Logger.i(FastCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)"); + + time = resumePosition; + if (resumePosition > 0.0) { + val pos = resumePosition / duration; + Logger.i(TAG, "resumePosition: $resumePosition, duration: ${duration}, pos: $pos") + post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: $pos"); + } else { + post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: 0"); + } + } + + override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double) { + throw NotImplementedError(); + } + + override fun seekVideo(timeSeconds: Double) { + if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) { + return; + } + + post("scrub?position=${timeSeconds}"); + } + + override fun resumeVideo() { + if (invokeInIOScopeIfRequired(::resumeVideo)) { + return; + } + + isPlaying = true; + post("rate?value=1.000000"); + } + + override fun pauseVideo() { + if (invokeInIOScopeIfRequired(::pauseVideo)) { + return; + } + + isPlaying = false; + post("rate?value=0.000000"); + } + + override fun stopVideo() { + if (invokeInIOScopeIfRequired(::stopVideo)) { + return; + } + + post("stop"); + } + + override fun stopCasting() { + if (invokeInIOScopeIfRequired(::stopCasting)) { + return; + } + + post("stop"); + stop(); + } + + override fun start() { + val adrs = addresses ?: return; + if (_started) { + return; + } + + _started = true; + _scopeIO?.cancel(); + _scopeIO = CoroutineScope(Dispatchers.IO); + + Logger.i(TAG, "Starting..."); + + _scopeIO?.launch { + try { + connectionState = CastConnectionState.CONNECTING; + + while (_scopeIO?.isActive == true) { + try { + val connectedSocket = getConnectedSocket(adrs.toList(), port); + if (connectedSocket == null) { + delay(3000); + continue; + } + + usedRemoteAddress = connectedSocket.inetAddress; + localAddress = connectedSocket.localAddress; + connectedSocket.close(); + _sessionId = UUID.randomUUID().toString(); + break; + } catch (e: Throwable) { + Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e) + } + } + + while (_scopeIO?.isActive == true) { + try { + val progressInfo = getProgress(); + if (progressInfo == null) { + connectionState = CastConnectionState.CONNECTING; + Logger.i(TAG, "Failed to retrieve progress from AirPlay device."); + delay(1000); + continue; + } + + connectionState = CastConnectionState.CONNECTED; + delay(1000); + + val progressIndex = progressInfo.lowercase().indexOf("position: "); + if (progressIndex == -1) { + continue; + } + + val progress = progressInfo.substring(progressIndex + "position: ".length).toDoubleOrNull() ?: continue; + + time = progress; + } catch (e: Throwable) { + Logger.w(TAG, "Failed to get server info from AirPlay device.", e) + } + } + } catch (e: Throwable) { + Logger.w(TAG, "Failed to setup AirPlay device connection.", e) + } + }; + + Logger.i(TAG, "Started."); + } + + override fun stop() { + Logger.i(TAG, "Stopping..."); + connectionState = CastConnectionState.DISCONNECTED; + + usedRemoteAddress = null; + localAddress = null; + _started = false; + _scopeIO?.cancel(); + _scopeIO = null; + } + + override fun getDeviceInfo(): CastingDeviceInfo { + return CastingDeviceInfo(name!!, CastProtocolType.AIRPLAY, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port); + } + + private fun getProgress(): String? { + val info = get("scrub"); + Logger.i(TAG, "Progress: ${info ?: "null"}"); + return info; + } + + private fun getPlaybackInfo(): String? { + val playbackInfo = get("playback-info"); + Logger.i(TAG, "Playback info: ${playbackInfo ?: "null"}"); + return playbackInfo; + } + + private fun getServerInfo(): String? { + val serverInfo = get("server-info"); + Logger.i(TAG, "Server info: ${serverInfo ?: "null"}"); + return serverInfo; + } + + private fun post(path: String): Boolean { + try { + val sessionId = _sessionId ?: return false; + + val headers = hashMapOf( + "X-Apple-Device-ID" to "0xdc2b61a0ce79", + "User-Agent" to "MediaControl/1.0", + "Content-Length" to "0", + "X-Apple-Session-ID" to sessionId + ); + + val url = "http://${usedRemoteAddress}:${port}/${path}"; + + Logger.i(TAG, "POST $url"); + val response = _client.post(url, headers); + if (!response.isOk) { + return false; + } + + return true; + } catch (e: Throwable) { + Logger.w(TAG, "Failed to POST $path"); + return false; + } + } + + private fun post(path: String, contentType: String, body: String): Boolean { + try { + val sessionId = _sessionId ?: return false; + + val headers = hashMapOf( + "X-Apple-Device-ID" to "0xdc2b61a0ce79", + "User-Agent" to "MediaControl/1.0", + "X-Apple-Session-ID" to sessionId, + "Content-Type" to contentType + ); + + val url = "http://${usedRemoteAddress}:${port}/${path}"; + + Logger.i(TAG, "POST $url:\n$body"); + val response = _client.post(url, body, headers); + if (!response.isOk) { + return false; + } + + return true; + } catch (e: Throwable) { + Logger.w(TAG, "Failed to POST $path $body"); + return false; + } + } + + private fun get(path: String): String? { + val sessionId = _sessionId ?: return null; + + try { + val headers = hashMapOf( + "X-Apple-Device-ID" to "0xdc2b61a0ce79", + "Content-Length" to "0", + "User-Agent" to "MediaControl/1.0", + "X-Apple-Session-ID" to sessionId + ); + + val url = "http://${usedRemoteAddress}:${port}/${path}"; + + Logger.i(TAG, "GET $url"); + val response = _client.get(url, headers); + if (!response.isOk) { + return null; + } + + if (response.body == null) { + return null; + } + + return response.body.string(); + } catch (e: Throwable) { + Logger.w(TAG, "Failed to GET $path"); + return null; + } + } + + private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean { + if(Looper.getMainLooper().thread == Thread.currentThread()) { + _scopeIO?.launch { action(); } + return true; + } + + return false; + } + + companion object { + val TAG = "AirPlayCastingDevice"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt new file mode 100644 index 00000000..66a655be --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt @@ -0,0 +1,94 @@ +package com.futo.platformplayer.casting + +import android.content.Context +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.getNowDiffMiliseconds +import com.futo.platformplayer.models.CastingDeviceInfo +import java.net.InetAddress +import java.time.OffsetDateTime + +enum class CastConnectionState { + DISCONNECTED, + CONNECTING, + CONNECTED +} + +enum class CastProtocolType { + CHROMECAST, + AIRPLAY, + FASTCAST +} + +abstract class CastingDevice { + abstract val protocol: CastProtocolType; + abstract val isReady: Boolean; + abstract var usedRemoteAddress: InetAddress?; + abstract var localAddress: InetAddress?; + abstract val canSetVolume: Boolean; + + var name: String? = null; + var isPlaying: Boolean = false + set(value) { + val changed = value != field; + field = value; + if (changed) { + onPlayChanged.emit(value); + } + }; + var timeReceivedAt: OffsetDateTime = OffsetDateTime.now() + private set; + var time: Double = 0.0 + set(value) { + val changed = value != field; + field = value; + if (changed) { + timeReceivedAt = OffsetDateTime.now(); + onTimeChanged.emit(value); + } + }; + var volume: Double = 1.0 + set(value) { + val changed = value != field; + field = value; + if (changed) { + onVolumeChanged.emit(value); + } + }; + val expectedCurrentTime: Double + get() { + val diff = timeReceivedAt.getNowDiffMiliseconds().toDouble() / 1000.0; + return time + diff; + }; + var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED + set(value) { + val changed = value != field; + field = value; + + if (changed) { + onConnectionStateChanged.emit(value); + } + }; + + var onConnectionStateChanged = Event1(); + var onPlayChanged = Event1(); + var onTimeChanged = Event1(); + var onVolumeChanged = Event1(); + + abstract fun stopCasting(); + + abstract fun seekVideo(timeSeconds: Double); + abstract fun stopVideo(); + abstract fun pauseVideo(); + abstract fun resumeVideo(); + abstract fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double); + abstract fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double); + open fun changeVolume(volume: Double) { throw NotImplementedError() } + + abstract fun start(); + abstract fun stop(); + + abstract fun getDeviceInfo(): CastingDeviceInfo; + + abstract fun getAddresses(): List; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt new file mode 100644 index 00000000..69b447e1 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt @@ -0,0 +1,606 @@ +package com.futo.platformplayer.casting + +import android.os.Looper +import android.util.Log +import com.futo.platformplayer.casting.models.FastCastSetVolumeMessage +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.getConnectedSocket +import com.futo.platformplayer.models.CastingDeviceInfo +import com.futo.platformplayer.protos.DeviceAuthMessageOuterClass +import com.futo.platformplayer.toHexString +import com.futo.platformplayer.toInetAddress +import kotlinx.coroutines.* +import org.json.JSONObject +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.IOException +import java.net.InetAddress +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocket +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + +class ChromecastCastingDevice : CastingDevice { + //See for more info: https://developers.google.com/cast/docs/media/messages + + override val protocol: CastProtocolType get() = CastProtocolType.CHROMECAST; + override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0; + override var usedRemoteAddress: InetAddress? = null; + override var localAddress: InetAddress? = null; + override val canSetVolume: Boolean get() = true; + + var addresses: Array? = null; + var port: Int = 0; + + private var _streamType: String? = null; + private var _contentType: String? = null; + private var _contentId: String? = null; + + private var _socket: SSLSocket? = null; + private var _outputStream: DataOutputStream? = null; + private var _inputStream: DataInputStream? = null; + private var _scopeIO: CoroutineScope? = null; + private var _requestId = 1; + private var _started: Boolean = false; + private var _sessionId: String? = null; + private var _transportId: String? = null; + private var _launching = false; + private var _mediaSessionId: Int? = null; + + constructor(name: String, addresses: Array, port: Int) : super() { + this.name = name; + this.addresses = addresses; + this.port = port; + } + + constructor(deviceInfo: CastingDeviceInfo) : super() { + this.name = deviceInfo.name; + this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray(); + this.port = deviceInfo.port; + } + + override fun getAddresses(): List { + return addresses?.toList() ?: listOf(); + } + + override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double) { + if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration) })) { + return; + } + + Logger.i(FastCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)"); + + time = resumePosition; + _streamType = streamType; + _contentType = contentType; + _contentId = contentId; + + playVideo(); + } + + override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double) { + //TODO: Can maybe be implemented by sending data:contentType,base64... + throw NotImplementedError(); + } + + private fun connectMediaChannel(transportId: String) { + val connectObject = JSONObject(); + connectObject.put("type", "CONNECT"); + connectObject.put("connType", 0); + sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.tp.connection", connectObject.toString()); + } + + private fun requestMediaStatus() { + val transportId = _transportId ?: return; + + val loadObject = JSONObject(); + loadObject.put("type", "GET_STATUS"); + loadObject.put("requestId", _requestId++); + sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString()); + } + + private fun playVideo() { + val transportId = _transportId ?: return; + val contentId = _contentId ?: return; + val streamType = _streamType ?: return; + val contentType = _contentType ?: return; + + val loadObject = JSONObject(); + loadObject.put("type", "LOAD"); + + val mediaObject = JSONObject(); + mediaObject.put("contentId", contentId); + mediaObject.put("streamType", streamType); + mediaObject.put("contentType", contentType); + + if (time > 0.0) { + val seekTime = time; + loadObject.put("currentTime", seekTime); + } + + loadObject.put("media", mediaObject); + loadObject.put("requestId", _requestId++); + + + //TODO: This replace is necessary to get rid of backward slashes added by the JSON Object serializer + val json = loadObject.toString().replace("\\/","/"); + sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", json); + } + + override fun changeVolume(volume: Double) { + if (invokeInIOScopeIfRequired({ changeVolume(volume) })) { + return; + } + + this.volume = volume + val setVolumeObject = JSONObject(); + setVolumeObject.put("type", "SET_VOLUME"); + + val volumeObject = JSONObject(); + volumeObject.put("level", volume) + setVolumeObject.put("volume", volumeObject); + + setVolumeObject.put("requestId", _requestId++); + sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", setVolumeObject.toString()); + } + + override fun seekVideo(timeSeconds: Double) { + if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) { + return; + } + + val transportId = _transportId ?: return; + val mediaSessionId = _mediaSessionId ?: return; + + val loadObject = JSONObject(); + loadObject.put("type", "SEEK"); + loadObject.put("mediaSessionId", mediaSessionId); + loadObject.put("requestId", _requestId++); + loadObject.put("currentTime", timeSeconds); + sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString()); + } + + override fun resumeVideo() { + if (invokeInIOScopeIfRequired(::resumeVideo)) { + return; + } + + val transportId = _transportId ?: return; + val mediaSessionId = _mediaSessionId ?: return; + + val loadObject = JSONObject(); + loadObject.put("type", "PLAY"); + loadObject.put("mediaSessionId", mediaSessionId); + loadObject.put("requestId", _requestId++); + sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString()); + } + + override fun pauseVideo() { + if (invokeInIOScopeIfRequired(::pauseVideo)) { + return; + } + + val transportId = _transportId ?: return; + val mediaSessionId = _mediaSessionId ?: return; + + val loadObject = JSONObject(); + loadObject.put("type", "PAUSE"); + loadObject.put("mediaSessionId", mediaSessionId); + loadObject.put("requestId", _requestId++); + sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString()); + } + + override fun stopVideo() { + if (invokeInIOScopeIfRequired(::stopVideo)) { + return; + } + + val transportId = _transportId ?: return; + val mediaSessionId = _mediaSessionId ?: return; + _contentId = null; + _contentType = null; + _streamType = null; + + val loadObject = JSONObject(); + loadObject.put("type", "STOP"); + loadObject.put("mediaSessionId", mediaSessionId); + loadObject.put("requestId", _requestId++); + sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString()); + } + + private fun launchPlayer() { + if (invokeInIOScopeIfRequired(::launchPlayer)) { + return; + } + + val launchObject = JSONObject(); + launchObject.put("type", "LAUNCH"); + launchObject.put("appId", "CC1AD845"); + launchObject.put("requestId", _requestId++); + sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString()); + } + + private fun getStatus() { + if (invokeInIOScopeIfRequired(::getStatus)) { + return; + } + + val launchObject = JSONObject(); + launchObject.put("type", "GET_STATUS"); + launchObject.put("requestId", _requestId++); + sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString()); + } + + private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean { + if(Looper.getMainLooper().thread == Thread.currentThread()) { + _scopeIO?.launch { action(); } + return true; + } + + return false; + } + + override fun stopCasting() { + if (invokeInIOScopeIfRequired(::stopCasting)) { + return; + } + + val sessionId = _sessionId; + if (sessionId != null) { + val launchObject = JSONObject(); + launchObject.put("type", "STOP"); + launchObject.put("sessionId", sessionId); + launchObject.put("requestId", _requestId++); + sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString()); + + _contentId = null; + _contentType = null; + _streamType = null; + _sessionId = null; + _transportId = null; + } + + Logger.i(TAG, "Stopping active device because stopCasting was called.") + stop(); + } + + override fun start() { + val adrs = addresses ?: return; + if (_started) { + return; + } + + _started = true; + _sessionId = null; + _mediaSessionId = null; + + Logger.i(TAG, "Starting..."); + + _launching = true; + + _scopeIO?.cancel(); + Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.") + _scopeIO = CoroutineScope(Dispatchers.IO); + + Thread { + connectionState = CastConnectionState.CONNECTING; + + while (_scopeIO?.isActive == true) { + try { + val connectedSocket = getConnectedSocket(adrs.toList(), port); + if (connectedSocket == null) { + Thread.sleep(3000); + continue; + } + + usedRemoteAddress = connectedSocket.inetAddress; + localAddress = connectedSocket.localAddress; + connectedSocket.close(); + break; + } catch (e: Throwable) { + Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e) + } + } + + val sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, trustAllCerts, null); + + val factory = sslContext.socketFactory; + + //Connection loop + while (_scopeIO?.isActive == true) { + Logger.i(TAG, "Connecting to Chromecast."); + connectionState = CastConnectionState.CONNECTING; + + try { + _socket = factory.createSocket(usedRemoteAddress, port) as SSLSocket; + _socket?.startHandshake(); + Logger.i(TAG, "Successfully connected to Chromecast at $usedRemoteAddress:$port"); + + try { + _outputStream = DataOutputStream(_socket?.outputStream); + _inputStream = DataInputStream(_socket?.inputStream); + } catch (e: Throwable) { + Logger.i(TAG, "Failed to authenticate to Chromecast.", e); + } + } catch (e: IOException) { + _socket?.close(); + Logger.i(TAG, "Failed to connect to Chromecast.", e); + + connectionState = CastConnectionState.CONNECTING; + Thread.sleep(3000); + continue; + } + + localAddress = _socket?.localAddress; + + try { + val connectObject = JSONObject(); + connectObject.put("type", "CONNECT"); + connectObject.put("connType", 0); + sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.connection", connectObject.toString()); + } catch (e: Throwable) { + Logger.i(TAG, "Failed to send connect message to Chromecast.", e); + _socket?.close(); + + connectionState = CastConnectionState.CONNECTING; + Thread.sleep(3000); + continue; + } + + getStatus(); + + val buffer = ByteArray(4096); + + Logger.i(TAG, "Started receiving."); + while (_scopeIO?.isActive == true) { + try { + val inputStream = _inputStream ?: break; + Log.d(TAG, "Receiving next packet..."); + val b1 = inputStream.readUnsignedByte(); + val b2 = inputStream.readUnsignedByte(); + val b3 = inputStream.readUnsignedByte(); + val b4 = inputStream.readUnsignedByte(); + val size = ((b1.toLong() shl 24) or (b2.toLong() shl 16) or (b3.toLong() shl 8) or b4.toLong()).toInt(); + if (size > buffer.size) { + Logger.w(TAG, "Skipping packet that is too large $size bytes.") + inputStream.skip(size.toLong()); + continue; + } + + Log.d(TAG, "Received header indicating $size bytes. Waiting for message."); + inputStream.read(buffer, 0, size); + + //TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end? + val messageBytes = buffer.sliceArray(IntRange(0, size - 1)); + Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}."); + val message = DeviceAuthMessageOuterClass.CastMessage.parseFrom(messageBytes); + if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") { + Logger.i(TAG, "Received message: $message"); + } + + try { + handleMessage(message); + } catch (e:Throwable) { + Logger.w(TAG, "Failed to handle message.", e); + } + } catch (e: java.net.SocketException) { + Logger.e(TAG, "Socket exception while receiving.", e); + break; + } catch (e: Throwable) { + Logger.e(TAG, "Exception while receiving.", e); + break; + } + } + _socket?.close(); + Logger.i(TAG, "Socket disconnected."); + + connectionState = CastConnectionState.CONNECTING; + Thread.sleep(3000); + } + + Logger.i(TAG, "Stopped connection loop."); + connectionState = CastConnectionState.DISCONNECTED; + }.start(); + + //Start ping loop + Thread { + Logger.i(TAG, "Started ping loop.") + + val pingObject = JSONObject(); + pingObject.put("type", "PING"); + + while (_scopeIO?.isActive == true) { + try { + sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.heartbeat", pingObject.toString()); + Thread.sleep(5000); + } catch (e: Throwable) { + + } + } + + Logger.i(TAG, "Stopped ping loop."); + }.start(); + + Logger.i(TAG, "Started."); + } + + private fun sendChannelMessage(sourceId: String, destinationId: String, namespace: String, json: String) { + try { + val castMessage = DeviceAuthMessageOuterClass.CastMessage.newBuilder() + .setProtocolVersion(DeviceAuthMessageOuterClass.CastMessage.ProtocolVersion.CASTV2_1_0) + .setSourceId(sourceId) + .setDestinationId(destinationId) + .setNamespace(namespace) + .setPayloadType(DeviceAuthMessageOuterClass.CastMessage.PayloadType.STRING) + .setPayloadUtf8(json) + .build(); + + sendMessage(castMessage.toByteArray()); + + if (namespace != "urn:x-cast:com.google.cast.tp.heartbeat") { + //Log.d(TAG, "Sent channel message: $castMessage"); + } + } catch (e: Throwable) { + Logger.w(TAG, "Failed to send channel message (sourceId: $sourceId, destinationId: $destinationId, namespace: $namespace, json: $json)", e); + } + } + + private fun handleMessage(message: DeviceAuthMessageOuterClass.CastMessage) { + if (message.payloadType == DeviceAuthMessageOuterClass.CastMessage.PayloadType.STRING) { + val jsonObject = JSONObject(message.payloadUtf8); + val type = jsonObject.getString("type"); + if (type == "RECEIVER_STATUS") { + val status = jsonObject.getJSONObject("status"); + + var sessionIsRunning = false; + if (status.has("applications")) { + val applications = status.getJSONArray("applications"); + + for (i in 0 until applications.length()) { + val applicationUpdate = applications.getJSONObject(i); + + val appId = applicationUpdate.getString("appId"); + Logger.i(TAG, "Status update received appId (appId: $appId)"); + + if (appId == "CC1AD845") { + sessionIsRunning = true; + + if (_sessionId == null) { + connectionState = CastConnectionState.CONNECTED; + _sessionId = applicationUpdate.getString("sessionId"); + + val transportId = applicationUpdate.getString("transportId"); + connectMediaChannel(transportId); + Logger.i(TAG, "Connected to media channel $transportId"); + _transportId = transportId; + + requestMediaStatus(); + playVideo(); + } + } + } + } + + if (!sessionIsRunning) { + _sessionId = null; + _mediaSessionId = null; + time = 0.0; + _transportId = null; + Logger.w(TAG, "Session not found."); + + if (_launching) { + Logger.i(TAG, "Player not found, launching."); + launchPlayer(); + } else { + Logger.i(TAG, "Player not found, disconnecting."); + stop(); + } + } else { + _launching = false; + } + + val volume = status.getJSONObject("volume"); + val volumeControlType = volume.getString("controlType"); + val volumeLevel = volume.getString("level").toDouble(); + val volumeMuted = volume.getBoolean("muted"); + val volumeStepInterval = volume.getString("stepInterval").toFloat(); + this.volume = if (volumeMuted) 0.0 else volumeLevel; + + Logger.i(TAG, "Status update received volume (level: $volumeLevel, muted: $volumeMuted)"); + } else if (type == "MEDIA_STATUS") { + val statuses = jsonObject.getJSONArray("status"); + for (i in 0 until statuses.length()) { + val status = statuses.getJSONObject(i); + _mediaSessionId = status.getInt("mediaSessionId"); + + val playerState = status.getString("playerState"); + val currentTime = status.getDouble("currentTime"); + + isPlaying = playerState == "PLAYING"; + if (isPlaying) { + time = currentTime; + } + + val playbackRate = status.getInt("playbackRate"); + Logger.i(TAG, "Media update received (mediaSessionId: $_mediaSessionId, playedState: $playerState, currentTime: $currentTime, playbackRate: $playbackRate)"); + + if (_contentType == null) { + stopVideo(); + } + } + } else if (type == "CLOSE") { + if (message.sourceId == "receiver-0") { + Logger.i(TAG, "Close received."); + stop(); + } + } + } else { + throw Exception("Payload type ${message.payloadType} is not implemented."); + } + } + + private fun sendMessage(data: ByteArray) { + val outputStream = _outputStream; + if (outputStream == null) { + Logger.w(TAG, "Failed to send ${data.size} bytes, output stream is null."); + return; + } + + val serializedSizeBE = ByteArray(4); + serializedSizeBE[0] = (data.size shr 24 and 0xff).toByte(); + serializedSizeBE[1] = (data.size shr 16 and 0xff).toByte(); + serializedSizeBE[2] = (data.size shr 8 and 0xff).toByte(); + serializedSizeBE[3] = (data.size and 0xff).toByte(); + outputStream.write(serializedSizeBE); + outputStream.write(data); + + //Log.d(TAG, "Sent ${data.size} bytes."); + } + + override fun stop() { + Logger.i(TAG, "Stopping..."); + usedRemoteAddress = null; + localAddress = null; + _started = false; + + val socket = _socket; + val scopeIO = _scopeIO; + + if (scopeIO != null && socket != null) { + Logger.i(TAG, "Cancelling scopeIO with open socket.") + + scopeIO.launch { + socket.close(); + connectionState = CastConnectionState.DISCONNECTED; + scopeIO.cancel(); + Logger.i(TAG, "Cancelled scopeIO with open socket.") + } + } else { + scopeIO?.cancel(); + Logger.i(TAG, "Cancelled scopeIO without open socket.") + } + + _scopeIO = null; + _socket = null; + _outputStream = null; + _inputStream = null; + _mediaSessionId = null; + connectionState = CastConnectionState.DISCONNECTED; + } + + override fun getDeviceInfo(): CastingDeviceInfo { + return CastingDeviceInfo(name!!, CastProtocolType.CHROMECAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port); + } + + companion object { + val TAG = "ChromecastCastingDevice"; + + val trustAllCerts: Array = arrayOf(object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) { } + override fun checkServerTrusted(chain: Array?, authType: String?) { } + override fun getAcceptedIssuers(): Array { return emptyArray(); } + }); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/FastCastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/FastCastCastingDevice.kt new file mode 100644 index 00000000..da4f8fbf --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/casting/FastCastCastingDevice.kt @@ -0,0 +1,407 @@ +package com.futo.platformplayer.casting + +import android.os.Looper +import android.util.Log +import com.futo.platformplayer.casting.models.* +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.getConnectedSocket +import com.futo.platformplayer.models.CastingDeviceInfo +import com.futo.platformplayer.toHexString +import com.futo.platformplayer.toInetAddress +import kotlinx.coroutines.* +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.IOException +import java.net.InetAddress +import java.net.Socket + +enum class Opcode(val value: Byte) { + NONE(0), + PLAY(1), + PAUSE(2), + RESUME(3), + STOP(4), + SEEK(5), + PLAYBACK_UPDATE(6), + VOLUME_UPDATE(7), + SET_VOLUME(8) +} + +class FastCastCastingDevice : CastingDevice { + //See for more info: TODO + + override val protocol: CastProtocolType get() = CastProtocolType.FASTCAST; + override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0; + override var usedRemoteAddress: InetAddress? = null; + override var localAddress: InetAddress? = null; + override val canSetVolume: Boolean get() = true; + + var addresses: Array? = null; + var port: Int = 0; + + private var _socket: Socket? = null; + private var _outputStream: DataOutputStream? = null; + private var _inputStream: DataInputStream? = null; + private var _scopeIO: CoroutineScope? = null; + private var _started: Boolean = false; + + constructor(name: String, addresses: Array, port: Int) : super() { + this.name = name; + this.addresses = addresses; + this.port = port; + } + + constructor(deviceInfo: CastingDeviceInfo) : super() { + this.name = deviceInfo.name; + this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray(); + this.port = deviceInfo.port; + } + + override fun getAddresses(): List { + return addresses?.toList() ?: listOf(); + } + + override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double) { + if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration) })) { + return; + } + + Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)"); + + time = resumePosition; + sendMessage(Opcode.PLAY, FastCastPlayMessage( + container = contentType, + url = contentId, + time = resumePosition.toInt() + )); + } + + override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double) { + if (invokeInIOScopeIfRequired({ loadContent(contentType, content, resumePosition, duration) })) { + return; + } + + Logger.i(TAG, "Start streaming content (contentType: $contentType, resumePosition: $resumePosition, duration: $duration)"); + + time = resumePosition; + sendMessage(Opcode.PLAY, FastCastPlayMessage( + container = contentType, + content = content, + time = resumePosition.toInt() + )); + } + + override fun changeVolume(volume: Double) { + if (invokeInIOScopeIfRequired({ changeVolume(volume) })) { + return; + } + + this.volume = volume + sendMessage(Opcode.SET_VOLUME, FastCastSetVolumeMessage(volume)) + } + + override fun seekVideo(timeSeconds: Double) { + if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) { + return; + } + + sendMessage(Opcode.SEEK, FastCastSeekMessage( + time = timeSeconds.toInt() + )); + } + + override fun resumeVideo() { + if (invokeInIOScopeIfRequired(::resumeVideo)) { + return; + } + + sendMessage(Opcode.RESUME); + } + + override fun pauseVideo() { + if (invokeInIOScopeIfRequired(::pauseVideo)) { + return; + } + + sendMessage(Opcode.PAUSE); + } + + override fun stopVideo() { + if (invokeInIOScopeIfRequired(::stopVideo)) { + return; + } + + sendMessage(Opcode.STOP); + } + + private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean { + if(Looper.getMainLooper().thread == Thread.currentThread()) { + _scopeIO?.launch { action(); } + return true; + } + + return false; + } + + override fun stopCasting() { + if (invokeInIOScopeIfRequired(::stopCasting)) { + return; + } + + stopVideo(); + + Logger.i(TAG, "Stopping active device because stopCasting was called.") + stop(); + } + + override fun start() { + val adrs = addresses ?: return; + if (_started) { + return; + } + + _started = true; + Logger.i(TAG, "Starting..."); + + _scopeIO?.cancel(); + Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.") + _scopeIO = CoroutineScope(Dispatchers.IO); + + Thread { + connectionState = CastConnectionState.CONNECTING; + + while (_scopeIO?.isActive == true) { + try { + val connectedSocket = getConnectedSocket(adrs.toList(), port); + if (connectedSocket == null) { + Thread.sleep(3000); + continue; + } + + usedRemoteAddress = connectedSocket.inetAddress; + localAddress = connectedSocket.localAddress; + connectedSocket.close(); + break; + } catch (e: Throwable) { + Logger.w(ChromecastCastingDevice.TAG, "Failed to get setup initial connection to FastCast device.", e) + } + } + + //Connection loop + while (_scopeIO?.isActive == true) { + Logger.i(TAG, "Connecting to FastCast."); + connectionState = CastConnectionState.CONNECTING; + + try { + _socket = Socket(usedRemoteAddress, port); + Logger.i(TAG, "Successfully connected to FastCast at $usedRemoteAddress:$port"); + + try { + _outputStream = DataOutputStream(_socket?.outputStream); + _inputStream = DataInputStream(_socket?.inputStream); + } catch (e: Throwable) { + Logger.i(TAG, "Failed to authenticate to FastCast.", e); + } + } catch (e: IOException) { + _socket?.close(); + Logger.i(TAG, "Failed to connect to FastCast.", e); + + connectionState = CastConnectionState.CONNECTING; + Thread.sleep(3000); + continue; + } + + localAddress = _socket?.localAddress; + connectionState = CastConnectionState.CONNECTED; + + val buffer = ByteArray(4096); + + Logger.i(TAG, "Started receiving."); + while (_scopeIO?.isActive == true) { + try { + val inputStream = _inputStream ?: break; + Log.d(TAG, "Receiving next packet..."); + val b1 = inputStream.readUnsignedByte(); + val b2 = inputStream.readUnsignedByte(); + val b3 = inputStream.readUnsignedByte(); + val b4 = inputStream.readUnsignedByte(); + val size = ((b4.toLong() shl 24) or (b3.toLong() shl 16) or (b2.toLong() shl 8) or b1.toLong()).toInt(); + if (size > buffer.size) { + Logger.w(TAG, "Skipping packet that is too large $size bytes.") + inputStream.skip(size.toLong()); + continue; + } + + Log.d(TAG, "Received header indicating $size bytes. Waiting for message."); + inputStream.read(buffer, 0, size); + + val messageBytes = buffer.sliceArray(IntRange(0, size)); + Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}."); + + val opcode = messageBytes[0]; + var json: String? = null; + if (size > 1) { + json = messageBytes.sliceArray(IntRange(1, size - 1)).decodeToString(); + } + + try { + handleMessage(Opcode.values().first { it.value == opcode }, json); + } catch (e:Throwable) { + Logger.w(TAG, "Failed to handle message.", e); + } + } catch (e: java.net.SocketException) { + Logger.e(TAG, "Socket exception while receiving.", e); + break; + } catch (e: Throwable) { + Logger.e(TAG, "Exception while receiving.", e); + break; + } + } + _socket?.close(); + Logger.i(TAG, "Socket disconnected."); + + connectionState = CastConnectionState.CONNECTING; + Thread.sleep(3000); + } + + Logger.i(TAG, "Stopped connection loop."); + connectionState = CastConnectionState.DISCONNECTED; + }.start(); + + Logger.i(TAG, "Started."); + } + + private fun handleMessage(opcode: Opcode, json: String? = null) { + when (opcode) { + Opcode.PLAYBACK_UPDATE -> { + if (json == null) { + Logger.w(TAG, "Got playback update without JSON, ignoring."); + return; + } + + val playbackUpdate = Json.decodeFromString(json); + time = playbackUpdate.time.toDouble(); + isPlaying = when (playbackUpdate.state) { + 1 -> true + else -> false + } + } + Opcode.VOLUME_UPDATE -> { + if (json == null) { + Logger.w(TAG, "Got volume update without JSON, ignoring."); + return; + } + + val volumeUpdate = Json.decodeFromString(json); + volume = volumeUpdate.volume; + } + else -> { } + } + } + + private fun sendMessage(opcode: Opcode) { + try { + val size = 1; + val outputStream = _outputStream; + if (outputStream == null) { + Logger.w(TAG, "Failed to send $size bytes, output stream is null."); + return; + } + + val serializedSizeLE = ByteArray(4); + serializedSizeLE[0] = (size and 0xff).toByte(); + serializedSizeLE[1] = (size shr 8 and 0xff).toByte(); + serializedSizeLE[2] = (size shr 16 and 0xff).toByte(); + serializedSizeLE[3] = (size shr 24 and 0xff).toByte(); + outputStream.write(serializedSizeLE); + + val opcodeBytes = ByteArray(1); + opcodeBytes[0] = opcode.value; + outputStream.write(opcodeBytes); + + Log.d(TAG, "Sent $size bytes."); + } catch (e: Throwable) { + Logger.i(TAG, "Failed to send message.", e); + } + } + + private inline fun sendMessage(opcode: Opcode, message: T) { + try { + val data: ByteArray; + var jsonString: String? = null; + if (message != null) { + jsonString = Json.encodeToString(message); + data = jsonString.encodeToByteArray(); + } else { + data = ByteArray(0); + } + + val size = 1 + data.size; + val outputStream = _outputStream; + if (outputStream == null) { + Logger.w(TAG, "Failed to send $size bytes, output stream is null."); + return; + } + + val serializedSizeLE = ByteArray(4); + serializedSizeLE[0] = (size and 0xff).toByte(); + serializedSizeLE[1] = (size shr 8 and 0xff).toByte(); + serializedSizeLE[2] = (size shr 16 and 0xff).toByte(); + serializedSizeLE[3] = (size shr 24 and 0xff).toByte(); + outputStream.write(serializedSizeLE); + + val opcodeBytes = ByteArray(1); + opcodeBytes[0] = opcode.value; + outputStream.write(opcodeBytes); + + if (data.isNotEmpty()) { + outputStream.write(data); + } + + Log.d(TAG, "Sent $size bytes: '$jsonString'."); + } catch (e: Throwable) { + Logger.i(TAG, "Failed to send message.", e); + } + } + + override fun stop() { + Logger.i(TAG, "Stopping..."); + usedRemoteAddress = null; + localAddress = null; + _started = false; + + val socket = _socket; + val scopeIO = _scopeIO; + + if (scopeIO != null && socket != null) { + Logger.i(TAG, "Cancelling scopeIO with open socket.") + + scopeIO.launch { + socket.close(); + connectionState = CastConnectionState.DISCONNECTED; + scopeIO.cancel(); + Logger.i(TAG, "Cancelled scopeIO with open socket.") + } + } else { + scopeIO?.cancel(); + Logger.i(TAG, "Cancelled scopeIO without open socket.") + } + + _scopeIO = null; + _socket = null; + _outputStream = null; + _inputStream = null; + connectionState = CastConnectionState.DISCONNECTED; + } + + override fun getDeviceInfo(): CastingDeviceInfo { + return CastingDeviceInfo(name!!, CastProtocolType.FASTCAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port); + } + + companion object { + val TAG = "FastCastCastingDevice"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt new file mode 100644 index 00000000..63b9dfe3 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -0,0 +1,758 @@ +package com.futo.platformplayer.casting + +import android.content.ContentResolver +import android.content.Context +import android.os.Looper +import com.futo.platformplayer.* +import com.futo.platformplayer.api.http.server.ManagedHttpServer +import com.futo.platformplayer.api.http.server.handlers.* +import com.futo.platformplayer.api.media.models.streams.sources.* +import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.builders.DashBuilder +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.CastingDeviceInfo +import com.futo.platformplayer.states.StateApp +import kotlinx.coroutines.* +import java.net.InetAddress +import java.util.* +import javax.jmdns.JmDNS +import javax.jmdns.ServiceEvent +import javax.jmdns.ServiceListener +import kotlin.collections.HashMap +import com.futo.platformplayer.stores.CastingDeviceInfoStorage +import com.futo.platformplayer.stores.FragmentedStorage +import javax.jmdns.ServiceTypeListener + +class StateCasting { + private val _scopeIO = CoroutineScope(Dispatchers.IO); + private val _scopeMain = CoroutineScope(Dispatchers.Main); + private lateinit var _jmDNS: JmDNS; + private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get(); + + private val _castServer = ManagedHttpServer(9999); + private var _started = false; + + var devices: HashMap = hashMapOf(); + var rememberedDevices: ArrayList = arrayListOf(); + val onDeviceAdded = Event1(); + val onDeviceChanged = Event1(); + val onDeviceRemoved = Event1(); + val onActiveDeviceConnectionStateChanged = Event2(); + val onActiveDevicePlayChanged = Event1(); + val onActiveDeviceTimeChanged = Event1(); + var activeDevice: CastingDevice? = null; + + val isCasting: Boolean get() = activeDevice != null; + + private val _chromecastServiceListener = object : ServiceListener { + override fun serviceAdded(event: ServiceEvent) { + Logger.i(TAG, "ChromeCast service added: " + event.info); + addOrUpdateDevice(event); + } + + override fun serviceRemoved(event: ServiceEvent) { + Logger.i(TAG, "ChromeCast service removed: " + event.info); + synchronized(devices) { + val device = devices[event.info.name]; + if (device != null) { + onDeviceRemoved.emit(device); + } + } + } + + override fun serviceResolved(event: ServiceEvent) { + Logger.i(TAG, "ChromeCast service resolved: " + event.info); + addOrUpdateDevice(event); + } + + fun addOrUpdateDevice(event: ServiceEvent) { + addOrUpdateChromeCastDevice(event.info.name, event.info.inetAddresses, event.info.port); + } + } + + private val _airPlayServiceListener = object : ServiceListener { + override fun serviceAdded(event: ServiceEvent) { + Logger.i(TAG, "AirPlay service added: " + event.info); + addOrUpdateDevice(event); + } + + override fun serviceRemoved(event: ServiceEvent) { + Logger.i(TAG, "AirPlay service removed: " + event.info); + synchronized(devices) { + val device = devices[event.info.name]; + if (device != null) { + onDeviceRemoved.emit(device); + } + } + } + + override fun serviceResolved(event: ServiceEvent) { + Logger.i(TAG, "AirPlay service resolved: " + event.info); + addOrUpdateDevice(event); + } + + fun addOrUpdateDevice(event: ServiceEvent) { + addOrUpdateAirPlayDevice(event.info.name, event.info.inetAddresses, event.info.port); + } + } + + private val _fastCastServiceListener = object : ServiceListener { + override fun serviceAdded(event: ServiceEvent) { + Logger.i(TAG, "FastCast service added: " + event.info); + addOrUpdateDevice(event); + } + + override fun serviceRemoved(event: ServiceEvent) { + Logger.i(TAG, "FastCast service removed: " + event.info); + synchronized(devices) { + val device = devices[event.info.name]; + if (device != null) { + onDeviceRemoved.emit(device); + } + } + } + + override fun serviceResolved(event: ServiceEvent) { + Logger.i(TAG, "FastCast service resolved: " + event.info); + addOrUpdateDevice(event); + } + + fun addOrUpdateDevice(event: ServiceEvent) { + addOrUpdateFastCastDevice(event.info.name, event.info.inetAddresses, event.info.port); + } + } + + private val _serviceTypeListener = object : ServiceTypeListener { + override fun serviceTypeAdded(event: ServiceEvent?) { + if (event == null) { + return; + } + + Logger.i(TAG, "Service type added (name: ${event.name}, type: ${event.type})"); + } + + override fun subTypeForServiceTypeAdded(event: ServiceEvent?) { + if (event == null) { + return; + } + + Logger.i(TAG, "Sub type for service type added (name: ${event.name}, type: ${event.type})"); + } + } + + fun onStop() { + val ad = activeDevice ?: return; + Logger.i(TAG, "Stopping active device because of onStop."); + ad.stop(); + } + + @Synchronized + fun start(context: Context) { + if (_started) + return; + _started = true; + + Logger.i(TAG, "CastingService starting..."); + + rememberedDevices.clear(); + rememberedDevices.addAll(_storage.deviceInfos.map { deviceFromCastingDeviceInfo(it) }); + + _scopeIO.launch { + try { + _jmDNS = JmDNS.create(InetAddress.getLocalHost()); + _jmDNS.addServiceListener("_googlecast._tcp.local.", _chromecastServiceListener); + _jmDNS.addServiceListener("_airplay._tcp.local.", _airPlayServiceListener); + _jmDNS.addServiceListener("_fastcast._tcp.local.", _fastCastServiceListener); + + if (BuildConfig.DEBUG) { + _jmDNS.addServiceTypeListener(_serviceTypeListener); + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to start casting service.", e); + } + } + _castServer.start(); + enableDeveloper(context.contentResolver, true); + + Logger.i(TAG, "CastingService started."); + } + + @Synchronized + fun stop() { + if (!_started) + return; + + _started = false; + + Logger.i(TAG, "CastingService stopping.") + + _scopeIO.launch { + try { + _jmDNS.removeServiceListener("_googlecast._tcp.local.", _chromecastServiceListener); + _jmDNS.removeServiceListener("_airplay._tcp", _airPlayServiceListener); + + if (BuildConfig.DEBUG) { + _jmDNS.removeServiceTypeListener(_serviceTypeListener); + } + + _jmDNS.close(); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to stop mDNS.", e); + } + } + + _scopeIO.cancel(); + _scopeMain.cancel(); + + Logger.i(TAG, "Stopping active device because StateCasting is being stopped.") + val d = activeDevice; + activeDevice = null; + d?.stop(); + + _castServer.stop(); + _castServer.removeAllHandlers(); + + Logger.i(TAG, "CastingService stopped.") + } + + @Synchronized + fun connectDevice(device: CastingDevice) { + if (activeDevice == device) + return; + + val ad = activeDevice; + if (ad != null) { + Logger.i(TAG, "Stopping previous device because a new one is being connected.") + ad.onPlayChanged.clear(); + ad.onTimeChanged.clear(); + ad.onConnectionStateChanged.clear(); + ad.stop(); + } + + device.onConnectionStateChanged.subscribe { castConnectionState -> + Logger.i(TAG, "Active device connection state changed: $castConnectionState"); + + if (castConnectionState == CastConnectionState.DISCONNECTED) { + Logger.i(TAG, "Clearing events: $castConnectionState"); + + device.onPlayChanged.clear(); + device.onTimeChanged.clear(); + device.onConnectionStateChanged.clear(); + activeDevice = null; + } + + invokeInMainScopeIfRequired { + StateApp.withContext(false) { context -> + context.let { + when (castConnectionState) { + CastConnectionState.CONNECTED -> UIDialogs.toast(it, "Connected to device") + CastConnectionState.CONNECTING -> UIDialogs.toast(it, "Connecting to device...") + CastConnectionState.DISCONNECTED -> UIDialogs.toast(it, "Disconnected from device") + } + } + }; + onActiveDeviceConnectionStateChanged.emit(device, castConnectionState); + }; + }; + device.onPlayChanged.subscribe { + invokeInMainScopeIfRequired { onActiveDevicePlayChanged.emit(it) }; + } + device.onTimeChanged.subscribe { + invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) }; + }; + + addRememberedDevice(device); + Logger.i(TAG, "Device added to active discovery. Active discovery now contains ${_storage.getDevicesCount()} devices.") + + try { + device.start(); + } catch (e: Throwable) { + Logger.w(TAG, "Failed to connect to device."); + device.onConnectionStateChanged.clear(); + device.onPlayChanged.clear(); + device.onTimeChanged.clear(); + return; + } + + activeDevice = device; + Logger.i(TAG, "Connect to device ${device.name}"); + } + + fun addRememberedDevice(deviceInfo: CastingDeviceInfo) { + val device = deviceFromCastingDeviceInfo(deviceInfo); + addRememberedDevice(device); + } + + fun addRememberedDevice(device: CastingDevice) { + if (_storage.addDevice(device.getDeviceInfo())) { + rememberedDevices.add(device); + } + } + + fun removeRememberedDevice(device: CastingDevice) { + val name = device.name ?: return; + _storage.removeDevice(name); + rememberedDevices.remove(device); + } + + private fun invokeInMainScopeIfRequired(action: () -> Unit){ + if(Looper.getMainLooper().thread != Thread.currentThread()) { + _scopeMain.launch { action(); } + return; + } + + action(); + } + + fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, ms: Long = -1): Boolean { + val ad = activeDevice ?: return false; + if (ad.connectionState != CastConnectionState.CONNECTED) { + return false; + } + + val resumePosition = if (ms > 0L) (ms.toDouble() / 1000.0) else 0.0; + + var sourceCount = 0; + if (videoSource != null) sourceCount++; + if (audioSource != null) sourceCount++; + if (subtitleSource != null) sourceCount++; + + if (sourceCount < 1) { + throw Exception("At least one source should be specified."); + } + + if (sourceCount > 1) { + if (ad is AirPlayCastingDevice) { + StateApp.withContext(false) { context -> UIDialogs.toast(context, "AirPlay does not support DASH. Try ChromeCast or FastCast for casting this video."); }; + ad.stopCasting(); + return false; + } + + if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) { + castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition); + } else { + StateApp.instance.scope.launch(Dispatchers.IO) { + try { + if (ad is FastCastCastingDevice) { + castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition); + } else { + castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition); + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to start casting DASH videoSource=${videoSource} audioSource=${audioSource}.", e); + } + } + } + } else { + if (videoSource is IVideoUrlSource) { + ad.loadVideo("BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble()); + } else if (audioSource is IAudioUrlSource) { + ad.loadVideo("BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble()); + } else if (videoSource is LocalVideoSource) { + castLocalVideo(video, videoSource, resumePosition); + } else if (audioSource is LocalAudioSource) { + castLocalAudio(video, audioSource, resumePosition); + } else { + throw Exception("Unhandled source type videoSource=$videoSource audioSource=$audioSource subtitleSource=$subtitleSource"); + } + } + + return true; + } + + fun resumeVideo(): Boolean { + val ad = activeDevice ?: return false; + ad.resumeVideo(); + return true; + } + + fun pauseVideo(): Boolean { + val ad = activeDevice ?: return false; + ad.pauseVideo(); + return true; + } + + fun stopVideo(): Boolean { + val ad = activeDevice ?: return false; + ad.stopVideo(); + return true; + } + + fun videoSeekTo(timeSeconds: Double): Boolean { + val ad = activeDevice ?: return false; + ad.seekVideo(timeSeconds); + return true; + } + + private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double) : List { + val ad = activeDevice ?: return listOf(); + + val url = "http://${ad.localAddress}:${_castServer.port}"; + val id = UUID.randomUUID(); + val videoPath = "/video-${id}" + val videoUrl = url + videoPath; + + _castServer.addHandler( + HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + + Logger.i(TAG, "Casting local video (videoUrl: $videoUrl)."); + ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble()); + + return listOf(videoUrl); + } + + private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double) : List { + val ad = activeDevice ?: return listOf(); + + val url = "http://${ad.localAddress}:${_castServer.port}"; + val id = UUID.randomUUID(); + val audioPath = "/audio-${id}" + val audioUrl = url + audioPath; + + _castServer.addHandler( + HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + + Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl)."); + ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble()); + + return listOf(audioUrl); + } + + + private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double) : List { + val ad = activeDevice ?: return listOf(); + + val url = "http://${ad.localAddress}:${_castServer.port}"; + val id = UUID.randomUUID(); + + val dashPath = "/dash-${id}" + val videoPath = "/video-${id}" + val audioPath = "/audio-${id}" + val subtitlePath = "/subtitle-${id}" + + val dashUrl = url + dashPath; + val videoUrl = url + videoPath; + val audioUrl = url + audioPath; + val subtitleUrl = url + subtitlePath; + + _castServer.addHandler( + HttpConstantHandler("GET", dashPath, DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitleUrl), + "application/dash+xml") + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + if (videoSource != null) { + _castServer.addHandler( + HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + _castServer.addHandler( + HttpOptionsAllowHandler(videoPath) + .withHeader("Access-Control-Allow-Origin", "*") + .withHeader("Connection", "keep-alive")) + .withTag("cast"); + } + if (audioSource != null) { + _castServer.addHandler( + HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + _castServer.addHandler( + HttpOptionsAllowHandler(audioPath) + .withHeader("Access-Control-Allow-Origin", "*") + .withHeader("Connection", "keep-alive")) + .withTag("cast"); + } + if (subtitleSource != null) { + _castServer.addHandler( + HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath, true) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + _castServer.addHandler( + HttpOptionsAllowHandler(subtitlePath) + .withHeader("Access-Control-Allow-Origin", "*") + .withHeader("Connection", "keep-alive")) + .withTag("cast"); + } + + Logger.i(TAG, "added new castLocalDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath)."); + ad.loadVideo("BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble()); + + return listOf(dashUrl, videoUrl, audioUrl, subtitleUrl); + } + + private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List { + val ad = activeDevice ?: return listOf(); + + val url = "http://${ad.localAddress}:${_castServer.port}"; + val id = UUID.randomUUID(); + val subtitlePath = "/subtitle-${id}"; + + val videoUrl = videoSource?.getVideoUrl(); + val audioUrl = audioSource?.getAudioUrl(); + + val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) { + return@withContext subtitleSource.getSubtitlesURI(); + } else null; + + var subtitlesUrl: String? = null; + if (subtitlesUri != null) { + if(subtitlesUri.scheme == "file") { + var content: String? = null; + val inputStream = contentResolver.openInputStream(subtitlesUri); + inputStream?.use { stream -> + val reader = stream.bufferedReader(); + content = reader.use { it.readText() }; + } + + if (content != null) { + _castServer.addHandler( + HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt") + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + } + + subtitlesUrl = url + subtitlePath; + } else { + subtitlesUrl = subtitlesUri.toString(); + } + } + + val content = DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl); + + Logger.i(TAG, "Direct dash cast to casting device (videoUrl: $videoUrl, audioUrl: $audioUrl)."); + ad.loadContent("application/dash+xml", content, resumePosition, video.duration.toDouble()); + + return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: ""); + } + + private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List { + val ad = activeDevice ?: return listOf(); + val proxyStreams = ad !is FastCastCastingDevice; + + val url = "http://${ad.localAddress}:${_castServer.port}"; + Logger.i(TAG, "DASH url: $url"); + + val id = UUID.randomUUID(); + + val dashPath = "/dash-${id}" + val videoPath = "/video-${id}" + val audioPath = "/audio-${id}" + val subtitlePath = "/subtitle-${id}" + + val dashUrl = url + dashPath; + val videoUrl = if(proxyStreams) url + videoPath else videoSource?.getVideoUrl(); + val audioUrl = if(proxyStreams) url + audioPath else audioSource?.getAudioUrl(); + + val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) { + return@withContext subtitleSource.getSubtitlesURI(); + } else null; + + //_castServer.removeAllHandlers("cast"); + //Logger.i(TAG, "removed all old castDash handlers."); + + var subtitlesUrl: String? = null; + if (subtitlesUri != null) { + if(subtitlesUri.scheme == "file") { + var content: String? = null; + val inputStream = contentResolver.openInputStream(subtitlesUri); + inputStream?.use { stream -> + val reader = stream.bufferedReader(); + content = reader.use { it.readText() }; + } + + if (content != null) { + _castServer.addHandler( + HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt") + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + } + + subtitlesUrl = url + subtitlePath; + } else { + subtitlesUrl = subtitlesUri.toString(); + } + } + + _castServer.addHandler( + HttpConstantHandler("GET", dashPath, DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl), + "application/dash+xml") + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + if (videoSource != null) { + _castServer.addHandler( + HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl()) + .withInjectedHost() + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + _castServer.addHandler( + HttpOptionsAllowHandler(videoPath) + .withHeader("Access-Control-Allow-Origin", "*") + .withHeader("Connection", "keep-alive")) + .withTag("cast"); + } + if (audioSource != null) { + _castServer.addHandler( + HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl()) + .withInjectedHost() + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + _castServer.addHandler( + HttpOptionsAllowHandler(audioPath) + .withHeader("Access-Control-Allow-Origin", "*") + .withHeader("Connection", "keep-alivcontexte")) + .withTag("cast"); + } + + Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)."); + ad.loadVideo("BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble()); + + return listOf(dashUrl, videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); + } + + private fun deviceFromCastingDeviceInfo(deviceInfo: CastingDeviceInfo): CastingDevice { + return when (deviceInfo.type) { + CastProtocolType.CHROMECAST -> { + ChromecastCastingDevice(deviceInfo); + } + CastProtocolType.AIRPLAY -> { + AirPlayCastingDevice(deviceInfo); + } + CastProtocolType.FASTCAST -> { + FastCastCastingDevice(deviceInfo); + } + else -> throw Exception("${deviceInfo.type} is not a valid casting protocol") + } + } + + private fun addOrUpdateChromeCastDevice(name: String, addresses: Array, port: Int) { + return addOrUpdateCastDevice(name, + deviceFactory = { ChromecastCastingDevice(name, addresses, port) }, + deviceUpdater = { d -> + if (d.isReady) { + return@addOrUpdateCastDevice false; + } + + val changed = addresses.contentEquals(d.addresses) || d.name != name || d.port != port; + if (changed) { + d.name = name; + d.addresses = addresses; + d.port = port; + } + + return@addOrUpdateCastDevice changed; + } + ); + } + + private fun addOrUpdateAirPlayDevice(name: String, addresses: Array, port: Int) { + return addOrUpdateCastDevice(name, + deviceFactory = { AirPlayCastingDevice(name, addresses, port) }, + deviceUpdater = { d -> + if (d.isReady) { + return@addOrUpdateCastDevice false; + } + + val changed = addresses.contentEquals(addresses) || d.name != name || d.port != port; + if (changed) { + d.name = name; + d.port = port; + d.addresses = addresses; + } + + return@addOrUpdateCastDevice changed; + } + ); + } + + private fun addOrUpdateFastCastDevice(name: String, addresses: Array, port: Int) { + return addOrUpdateCastDevice(name, + deviceFactory = { FastCastCastingDevice(name, addresses, port) }, + deviceUpdater = { d -> + if (d.isReady) { + return@addOrUpdateCastDevice false; + } + + val changed = addresses.contentEquals(addresses) || d.name != name || d.port != port; + if (changed) { + d.name = name; + d.port = port; + d.addresses = addresses; + } + + return@addOrUpdateCastDevice changed; + } + ); + } + + private inline fun addOrUpdateCastDevice(name: String, deviceFactory: () -> TCastDevice, deviceUpdater: (device: TCastDevice) -> Boolean) where TCastDevice : CastingDevice { + var invokeEvents: (() -> Unit)? = null; + + synchronized(devices) { + val device = devices[name]; + if (device != null) { + if (device !is TCastDevice) { + Logger.w(TAG, "Device name conflict between device types. Ignoring device."); + } else { + val changed = deviceUpdater(device as TCastDevice); + if (changed) { + invokeEvents = { + onDeviceChanged.emit(device); + } + } else { + + } + } + } else { + val newDevice = deviceFactory(); + devices[name] = newDevice; + + invokeEvents = { + onDeviceAdded.emit(newDevice); + }; + } + } + + invokeEvents?.let { _scopeMain.launch { it(); }; }; + } + + fun enableDeveloper(contentResolver: ContentResolver, enableDev: Boolean){ + _castServer.removeAllHandlers("dev"); + if(enableDev) { + _castServer.addHandler(HttpFuntionHandler("GET", "/dashPlayer") { context -> + if (context.query.containsKey("dashUrl")) { + val dashUrl = context.query["dashUrl"]; + val html = "
\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + "
"; + context.respondCode(200, html, "text/html"); + } + }).withTag("dev"); + } + } + + companion object { + val instance: StateCasting = StateCasting(); + + private val TAG = "StateCasting"; + } +} + diff --git a/app/src/main/java/com/futo/platformplayer/casting/models/FastCast.kt b/app/src/main/java/com/futo/platformplayer/casting/models/FastCast.kt new file mode 100644 index 00000000..5b8e8272 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/casting/models/FastCast.kt @@ -0,0 +1,33 @@ +package com.futo.platformplayer.casting.models + +import kotlinx.serialization.Serializable + +@kotlinx.serialization.Serializable +data class FastCastPlayMessage( + val container: String, + val url: String? = null, + val content: String? = null, + val time: Int? = null +) { } + +@kotlinx.serialization.Serializable +data class FastCastSeekMessage( + val time: Int +) { } + +@kotlinx.serialization.Serializable +data class FastCastPlaybackUpdateMessage( + val time: Int, + val state: Int +) { } + + +@Serializable +data class FastCastVolumeUpdateMessage( + val volume: Double +) + +@Serializable +data class FastCastSetVolumeMessage( + val volume: Double +) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/constructs/BackgroundTaskHandler.kt b/app/src/main/java/com/futo/platformplayer/constructs/BackgroundTaskHandler.kt new file mode 100644 index 00000000..86676267 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/constructs/BackgroundTaskHandler.kt @@ -0,0 +1,70 @@ +package com.futo.platformplayer.constructs + +import com.futo.platformplayer.logging.Logger +import kotlinx.coroutines.* + +class BackgroundTaskHandler { + private val TAG = "BackgroundTaskHandler" + + var onError = Event1(); + + private val _scope: CoroutineScope; + private val _dispatcher: CoroutineDispatcher; + private var _idGenerator = 0; + private val _task: (() -> Unit); + private val _lockObject = Object(); + + constructor(scope: CoroutineScope, task: (() -> Unit), dispatcher: CoroutineDispatcher = Dispatchers.IO) { + _task = task; + _scope = scope; + _dispatcher = dispatcher; + } + + inline fun exception(noinline cb : (T)->Unit) : BackgroundTaskHandler { + onError.subscribeConditional { + if(it is T) { + cb(it); + return@subscribeConditional true; + } + + return@subscribeConditional false; + } + return this; + } + + @Synchronized + fun run() { + val id = ++_idGenerator; + + _scope.launch(_dispatcher) { + synchronized (_lockObject) { + if (id != _idGenerator) + return@launch; + + try { + _task.invoke(); + if (id != _idGenerator) + return@launch; + } catch (e: Throwable) { + if (id != _idGenerator) + return@launch; + + if (!onError.emit(e)) { + Logger.e(TAG, "Uncaught exception handled by BackgroundTaskHandler.", e); + } + } + } + } + } + + @Synchronized + fun cancel() { + _idGenerator++; + } + + @Synchronized + fun dispose() { + cancel(); + onError.clear(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/constructs/BatchedTaskHandler.kt b/app/src/main/java/com/futo/platformplayer/constructs/BatchedTaskHandler.kt new file mode 100644 index 00000000..a6464301 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/constructs/BatchedTaskHandler.kt @@ -0,0 +1,73 @@ +package com.futo.platformplayer.constructs + +import com.futo.platformplayer.states.StateApp +import kotlinx.coroutines.* + +class BatchedTaskHandler { + + private val _batchLock = Object(); + //TODO: Determine Deferred/Async vs CompletableFuture (JVM8) + private val _batchRequest = HashMap>(); + + private val _scope: CoroutineScope; + private val _task: suspend ((parameter: TParameter) -> TResult); + private val _taskGetCache: ((parameter: TParameter) -> TResult?)?; + private val _taskSetCache: ((para: TParameter, result: TResult) -> Unit)?; + + constructor(scope: CoroutineScope, task: suspend ((parameter: TParameter) -> TResult), taskGetCache: ((parameter: TParameter) -> TResult?)? = null, taskSetCache: ((para: TParameter, result: TResult) -> Unit)? = null) { + _task = task; + _scope = scope; + _taskGetCache = taskGetCache; + _taskSetCache = taskSetCache; + if((_taskGetCache != null) != (_taskSetCache != null)) + throw IllegalArgumentException("Neither or both getCache/setCache need to be provided"); + } + + fun execute(para : TParameter) : Deferred { + var result: TResult? = null; + var taskResult: Deferred? = null; + + synchronized(_batchLock) { + result = _taskGetCache?.invoke(para); + if(result == null) { + taskResult = _batchRequest[para]; + if(taskResult?.isCancelled ?: false) { + _batchRequest.remove(para); + taskResult = null; + } + } + + //Cached + if(result != null) + //TODO: Replace with some kind of constant Deferred + return _scope.async { result as TResult } + //Already requesting + if(taskResult != null) + return taskResult as Deferred; + + //No ongoing task, then execute the search + //TODO: Replace GlobalScope with _scope after preventing cancel on exception + val task = GlobalScope.async { + val res: TResult; + try { + res = _task.invoke(para); + result = res; + } catch(ex : Throwable) { + synchronized (_batchLock) { + _batchRequest.remove(para); + } + + throw ex.fillInStackTrace(); + } + + synchronized(_batchLock) { + _batchRequest.remove(para); + _taskSetCache?.invoke(para, res); + return@async res; + } + }; + _batchRequest[para] = task; + return task; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/constructs/Event.kt b/app/src/main/java/com/futo/platformplayer/constructs/Event.kt new file mode 100644 index 00000000..84802a6d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/constructs/Event.kt @@ -0,0 +1,146 @@ +package com.futo.platformplayer.constructs + +interface IEvent { + +} +abstract class EventBase: IEvent { + + protected val _conditionalListeners = mutableListOf>(); + protected val _listeners = mutableListOf>(); + + fun subscribeConditional(listener: ConditionalHandler) { + synchronized(_conditionalListeners) { + _conditionalListeners.add(TaggedHandler(listener)); + } + } + + fun subscribeConditional(tag: Any?, listener: ConditionalHandler) { + synchronized(_conditionalListeners) { + _conditionalListeners.add(TaggedHandler(listener, tag)); + } + } + + fun subscribe(listener : Handler) { + synchronized(_listeners) { + _listeners.add(TaggedHandler(listener)); + } + } + + fun subscribe(tag: Any?, listener: Handler) { + synchronized(_listeners) { + _listeners.add(TaggedHandler(listener, tag)); + } + } + + fun remove(tag: Any) { + synchronized(_conditionalListeners) { + _conditionalListeners.removeIf { it.tag == tag }; + } + + synchronized(_listeners) { + _listeners.removeIf { it.tag == tag }; + } + } + + fun clear() { + synchronized(_conditionalListeners) { + _conditionalListeners.clear(); + } + + synchronized(_listeners) { + _listeners.clear(); + } + } + + class TaggedHandler { + val tag: Any?; + val handler: T; + + constructor(handler: T, tag: Any? = null) { + this.tag = tag; + this.handler = handler; + } + } +} + +class Event0() : EventBase<(()->Unit), (()->Boolean)>() { + fun emit() : Boolean { + var handled: Boolean; + synchronized(_listeners) { + handled = _listeners.isNotEmpty(); + } + + synchronized(_conditionalListeners) { + for (conditional in _conditionalListeners) + handled = handled || conditional.handler.invoke(); + } + + synchronized(_listeners) { + for (handler in _listeners) + handler.handler.invoke(); + } + + return handled; + } +} +class Event1() : EventBase<((T1)->Unit), ((T1)->Boolean)>() { + fun emit(value : T1): Boolean { + var handled: Boolean; + synchronized(_listeners) { + handled = _listeners.isNotEmpty(); + } + + synchronized(_conditionalListeners) { + for (conditional in _conditionalListeners) + handled = handled || conditional.handler.invoke(value); + } + + synchronized(_listeners) { + for (handler in _listeners) + handler.handler.invoke(value); + } + + return handled; + } +} +class Event2() : EventBase<((T1, T2)->Unit), ((T1, T2)->Boolean)>() { + fun emit(value1 : T1, value2 : T2): Boolean { + var handled: Boolean; + synchronized(_listeners) { + handled = _listeners.isNotEmpty(); + } + + synchronized(_conditionalListeners) { + for (conditional in _conditionalListeners) + handled = handled || conditional.handler.invoke(value1, value2); + } + + synchronized(_listeners) { + for (handler in _listeners) + handler.handler.invoke(value1, value2); + } + + return handled; + } +} + +class Event3() : EventBase<((T1, T2, T3)->Unit), ((T1, T2, T3)->Boolean)>() { + fun emit(value1 : T1, value2 : T2, value3 : T3): Boolean { + var handled: Boolean; + synchronized(_listeners) { + handled = _listeners.isNotEmpty(); + } + + synchronized(_conditionalListeners) { + for (conditional in _conditionalListeners) + handled = handled || conditional.handler.invoke(value1, value2, value3); + } + + synchronized(_listeners) { + for (handler in _listeners) + handler.handler.invoke(value1, value2, value3); + } + + return handled; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/constructs/TaskHandler.kt b/app/src/main/java/com/futo/platformplayer/constructs/TaskHandler.kt new file mode 100644 index 00000000..9307fff3 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/constructs/TaskHandler.kt @@ -0,0 +1,112 @@ +package com.futo.platformplayer.constructs + +import android.util.Log +import com.futo.platformplayer.logging.Logger +import kotlinx.coroutines.* + +class TaskHandler { + private val TAG = "TaskHandler" + + var onSuccess = Event1(); + var onError = Event2(); + + private val _scope: ()->CoroutineScope; + private val _dispatcher: CoroutineDispatcher; + private var _idGenerator = 0; + private val _task: suspend ((parameter: TParameter) -> TResult); + + constructor(claz : Class, scope: ()->CoroutineScope) { + _task = { claz.newInstance() }; + _scope = scope; + _dispatcher = Dispatchers.IO; + } + constructor(scope: ()->CoroutineScope, task: suspend ((parameter: TParameter) -> TResult), dispatcher: CoroutineDispatcher = Dispatchers.IO) { + _task = task; + _scope = scope; + _dispatcher = dispatcher; + } + + inline fun success(noinline cb : (TResult)->Unit) : TaskHandler { + onSuccess.subscribe(cb); + return this; + } + + inline fun exception(noinline cb : (T)->Unit) : TaskHandler { + onError.subscribeConditional { ex, para -> + if(ex is T) { + cb(ex); + return@subscribeConditional true; + } + return@subscribeConditional false; + } + return this; + } + inline fun exceptionWithParameter(noinline cb : (T, TParameter)->Unit) : TaskHandler { + onError.subscribeConditional { ex, para -> + if(ex is T) { + cb(ex, para); + return@subscribeConditional true; + } + + return@subscribeConditional false; + } + return this; + } + + @Synchronized + fun run(parameter: TParameter) { + val id = ++_idGenerator; + + _scope().launch(_dispatcher) { + if (id != _idGenerator) + return@launch; + + try { + val result = _task.invoke(parameter); + if (id != _idGenerator) + return@launch; + + withContext(Dispatchers.Main) { + if (id != _idGenerator) + return@withContext; + + try { + onSuccess.emit(result); + } + catch (e: Throwable) { + Logger.w(TAG, "Handled exception in TaskHandler onSuccess.", e); + onError.emit(e, parameter); + } + } + } + catch (e: Throwable) { + Log.i("TaskHandler", "TaskHandler.run in exception: " + e.message); + if (id != _idGenerator) + return@launch; + + withContext(Dispatchers.Main) { + if (id != _idGenerator) + return@withContext; + + if (!onError.emit(e, parameter)) { + Logger.e(TAG, "Uncaught exception handled by TaskHandler.", e); + } else { + Logger.w(TAG, "Handled exception in TaskHandler invoke.", e); + } + } + } + } + } + + @Synchronized + fun cancel() { + _idGenerator++; + } + + @Synchronized + fun dispose() { + cancel(); + onSuccess.clear(); + onError.clear(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/debug/Stopwatch.kt b/app/src/main/java/com/futo/platformplayer/debug/Stopwatch.kt new file mode 100644 index 00000000..a10c8da2 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/debug/Stopwatch.kt @@ -0,0 +1,16 @@ +package com.futo.platformplayer.debug + +import com.google.android.exoplayer2.util.Log + +class Stopwatch { + var startTime = System.nanoTime() + + fun logAndNext(tag: String, message: String): Long { + val now = System.nanoTime() + val diff = now - startTime + val diffMs = diff / 1000000.0 + Log.d(tag, "STOPWATCH $message ${diffMs}ms") + startTime = now + return diff + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt b/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt new file mode 100644 index 00000000..808662b9 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt @@ -0,0 +1,434 @@ +package com.futo.platformplayer.developer + +import android.content.Context +import com.futo.platformplayer.activities.LoginActivity +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.api.http.server.HttpContext +import com.futo.platformplayer.api.http.server.HttpGET +import com.futo.platformplayer.api.http.server.HttpPOST +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient +import com.futo.platformplayer.engine.V8Plugin +import com.futo.platformplayer.engine.dev.V8RemoteObject +import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.gsonStandard +import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.serialize +import com.futo.platformplayer.engine.packages.PackageHttp +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateAssets +import com.futo.platformplayer.states.StateDeveloper +import com.futo.platformplayer.states.StatePlatform +import com.google.gson.JsonArray +import com.google.gson.JsonParser +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.util.UUID +import kotlin.reflect.jvm.jvmErasure + +class DeveloperEndpoints(private val context: Context) { + private val TAG = "DeveloperEndpoints"; + private val _client = ManagedHttpClient(); + private var _testPlugin: V8Plugin? = null; + private val testPluginOrThrow: V8Plugin get() = _testPlugin ?: throw IllegalStateException("Attempted to use test plugin without plugin"); + private val _testPluginVariables: HashMap = hashMapOf(); + + private inline fun createRemoteObjectArray(objs: Iterable): List { + val remotes = mutableListOf(); + for(obj in objs) + remotes.add(createRemoteObject(obj)!!); + return remotes; + } + private inline fun createRemoteObject(obj: T): V8RemoteObject? { + if(obj == null) + return null; + + val id = UUID.randomUUID().toString(); + val robj = V8RemoteObject(id, obj as Any); + if(robj.requiresRegistration) { + synchronized(_testPluginVariables) { + _testPluginVariables.put(id, robj); + } + } + return robj; + } + private inline fun getRemoteObjectOrCreate(obj: T): V8RemoteObject? { + if(obj == null) + return null; + + var instance: V8RemoteObject? = getRemoteObjectByInstance(obj as Any); + if(instance == null) + instance = createRemoteObject(obj); + return instance!!; + } + private fun getRemoteObject(id: String): V8RemoteObject { + synchronized(_testPluginVariables) { + if(!_testPluginVariables.containsKey(id)) + throw IllegalArgumentException("Remote object [${id}] does not exist"); + return _testPluginVariables[id]!!; + } + } + private fun getRemoteObjectByInstance(obj: Any): V8RemoteObject? { + + synchronized(_testPluginVariables) { + return _testPluginVariables.values.firstOrNull { it.obj == obj }; + } + } + + + //Files + @HttpGET("/dev", "text/html") + val devTestHtml = StateAssets.readAsset(context, "devportal/index.html", true); + @HttpGET("/source.js", "application/javascript") + val devSourceJS = StateAssets.readAsset(context, "scripts/source.js", true); + @HttpGET("/dev_bridge.js", "application/javascript") + val devBridgeJS = StateAssets.readAsset(context, "devportal/dev_bridge.js", true); + @HttpGET("/source_docs.json", "application/json") + val devSourceDocsJson = Json.encodeToString(JSClient.getJSDocs()); + @HttpGET("/source_docs.js", "application/javascript") + val devSourceDocsJS = "const sourceDocs = $devSourceDocsJson"; + + //Dependencies + //@HttpGET("/dependencies/vue.js", "application/javascript") + //val depVue = StateAssets.readAsset(context, "devportal/dependencies/vue.js", true); + //@HttpGET("/dependencies/vuetify.js", "application/javascript") + //val depVuetify = StateAssets.readAsset(context, "devportal/dependencies/vuetify.js", true); + //@HttpGET("/dependencies/vuetify.min.css", "text/css") + //val depVuetifyCss = StateAssets.readAsset(context, "devportal/dependencies/vuetify.min.css", true); + @HttpGET("/dependencies/FutoMainLogo.svg", "image/svg+xml") + val depFutoLogo = StateAssets.readAsset(context, "devportal/dependencies/FutoMainLogo.svg", true); + + @HttpGET("/reference_plugin.d.ts", "text/plain") + fun devSourceTSWithRefs(httpContext: HttpContext) { + val builder = StringBuilder(); + + builder.appendLine("//Reference Scriptfile"); + builder.appendLine("//Intended exclusively for auto-complete in your IDE, not for execution"); + + builder.appendLine(StateAssets.readAsset(context, "devportal/plugin.d.ts", true)); + + httpContext.respondCode(200, builder.toString(), "text/plain"); + } + + @HttpGET("/reference_autocomplete.js", "application/javascript") + fun devSourceJSWithRefs(httpContext: HttpContext) { + val builder = StringBuilder(); + + builder.appendLine("//Reference Scriptfile"); + builder.appendLine("//Intended exclusively for auto-complete in your IDE, not for execution"); + + builder.appendLine(StateAssets.readAsset(context, "scripts/source.js", true)); + + for(pack in testPluginOrThrow.getPackages()) { + builder.appendLine(); + builder.appendLine("//Package ${pack.name} (variable: ${pack.variableName})"); + val props = V8RemoteObject.getV8Properties(pack::class); + val funcs = V8RemoteObject.getV8Functions(pack::class); + + if(!pack.variableName.isNullOrEmpty() && (props.isNotEmpty() || funcs.isNotEmpty())) { + builder.appendLine("let ${pack.variableName} = {"); + + val lastProp = props.lastOrNull(); + for(prop in props) { + builder.appendLine(" /**"); + builder.appendLine(" * @return {${prop.returnType.jvmErasure.simpleName}}"); + builder.appendLine(" **/"); + builder.append(" ${prop.name}: null"); + if(prop != lastProp || funcs.isNotEmpty()) + builder.append(",\n"); + else + builder.append(("\n")); + builder.appendLine(); + } + + val lastFunc = funcs.lastOrNull(); + for(func in funcs) { + builder.appendLine(" /**"); + for(para in func.parameters.subList(1, func.parameters.size)) + builder.appendLine(" * @param {${para.type.jvmErasure.simpleName}} ${para.name}"); + builder.appendLine(" * @return {${func.returnType.jvmErasure.simpleName}}"); + builder.appendLine(" **/"); + builder.append(" ${func.name}: function("); + + val lastPara = func.parameters.lastOrNull(); + for(para in func.parameters.subList(1, func.parameters.size)) { + builder.append("${para.name}"); + if(para != lastPara) + builder.append(", "); + } + builder.append(") {}"); + if(func != lastFunc || funcs.isNotEmpty()) + builder.append(",\n"); + else + builder.append(("\n")); + builder.appendLine(); + } + + builder.appendLine("}"); + } + } + httpContext.respondCode(200, builder.toString(), "application/javascript"); + } + + + + @HttpPOST("/plugin/getWarnings") + fun plugin_getWarnings(context: HttpContext) { + val config = context.readContentJson() + context.respondJson(200, config.getWarnings()) + } + + //Testing + @HttpPOST("/plugin/updateTestPlugin") + fun pluginUpdateTestPlugin(context: HttpContext) { + val config = context.readContentJson() + try { + _testPluginVariables.clear(); + _testPlugin = V8Plugin(StateApp.instance.context, config); + context.respondJson(200, testPluginOrThrow.getPackageVariables()); + } + catch(ex: Throwable) { + context.respondCode(500, (ex::class.simpleName + ":" + ex.message) ?: "", "text/plain") + } + } + @HttpPOST("/plugin/cleanTestPlugin") + fun pluginCleanTestPlugin(context: HttpContext) { + try { + _testPluginVariables.clear(); + context.respondCode(200); + } + catch(ex: Throwable) { + context.respondCode(500, (ex::class.simpleName + ":" + ex.message) ?: "", "text/plain") + } + } + @HttpGET("/plugin/loginTestPlugin") + fun pluginLoginTestPlugin(context: HttpContext) { + val config = _testPlugin?.config as SourcePluginConfig; + try { + val authConfig = config.authentication; + if(authConfig == null) { + context.respondCode(403, "This plugin doesn't support auth"); + return; + } + LoginActivity.showLogin(StateApp.instance.context, config) { + _testPluginVariables.clear(); + _testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null), JSHttpClient(null, it)); + + }; + context.respondCode(200, "Login started"); + } + catch(ex: Throwable) { + context.respondCode(500, (ex::class.simpleName + ":" + ex.message) ?: "", "text/plain") + } + } + @HttpGET("/plugin/logoutTestPlugin") + fun pluginLogoutTestPlugin(context: HttpContext) { + val config = _testPlugin?.config as SourcePluginConfig; + try { + _testPluginVariables.clear(); + _testPlugin = V8Plugin(StateApp.instance.context, config, null); + context.respondCode(200, "Logged out"); + } + catch(ex: Throwable) { + context.respondCode(500, (ex::class.simpleName + ":" + ex.message) ?: "", "text/plain") + } + } + + @HttpGET("/plugin/isLoggedIn") + fun pluginIsLoggedIn(context: HttpContext) { + try { + val isLoggedIn = _testPlugin?.httpClientAuth is JSHttpClient && (_testPlugin?.httpClientAuth as JSHttpClient).isLoggedIn; + context.respondCode(200, if(isLoggedIn) "true" else "false", "application/json"); + } + catch(ex: Throwable) { + context.respondCode(500, (ex::class.simpleName + ":" + ex.message) ?: "", "text/plain") + } + } + + @HttpGET("/plugin/packageGet") + fun pluginPackageGet(context: HttpContext) { + val variableName = context.query.get("variable") + try { + if(variableName.isNullOrEmpty()) { + context.respondCode(400, "Missing variable name"); + return; + } + val pack = testPluginOrThrow.getPackageByVariableName(variableName); + context.respondCode(200, getRemoteObjectOrCreate(pack)?.serialize() ?: "null", "application/json"); + } + catch(ex: Throwable) { + //Logger.e("Developer Endpoints", "Failed to fetch packageGet:", ex); + context.respondCode(500, ex::class.simpleName + ":" + ex.message ?: "", "text/plain") + } + } + @HttpPOST("/plugin/remoteCall") + fun pluginRemoteCall(context: HttpContext) { + try { + val parameters = context.readContentString(); + val objId = context.query.get("id") + val method = context.query.get("method") + + if(objId.isNullOrEmpty()) { + context.respondCode(400, "Missing object id"); + return; + } + if(method.isNullOrEmpty()) { + context.respondCode(400, "Missing method"); + return; + } + val remoteObj = getRemoteObject(objId); + val paras = JsonParser.parseString(parameters); + if(!paras.isJsonArray) + throw IllegalArgumentException("Expected json array as body"); + if(method != "isLoggedIn") + Logger.i(TAG, "Remote Call [${objId}].${method}(...)"); + val callResult = remoteObj.call(method, paras as JsonArray); + val json = wrapRemoteResult(callResult, false); + context.respondCode(200, json, "application/json"); + } + catch(ilEx: IllegalArgumentException) { + if(ilEx.message?.contains("does not exist") ?: false) { + context.respondCode(400, ilEx.message ?: "", "text/plain"); + } + else { + Logger.e("DeveloperEndpoints", ilEx.message, ilEx); + context.respondCode(500, ilEx::class.simpleName + ":" + ilEx.message ?: "", "text/plain") + } + } + catch(ex: Throwable) { + Logger.e("DeveloperEndpoints", ex.message, ex); + context.respondCode(500, ex::class.simpleName + ":" + ex.message ?: "", "text/plain") + } + } + @HttpGET("/plugin/remoteProp") + fun pluginRemoteProp(context: HttpContext) { + val objId = context.query.get("id") + val prop = context.query.get("prop") + try { + if(objId.isNullOrEmpty()) { + context.respondCode(400, "Missing variable name"); + return; + } + if(prop.isNullOrEmpty()) { + context.respondCode(400, "Missing prop name"); + return; + } + val remoteObj = getRemoteObject(objId); + Logger.i(TAG, "Remote Prop [${objId}].${prop}(...)"); + + //TODO: Determine if we should get existing or always create new + val callResult = remoteObj.prop(prop); + val json = wrapRemoteResult(callResult, true); + context.respondCode(200, json, "application/json"); + } + catch(ilEx: IllegalArgumentException) { + if(ilEx.message?.contains("does not exist") ?: false) { + context.respondCode(400, ilEx.message ?: "", "text/plain"); + } + else { + Logger.e("DeveloperEndpoints", ilEx.message, ilEx); + context.respondCode(500, ilEx::class.simpleName + ":" + ilEx.message ?: "", "text/plain") + } + } + catch(ex: Throwable) { + Logger.e("DeveloperEndpoints", ex.message, ex); + context.respondCode(500, ex::class.simpleName + ":" + ex.message ?: "", "text/plain") + } + } + + private fun wrapRemoteResult(callResult: Any?, useCached: Boolean = false): String { + return if(callResult == null) + "null"; + else if(callResult.javaClass.isPrimitive || callResult.javaClass == String::class.java) + gsonStandard.toJson(callResult); + else if(callResult is Iterable<*> && callResult.count() == 0) + return "[]"; + else if(callResult is Iterable<*>) { + val firstItemType = callResult.first()!!.javaClass; + if(firstItemType.isPrimitive || firstItemType == String::class.java) + return gsonStandard.toJson(callResult); + else + createRemoteObjectArray(callResult).serialize(); + } + else if(useCached) + getRemoteObjectOrCreate(callResult)?.serialize() ?: "null"; + else + createRemoteObject(callResult)?.serialize() ?: "null"; + } + + + + //Integration + @HttpPOST("/plugin/loadDevPlugin") + fun pluginLoadDevPlugin(context: HttpContext) { + val config = context.readContentJson() + try { + val script = _client.get(config.absoluteScriptUrl!!); + if(!script.isOk) + throw IllegalStateException("URL ${config.scriptUrl} return code ${script.code}"); + if(script.body == null) + throw IllegalStateException("URL ${config.scriptUrl} return no body"); + + val id = StatePlatform.instance.injectDevPlugin(config, script.body.string()); + context.respondJson(200, id); + } + catch(ex: Exception) { + Logger.e("DeveloperEndpoints", ex.message, ex); + context.respondCode(500, ex::class.simpleName + ":" + ex.message ?: "", "text/plain") + } + } + + @HttpGET("/plugin/getDevLogs") + fun pluginGetDevLogs(context: HttpContext) { + try { + val index = context.query.getOrDefault("index", "0").toInt(); + context.respondJson(200, StateDeveloper.instance.getLogs(index)); + } + catch(ex: Exception) { + context.respondCode(500, ex.message ?: "", "text/plain") + } + } + @HttpGET("/plugin/fakeDevLog") + fun pluginFakeDevLog(context: HttpContext) { + try { + val type = context.query.getOrDefault("type", "INFO"); + val devId = context.query.getOrDefault("devId", ""); + val msg = context.query.getOrDefault("msg", ""); + when(type) { + "INFO" -> StateDeveloper.instance.logDevInfo(devId, msg); + "EXCEPTION" -> StateDeveloper.instance.logDevException(devId, msg); + } + context.respondCode(200); + } + catch(ex: Exception) { + context.respondCode(500, ex.message ?: "", "text/plain") + } + } + + //Internal calls + @HttpPOST("/get") + fun get(context: HttpContext) { + try{ + val body = context.readContentJson(); + if(body.url == null) + throw IllegalStateException("Missing url"); + + val resp = _client.get(body.url!!, body.headers); + + context.respondCode(200, + Json.encodeToString(PackageHttp.BridgeHttpResponse(resp.code, resp.body?.string())), + context.query.getOrDefault("CT", "text/plain")); + } + catch(ex: Exception) { + context.respondCode(500, ex.message ?: "", "text/plain"); + } + } + + @kotlinx.serialization.Serializable + class BridgeHttpRequest() { + var url: String? = null; + var headers: MutableMap = HashMap(); + var contentType: String = ""; + var body: String? = null; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt new file mode 100644 index 00000000..452b290f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt @@ -0,0 +1,208 @@ +package com.futo.platformplayer.dialogs + +import android.app.AlertDialog +import android.app.PendingIntent.* +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.graphics.drawable.Animatable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.WindowManager +import android.widget.Button +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.* +import com.futo.platformplayer.receivers.InstallReceiver +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateUpdate +import kotlinx.coroutines.* +import java.io.File +import java.io.InputStream + +class AutoUpdateDialog(context: Context?) : AlertDialog(context) { + companion object { + private val TAG = "AutoUpdateDialog"; + } + + private lateinit var _buttonNever: Button; + private lateinit var _buttonClose: Button; + private lateinit var _buttonUpdate: LinearLayout; + private lateinit var _text: TextView; + private lateinit var _textProgress: TextView; + private lateinit var _updateSpinner: ImageView; + private lateinit var _buttonShowChangelog: Button; + private var _maxVersion: Int = 0; + + private var _updating: Boolean = false; + private var _apkFile: File? = null; + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_update, null)); + + _buttonNever = findViewById(R.id.button_never); + _buttonClose = findViewById(R.id.button_close); + _buttonUpdate = findViewById(R.id.button_update); + _text = findViewById(R.id.text_dialog); + _textProgress = findViewById(R.id.text_progress); + _updateSpinner = findViewById(R.id.update_spinner); + _buttonShowChangelog = findViewById(R.id.button_show_changelog); + + _buttonNever.setOnClickListener { + Settings.instance.autoUpdate.check = 1; + Settings.instance.save(); + dismiss(); + }; + + _buttonClose.setOnClickListener { + dismiss(); + }; + + _buttonShowChangelog.setOnClickListener { + dismiss(); + UIDialogs.showChangelogDialog(context, _maxVersion); + }; + + _buttonUpdate.setOnClickListener { + if (_updating) { + return@setOnClickListener; + } + + _updating = true; + update(); + }; + } + + fun showPredownloaded(apkFile: File) { + _apkFile = apkFile; + super.show() + } + + override fun dismiss() { + super.dismiss() + InstallReceiver.onReceiveResult.clear(); + Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.") + } + + private fun update() { + _buttonShowChangelog.visibility = Button.GONE; + _buttonNever.visibility = Button.GONE; + _buttonClose.visibility = Button.GONE; + _buttonUpdate.visibility = Button.GONE; + setCancelable(false); + setCanceledOnTouchOutside(false); + window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + + _text.text = context.resources.getText(R.string.downloading_update); + (_updateSpinner?.drawable as Animatable?)?.start(); + + GlobalScope.launch(Dispatchers.IO) { + var inputStream: InputStream? = null; + try { + val apkFile = _apkFile; + if (apkFile != null) { + inputStream = apkFile.inputStream(); + val dataLength = apkFile.length(); + install(inputStream, dataLength); + } else { + val client = ManagedHttpClient(); + val response = client.get(StateUpdate.APK_URL); + if (response.isOk && response.body != null) { + inputStream = response.body.byteStream(); + val dataLength = response.body.contentLength(); + install(inputStream, dataLength); + } else { + throw Exception("Failed to download latest version of app."); + } + } + } catch (e: Throwable) { + Logger.w(TAG, "Exception thrown while downloading and installing latest version of app.", e); + withContext(Dispatchers.Main) { + onReceiveResult("Failed to download update."); + } + } finally { + inputStream?.close(); + } + } + } + + private suspend fun install(inputStream: InputStream, dataLength: Long) { + var lastProgressText = ""; + var session: PackageInstaller.Session? = null; + + try { + Logger.i(TAG, "Hooked InstallReceiver.onReceiveResult.") + InstallReceiver.onReceiveResult.subscribe(this) { message -> onReceiveResult(message); }; + + val packageInstaller: PackageInstaller = context.packageManager.packageInstaller; + val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL); + val sessionId = packageInstaller.createSession(params); + session = packageInstaller.openSession(sessionId) + + session.openWrite("package", 0, dataLength).use { sessionStream -> + inputStream.copyToOutputStream(dataLength, sessionStream) { progress -> + val progressText = "${(progress * 100.0f).toInt()}%"; + if (lastProgressText != progressText) { + lastProgressText = progressText; + + //TODO: Use proper scope + GlobalScope.launch(Dispatchers.Main) { + _textProgress.text = progressText; + }; + } + } + + session.fsync(sessionStream); + }; + + val intent = Intent(context, InstallReceiver::class.java); + val pendingIntent = getBroadcast(context, 0, intent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT); + val statusReceiver = pendingIntent.intentSender; + + session.commit(statusReceiver); + session.close(); + + withContext(Dispatchers.Main) { + _textProgress.text = ""; + _text.text = context.resources.getText(R.string.installing_update); + } + } catch (e: Throwable) { + Logger.w(TAG, "Exception thrown while downloading and installing latest version of app.", e); + session?.abandon(); + withContext(Dispatchers.Main) { + onReceiveResult("Failed to download update."); + } + } + finally { + withContext(Dispatchers.Main) { + window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + } + } + + private fun onReceiveResult(result: String?) { + InstallReceiver.onReceiveResult.remove(this); + Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler."); + + setCancelable(true); + setCanceledOnTouchOutside(true); + _buttonClose.visibility = View.VISIBLE; + (_updateSpinner?.drawable as Animatable?)?.stop(); + + if (result == null || result.isBlank()) { + _updateSpinner.setImageResource(R.drawable.ic_update_success_251dp); + _text.text = context.resources.getText(R.string.success); + } else { + _updateSpinner.setImageResource(R.drawable.ic_update_fail_251dp); + _text.text = "${context.resources.getText(R.string.failed_to_update_with_error)}: '${result}'."; + } + } + + fun setMaxVersion(version: Int) { + _maxVersion = version; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/AutomaticBackupDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/AutomaticBackupDialog.kt new file mode 100644 index 00000000..29a8a3c2 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/dialogs/AutomaticBackupDialog.kt @@ -0,0 +1,88 @@ +package com.futo.platformplayer.dialogs + +import android.app.AlertDialog +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.WindowManager +import android.view.inputmethod.InputMethodManager +import android.widget.* +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateBackup +import com.google.android.material.button.MaterialButton + + +class AutomaticBackupDialog(context: Context) : AlertDialog(context) { + private lateinit var _buttonStart: LinearLayout; + private lateinit var _buttonStop: LinearLayout; + private lateinit var _buttonCancel: ImageButton; + + private lateinit var _editPassword: EditText; + + private lateinit var _inputMethodManager: InputMethodManager; + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup, null)); + + _buttonCancel = findViewById(R.id.button_cancel); + _buttonStop = findViewById(R.id.button_stop); + _buttonStart = findViewById(R.id.button_start); + _editPassword = findViewById(R.id.edit_password); + + _inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager; + + _buttonCancel.setOnClickListener { + clearFocus(); + dismiss(); + }; + _buttonStop.setOnClickListener { + clearFocus(); + dismiss(); + Settings.instance.backup.autoBackupPassword = null; + Settings.instance.backup.didAskAutoBackup = true; + Settings.instance.save(); + + UIDialogs.toast(context, "AutoBackup disabled"); + } + + _buttonStart.setOnClickListener { + val pbytes = _editPassword.text.toString().toByteArray(); + if(pbytes.size < 4 || pbytes.size > 32) { + UIDialogs.toast(context, "Password needs to be atleast 4 bytes long and smaller than 32 bytes", false); + return@setOnClickListener; + } + clearFocus(); + dismiss(); + Logger.i(TAG, "Set AutoBackupPassword"); + Settings.instance.backup.autoBackupPassword = _editPassword.text.toString(); + Settings.instance.backup.didAskAutoBackup = true; + Settings.instance.save(); + + UIDialogs.toast(context, "AutoBackup enabled"); + + try { + StateBackup.startAutomaticBackup(true); + } + catch(ex: Throwable) { + Logger.e(TAG, "Forced automatic backup failed", ex); + UIDialogs.toast(context, "Automatic backup failed due to:\n" + ex.message); + } + }; + + window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM); + } + + private fun clearFocus() { + _editPassword.clearFocus(); + currentFocus?.let { _inputMethodManager.hideSoftInputFromWindow(it.windowToken, 0) }; + } + + companion object { + private val TAG = "AutomaticBackupDialog"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/AutomaticRestoreDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/AutomaticRestoreDialog.kt new file mode 100644 index 00000000..80842f24 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/dialogs/AutomaticRestoreDialog.kt @@ -0,0 +1,89 @@ +package com.futo.platformplayer.dialogs + +import android.app.AlertDialog +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.WindowManager +import android.view.inputmethod.InputMethodManager +import android.widget.* +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.dp +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.selectBestImage +import com.futo.platformplayer.states.StateAnnouncement +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateBackup +import com.futo.platformplayer.states.StatePolycentric +import com.futo.polycentric.core.* +import com.google.android.material.button.MaterialButton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import userpackage.Protocol +import java.time.OffsetDateTime + + +class AutomaticRestoreDialog(context: Context, val scope: CoroutineScope) : AlertDialog(context) { + private lateinit var _buttonStart: LinearLayout; + private lateinit var _buttonCancel: MaterialButton; + + private lateinit var _editPassword: EditText; + + private lateinit var _inputMethodManager: InputMethodManager; + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup_restore, null)); + + _buttonCancel = findViewById(R.id.button_cancel); + _buttonStart = findViewById(R.id.button_start); + _editPassword = findViewById(R.id.edit_password); + + _inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager; + + _buttonCancel.setOnClickListener { + clearFocus(); + dismiss(); + }; + + _buttonStart.setOnClickListener { + val pbytes = _editPassword.text.toString().toByteArray(); + if(pbytes.size < 4 || pbytes.size > 32) { + UIDialogs.toast(context, "Password needs to be atleast 4 bytes long and less than 32 bytes", false); + return@setOnClickListener; + } + clearFocus(); + + try { + StateBackup.restoreAutomaticBackup(context, scope, _editPassword.text.toString(), true); + dismiss(); + } + catch(ex: Throwable) { + Logger.e(TAG, "Failed to restore automatic backup", ex); + //UIDialogs.toast(context, "Restore failed due to:\n" + ex.message); + UIDialogs.showGeneralErrorDialog(context, "Restore failed", ex); + } + }; + + window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM); + } + + private fun clearFocus() { + _editPassword.clearFocus(); + currentFocus?.let { _inputMethodManager.hideSoftInputFromWindow(it.windowToken, 0) }; + } + + companion object { + private val TAG = "AutomaticRestoreDialog"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt new file mode 100644 index 00000000..9966e40a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt @@ -0,0 +1,138 @@ +package com.futo.platformplayer.dialogs + +import android.app.AlertDialog +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.WindowManager +import android.widget.* +import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.casting.CastProtocolType +import com.futo.platformplayer.casting.StateCasting +import com.futo.platformplayer.models.CastingDeviceInfo +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.toInetAddress +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + + +class CastingAddDialog(context: Context?) : AlertDialog(context) { + private lateinit var _spinnerType: Spinner; + private lateinit var _editName: EditText; + private lateinit var _editIP: EditText; + private lateinit var _editPort: EditText; + private lateinit var _textError: TextView; + private lateinit var _buttonCancel: Button; + private lateinit var _buttonConfirm: LinearLayout; + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_casting_add, null)); + + _spinnerType = findViewById(R.id.spinner_type); + _editName = findViewById(R.id.edit_name); + _editIP = findViewById(R.id.edit_ip); + _editPort = findViewById(R.id.edit_port); + _textError = findViewById(R.id.text_error); + _buttonCancel = findViewById(R.id.button_cancel); + _buttonConfirm = findViewById(R.id.button_confirm); + + ArrayAdapter.createFromResource(context, R.array.casting_device_type_array, R.layout.spinner_item_simple).also { adapter -> + adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); + _spinnerType.adapter = adapter; + }; + + _buttonCancel.setOnClickListener { + performDismiss(); + } + + _spinnerType.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { + _editPort.text.clear(); + _editPort.text.append(when (_spinnerType.selectedItemPosition) { + 0 -> "46899" //FastCast + 1 -> "8009" //ChromeCast + else -> "" + }); + } + override fun onNothingSelected(parent: AdapterView<*>?) = Unit + }; + + _buttonConfirm.setOnClickListener { + val castProtocolType: CastProtocolType = when (_spinnerType.selectedItemPosition) { + 0 -> CastProtocolType.FASTCAST + 1 -> CastProtocolType.CHROMECAST + 2 -> CastProtocolType.AIRPLAY + else -> { + _textError.text = "Device type is invalid expected values like FastCast or ChromeCast."; + _textError.visibility = View.VISIBLE; + return@setOnClickListener; + } + }; + + val name = _editName.text.toString().trim(); + if (name.isNullOrBlank()) { + _textError.text = "Name can not be empty."; + _textError.visibility = View.VISIBLE; + return@setOnClickListener; + } + + val ip = _editIP.text.toString().trim(); + if (ip.isNullOrBlank()) { + _textError.text = "IP can not be empty."; + _textError.visibility = View.VISIBLE; + return@setOnClickListener; + } + + val address = ip.toInetAddress(); + if (address == null) { + _textError.text = "IP address is invalid, expected an IPv4 or IPv6 address."; + _textError.visibility = View.VISIBLE; + return@setOnClickListener; + } + + val port: UShort? = _editPort.text.toString().trim().toUShortOrNull(); + if (port == null) { + _textError.text = "Port number is invalid, expected a number between 0 and 65535."; + _textError.visibility = View.VISIBLE; + return@setOnClickListener; + } + + _textError.visibility = View.GONE; + val castingDeviceInfo = CastingDeviceInfo(name, castProtocolType, arrayOf(ip), port.toInt()); + StateCasting.instance.addRememberedDevice(castingDeviceInfo); + performDismiss(); + }; + } + + override fun show() { + super.show(); + + _spinnerType.setSelection(0); + _editPort.text.clear(); + _editPort.text.append("46899"); + _editIP.text.clear(); + _editName.text.clear(); + _textError.visibility = View.GONE; + + window?.apply { + clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) + clearFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) + setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) + }; + } + + private fun performDismiss(shouldShowCastingDialog: Boolean = true) { + if (shouldShowCastingDialog) { + UIDialogs.showCastingDialog(context); + } + + dismiss(); + } + + companion object { + private val TAG = "CastingAddDialog"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ChangelogDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ChangelogDialog.kt new file mode 100644 index 00000000..38f4f65a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ChangelogDialog.kt @@ -0,0 +1,134 @@ +package com.futo.platformplayer.dialogs + +import android.app.AlertDialog +import android.app.PendingIntent.* +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.graphics.drawable.Animatable +import android.os.Bundle +import android.text.method.ScrollingMovementMethod +import android.view.LayoutInflater +import android.view.View +import android.view.WindowManager +import android.widget.Button +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.lifecycle.lifecycleScope +import com.futo.platformplayer.* +import com.futo.platformplayer.receivers.InstallReceiver +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StateUpdate +import kotlinx.coroutines.* +import java.io.File +import java.io.InputStream + +class ChangelogDialog(context: Context?) : AlertDialog(context) { + companion object { + private val TAG = "ChangelogDialog"; + } + + private lateinit var _textVersion: TextView; + private lateinit var _textChangelog: TextView; + private lateinit var _buttonPrevious: Button; + private lateinit var _buttonNext: Button; + private lateinit var _buttonClose: Button; + private lateinit var _buttonUpdate: LinearLayout; + private lateinit var _imageSpinner: ImageView; + private var _isLoading: Boolean = false; + private var _version: Int = 0; + private var _maxVersion: Int = 0; + private var _managedHttpClient = ManagedHttpClient(); + + private val _taskDownloadChangelog = TaskHandler(StateApp.instance.scopeGetter, { version -> StateUpdate.instance.downloadChangelog(_managedHttpClient, version) }) + .success { setChangelog(it); } + .exception { + Logger.w(TAG, "Failed to load changelog.", it); + setChangelog(null); + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_changelog, null)); + + _textVersion = findViewById(R.id.text_version); + _textChangelog = findViewById(R.id.text_changelog); + _buttonPrevious = findViewById(R.id.button_previous); + _buttonNext = findViewById(R.id.button_next); + _buttonClose = findViewById(R.id.button_close); + _buttonUpdate = findViewById(R.id.button_update); + _imageSpinner = findViewById(R.id.image_spinner); + + _textChangelog.movementMethod = ScrollingMovementMethod(); + + _buttonPrevious.setOnClickListener { + setVersion(Math.max(0, _version - 1)); + }; + + _buttonNext.setOnClickListener { + setVersion(Math.min(_maxVersion, _version + 1)); + }; + + _buttonClose.setOnClickListener { + dismiss(); + }; + + _buttonUpdate.setOnClickListener { + UIDialogs.showUpdateAvailableDialog(context, _maxVersion); + dismiss(); + }; + } + + override fun dismiss() { + _taskDownloadChangelog.cancel(); + super.dismiss() + } + + fun setMaxVersion(version: Int) { + _maxVersion = version; + setVersion(version); + + val currentVersion = BuildConfig.VERSION_CODE; + _buttonUpdate.visibility = if (currentVersion == _maxVersion) View.GONE else View.VISIBLE; + } + + private fun setVersion(version: Int) { + if (_version == version) { + return; + } + + _version = version; + _buttonPrevious.visibility = if (_version == 0) View.GONE else View.VISIBLE; + _buttonNext.visibility = if (_version == _maxVersion) View.GONE else View.VISIBLE; + _textVersion.text = version.toString(); + setIsLoading(true); + _taskDownloadChangelog.run(_version); + } + + private fun setChangelog(text: String?) { + _textChangelog.text = text ?: "There is no changelog available for this version."; + setIsLoading(false); + } + + private fun setIsLoading(isLoading: Boolean) { + if (isLoading) { + _imageSpinner.visibility = View.VISIBLE; + _textChangelog.visibility = View.GONE; + (_imageSpinner.drawable as Animatable?)?.start(); + } else { + (_imageSpinner.drawable as Animatable?)?.stop(); + _imageSpinner.visibility = View.GONE; + _textChangelog.visibility = View.VISIBLE; + } + + _isLoading = false; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/CommentDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/CommentDialog.kt new file mode 100644 index 00000000..1d401348 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/dialogs/CommentDialog.kt @@ -0,0 +1,104 @@ +package com.futo.platformplayer.dialogs + +import android.app.AlertDialog +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.WindowManager +import android.view.inputmethod.InputMethodManager +import android.widget.* +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.dp +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.selectBestImage +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePolycentric +import com.futo.polycentric.core.* +import com.google.android.material.button.MaterialButton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import userpackage.Protocol +import java.time.OffsetDateTime + + +class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol.Reference) : AlertDialog(context) { + private lateinit var _buttonCreate: LinearLayout; + private lateinit var _buttonCancel: MaterialButton; + private lateinit var _editComment: EditText; + private lateinit var _inputMethodManager: InputMethodManager; + + val onCommentAdded = Event1(); + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_comment, null)); + + _buttonCancel = findViewById(R.id.button_cancel); + _buttonCreate = findViewById(R.id.button_create); + _editComment = findViewById(R.id.edit_comment); + + _inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager; + + _buttonCancel.setOnClickListener { + clearFocus(); + dismiss(); + }; + + _buttonCreate.setOnClickListener { + clearFocus(); + + val comment = _editComment.text.toString(); + val processHandle = StatePolycentric.instance.processHandle!! + val eventPointer = processHandle.post(comment, null, ref) + + StateApp.instance.scopeGetter().launch(Dispatchers.IO) { + try { + processHandle.fullyBackfillServers() + } catch (e: Throwable) { + Logger.e(TAG, "Failed to backfill servers.", e); + } + } + val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(processHandle.system)) + val dp_25 = 25.dp(context.resources) + onCommentAdded.emit(PolycentricPlatformComment( + contextUrl = contextUrl, + author = PlatformAuthorLink( + id = PlatformID("polycentric", processHandle.system.systemToURLInfoSystemLinkUrl(systemState.servers.toList()), null, ClaimType.POLYCENTRIC.value.toInt()), + name = systemState.username, + url = processHandle.system.systemToURLInfoSystemLinkUrl(systemState.servers.toList()), + thumbnail = systemState.avatar.selectBestImage(dp_25 * dp_25)?.toURLInfoSystemLinkUrl(processHandle, systemState.servers.toList()), + subscribers = null + ), + msg = comment, + rating = RatingLikeDislikes(0, 0), + date = OffsetDateTime.now(), + reference = eventPointer.toReference() + )); + + dismiss(); + }; + + window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM); + focus(); + } + + private fun focus() { + _editComment.requestFocus(); + window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE); + } + + private fun clearFocus() { + _editComment.clearFocus(); + currentFocus?.let { _inputMethodManager.hideSoftInputFromWindow(it.windowToken, 0) }; + } + + companion object { + private val TAG = "CommentDialog"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt new file mode 100644 index 00000000..dca38091 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt @@ -0,0 +1,160 @@ +package com.futo.platformplayer.dialogs + +import android.app.AlertDialog +import android.content.Context +import android.graphics.drawable.Animatable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.widget.Button +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.casting.CastConnectionState +import com.futo.platformplayer.casting.CastingDevice +import com.futo.platformplayer.casting.StateCasting +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.views.adapters.DeviceAdapter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import java.util.UUID + +class ConnectCastingDialog(context: Context?) : AlertDialog(context) { + private lateinit var _imageLoader: ImageView; + private lateinit var _buttonClose: Button; + private lateinit var _buttonAdd: Button; + private lateinit var _textNoDevicesFound: TextView; + private lateinit var _textNoDevicesRemembered: TextView; + private lateinit var _recyclerDevices: RecyclerView; + private lateinit var _recyclerRememberedDevices: RecyclerView; + private lateinit var _adapter: DeviceAdapter; + private lateinit var _rememberedAdapter: DeviceAdapter; + private val _devices: ArrayList = arrayListOf(); + private val _rememberedDevices: ArrayList = arrayListOf(); + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_casting_connect, null)); + + _imageLoader = findViewById(R.id.image_loader); + _buttonClose = findViewById(R.id.button_close); + _buttonAdd = findViewById(R.id.button_add); + _recyclerDevices = findViewById(R.id.recycler_devices); + _recyclerRememberedDevices = findViewById(R.id.recycler_remembered_devices); + _textNoDevicesFound = findViewById(R.id.text_no_devices_found); + _textNoDevicesRemembered = findViewById(R.id.text_no_devices_remembered); + + _adapter = DeviceAdapter(_devices, false); + _recyclerDevices.adapter = _adapter; + _recyclerDevices.layoutManager = LinearLayoutManager(context); + + _rememberedAdapter = DeviceAdapter(_rememberedDevices, true); + _rememberedAdapter.onRemove.subscribe { d -> + if (StateCasting.instance.activeDevice == d) { + d.stopCasting(); + } + + StateCasting.instance.removeRememberedDevice(d); + val index = _rememberedDevices.indexOf(d); + if (index != -1) { + _rememberedDevices.removeAt(index); + _rememberedAdapter.notifyItemRemoved(index); + } + + _textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE; + _recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE; + }; + _recyclerRememberedDevices.adapter = _rememberedAdapter; + _recyclerRememberedDevices.layoutManager = LinearLayoutManager(context); + + _buttonClose.setOnClickListener { dismiss(); }; + _buttonAdd.setOnClickListener { + UIDialogs.showCastingAddDialog(context); + dismiss(); + }; + } + + override fun show() { + super.show(); + Logger.i(TAG, "Dialog shown."); + + (_imageLoader.drawable as Animatable?)?.start(); + + _devices.clear(); + synchronized (StateCasting.instance.devices) { + _devices.addAll(StateCasting.instance.devices.values); + } + + _rememberedDevices.clear(); + synchronized (StateCasting.instance.rememberedDevices) { + _rememberedDevices.addAll(StateCasting.instance.rememberedDevices); + } + + _textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE; + _recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE; + _textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE; + _recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE; + + StateCasting.instance.onDeviceAdded.subscribe(this) { d -> + _devices.add(d); + _adapter.notifyItemInserted(_devices.size - 1); + _textNoDevicesFound.visibility = View.GONE; + _recyclerDevices.visibility = View.VISIBLE; + }; + + StateCasting.instance.onDeviceChanged.subscribe(this) { d -> + val index = _devices.indexOf(d); + if (index == -1) { + return@subscribe; + } + + _devices[index] = d; + _adapter.notifyItemChanged(index); + }; + + StateCasting.instance.onDeviceRemoved.subscribe(this) { d -> + val index = _devices.indexOf(d); + if (index == -1) { + return@subscribe; + } + + _devices.removeAt(index); + _adapter.notifyItemRemoved(index); + _textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE; + _recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE; + }; + + StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState -> + if (connectionState != CastConnectionState.CONNECTED) { + return@subscribe; + } + + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { + dismiss(); + }; + }; + + _adapter.notifyDataSetChanged(); + _rememberedAdapter.notifyDataSetChanged(); + } + + override fun dismiss() { + super.dismiss(); + + (_imageLoader.drawable as Animatable?)?.stop(); + + StateCasting.instance.onDeviceAdded.remove(this); + StateCasting.instance.onDeviceChanged.remove(this); + StateCasting.instance.onDeviceRemoved.remove(this); + StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); + } + + companion object { + private val TAG = "CastingDialog"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt new file mode 100644 index 00000000..878a14d9 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt @@ -0,0 +1,128 @@ +package com.futo.platformplayer.dialogs + +import android.app.AlertDialog +import android.content.Context +import android.graphics.drawable.Animatable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.widget.Button +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.R +import com.futo.platformplayer.casting.* +import com.futo.platformplayer.states.StateApp +import com.google.android.material.slider.Slider +import com.google.android.material.slider.Slider.OnChangeListener +import com.google.android.material.slider.Slider.OnSliderTouchListener +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { + private lateinit var _buttonClose: Button; + private lateinit var _imageLoader: ImageView; + private lateinit var _imageDevice: ImageView; + private lateinit var _textName: TextView; + private lateinit var _textType: TextView; + private lateinit var _buttonDisconnect: LinearLayout; + private lateinit var _sliderVolume: Slider; + private lateinit var _layoutVolumeAdjustable: LinearLayout; + private lateinit var _layoutVolumeFixed: LinearLayout; + private var _device: CastingDevice? = null; + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_casting_connected, null)); + + _imageLoader = findViewById(R.id.image_loader); + _buttonClose = findViewById(R.id.button_close); + _imageDevice = findViewById(R.id.image_device); + _textName = findViewById(R.id.text_name); + _textType = findViewById(R.id.text_type); + _buttonDisconnect = findViewById(R.id.button_disconnect); + _sliderVolume = findViewById(R.id.slider_volume); + _layoutVolumeAdjustable = findViewById(R.id.layout_volume_adjustable); + _layoutVolumeFixed = findViewById(R.id.layout_volume_fixed); + + _buttonClose.setOnClickListener { dismiss(); }; + _buttonDisconnect.setOnClickListener { + StateCasting.instance.activeDevice?.stopCasting(); + dismiss(); + }; + + _sliderVolume.addOnChangeListener(OnChangeListener { _, value, _ -> StateCasting.instance.activeDevice?.changeVolume(value.toDouble()); }); + + setLoading(false); + updateDevice(); + } + + override fun show() { + super.show(); + Logger.i(TAG, "Dialog shown."); + + _device?.onVolumeChanged?.remove(this); + _device?.onVolumeChanged?.subscribe { + _sliderVolume.value = it.toFloat(); + }; + + _device = StateCasting.instance.activeDevice; + val d = _device; + val isConnected = d != null && d.connectionState == CastConnectionState.CONNECTED; + setLoading(!isConnected); + StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState -> + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { setLoading(connectionState != CastConnectionState.CONNECTED); }; + }; + + updateDevice(); + } + + override fun dismiss() { + super.dismiss(); + _device?.onVolumeChanged?.remove(this); + _device = null; + StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); + } + + private fun updateDevice() { + val d = StateCasting.instance.activeDevice ?: return; + + if (d is ChromecastCastingDevice) { + _imageDevice.setImageResource(R.drawable.ic_chromecast); + _textType.text = "Chromecast"; + } else if (d is AirPlayCastingDevice) { + _imageDevice.setImageResource(R.drawable.ic_airplay); + _textType.text = "AirPlay"; + } else if (d is FastCastCastingDevice) { + _imageDevice.setImageResource(R.drawable.ic_fc); + _textType.text = "FastCast"; + } + + _textName.text = d.name; + _sliderVolume.value = d.volume.toFloat(); + + if (d.canSetVolume) { + _layoutVolumeAdjustable.visibility = View.VISIBLE; + _layoutVolumeFixed.visibility = View.GONE; + } else { + _layoutVolumeAdjustable.visibility = View.GONE; + _layoutVolumeFixed.visibility = View.VISIBLE; + } + } + + private fun setLoading(isLoading: Boolean) { + if (isLoading) { + _imageLoader.visibility = View.VISIBLE; + (_imageLoader.drawable as Animatable?)?.start(); + } else { + (_imageLoader.drawable as Animatable?)?.stop(); + _imageLoader.visibility = View.GONE; + } + } + + companion object { + private val TAG = "CastingDialog"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ImportDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ImportDialog.kt new file mode 100644 index 00000000..62305de1 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ImportDialog.kt @@ -0,0 +1,210 @@ +package com.futo.platformplayer.dialogs + +import android.app.AlertDialog +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.Animatable +import android.os.Bundle +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import android.text.method.ScrollingMovementMethod +import android.view.LayoutInflater +import android.view.View +import android.view.WindowManager +import android.widget.Button +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.* +import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.stores.v2.ManagedStore +import kotlinx.coroutines.* + +class ImportDialog : AlertDialog { + companion object { + private val TAG = "ImportDialog"; + } + private val _context: Context; + + private lateinit var _buttonCancel: Button; + private lateinit var _buttonImport: LinearLayout; + + private lateinit var _buttonCancelImport: Button; + private lateinit var _buttonOk: LinearLayout; + private lateinit var _buttonRetry: Button; + + private lateinit var _import_name_text: TextView; + private lateinit var _import_type_text: TextView; + + private lateinit var _import_result_restored_text: TextView; + private lateinit var _import_result_failed_text: TextView; + private lateinit var _import_result_fplugin_text: TextView; + private lateinit var _import_result_failed_count_text: TextView; + + private lateinit var _uiChoiceTop: FrameLayout; + private lateinit var _uiProgressTop: FrameLayout; + + private lateinit var _uiChoiceBot: LinearLayout; + private lateinit var _uiResultBot: LinearLayout; + + private lateinit var _textProgress: TextView; + private lateinit var _updateSpinner: ImageView; + + private var _isImport: Boolean = false; + + private val _store: ManagedStore<*>; + private val _onConcluded: ()->Unit; + + private val _name: String; + private val _toImport: List; + + + constructor(context: Context, importStore: ManagedStore<*>, name: String, toReconstruct: List, onConcluded: ()->Unit): super(context) { + _context = context; + _store = importStore; + _onConcluded = onConcluded; + _name = name; + _toImport = ArrayList(toReconstruct); + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_import, null)); + + _buttonCancel = findViewById(R.id.button_cancel); + _buttonImport = findViewById(R.id.button_import); + + _buttonOk = findViewById(R.id.button_ok); + _buttonCancelImport = findViewById(R.id.button_cancel_import); + _buttonRetry = findViewById(R.id.button_retry); + + _import_type_text = findViewById(R.id.import_type_text); + _import_name_text = findViewById(R.id.import_name_text); + + _import_result_restored_text = findViewById(R.id.import_result_restored_text); + _import_result_failed_text = findViewById(R.id.import_result_failed_text); + _import_result_fplugin_text = findViewById(R.id.import_result_fplugin_text); + _import_result_failed_count_text = findViewById(R.id.import_result_failed_count_text); + + _uiChoiceTop = findViewById(R.id.dialog_ui_choice_top); + _uiProgressTop = findViewById(R.id.dialog_ui_progress_top); + + _uiChoiceBot = findViewById(R.id.dialog_ui_bottom_choice); + _uiResultBot = findViewById(R.id.dialog_ui_bottom_result) + + _textProgress = findViewById(R.id.text_progress); + _updateSpinner = findViewById(R.id.update_spinner); + + val toMigrateCount = _store.getMissingReconstructionCount(); + _import_type_text.text = _store.name; + _import_name_text.text = _name; + + _import_result_failed_text.movementMethod = ScrollingMovementMethod.getInstance() + + _buttonCancel.setOnClickListener { + dismiss(); + }; + _buttonImport.setOnClickListener { + if (_isImport) + return@setOnClickListener; + _isImport = true; + import(); + }; + + _buttonRetry.setOnClickListener { + import(); + }; + } + + override fun dismiss() { + super.dismiss(); + _onConcluded.invoke(); + } + + private fun import() { + _uiChoiceTop.visibility = View.GONE; + _uiChoiceBot.visibility = View.GONE; + _uiResultBot.visibility = View.GONE; + _uiProgressTop.visibility = View.VISIBLE; + _textProgress.text = "0/${_store.getMissingReconstructionCount()}"; + + setCancelable(false); + setCanceledOnTouchOutside(false); + window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + + _updateSpinner.drawable?.assume()?.start(); + + val scope = StateApp.instance.scopeOrNull; + scope?.launch(Dispatchers.IO) { + try { + val migrationResult = _store.importReconstructions(_toImport) { finished, total -> + scope.launch(Dispatchers.Main) { + _textProgress.text = "${finished}/${total}"; + } + }; + + withContext(Dispatchers.Main) { + try { + val realFailures = migrationResult.exceptions.filter { it !is NoPlatformClientException }; + val pluginFailures = migrationResult.exceptions.filter { it is NoPlatformClientException }; + + _import_result_restored_text.text = "Imported ${migrationResult.success} items"; + _import_result_fplugin_text.visibility = View.GONE; + + if(realFailures.isNotEmpty() || migrationResult.messages.isNotEmpty()) { + val messagesText = migrationResult.messages.map { it }.joinToString("\n") + (if(migrationResult.messages.isNotEmpty()) "\n" else ""); + val errorText = realFailures.map { it.message }.joinToString("\n"); + val spannable = SpannableString(messagesText + errorText); + spannable.setSpan(ForegroundColorSpan(Color.WHITE), 0, messagesText.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + spannable.setSpan(ForegroundColorSpan(Color.RED), messagesText.length, spannable.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + + _import_result_failed_text.text = spannable + _import_result_failed_text.visibility = View.VISIBLE; + } + else + _import_result_failed_text.visibility = View.GONE; + + if (realFailures.isEmpty()) { + _import_result_failed_count_text.visibility = View.GONE; + _buttonCancelImport.visibility = View.GONE; + _buttonRetry.visibility = View.GONE; + } else { + _import_result_failed_count_text.visibility = View.VISIBLE; + _import_result_failed_count_text.text = "(${migrationResult.exceptions.size} failed)" + _buttonCancelImport.visibility = View.VISIBLE; + _buttonRetry.visibility = View.VISIBLE; + } + + if(pluginFailures.isEmpty()) { + _import_result_fplugin_text.visibility = View.GONE; + } else { + _import_result_fplugin_text.visibility = View.VISIBLE; + _import_result_fplugin_text.text = "Plugin not enabled for ${pluginFailures} items"; + } + + _buttonCancelImport.setOnClickListener { + dismiss(); + }; + _buttonOk.setOnClickListener { + if(migrationResult.exceptions.size > 0) + UIDialogs.toast(_context, "${migrationResult.exceptions.size} items will be invisible\nWe will ask again next boot"); + dismiss(); + } + + _uiProgressTop.visibility = View.GONE; + _uiChoiceTop.visibility = View.VISIBLE; + _uiResultBot.visibility = View.VISIBLE; + } catch (e: Throwable) { + Logger.e(TAG, "Failed to update import UI.", e) + } + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to import reconstruction.", e) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/MigrateDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/MigrateDialog.kt new file mode 100644 index 00000000..e933a425 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/dialogs/MigrateDialog.kt @@ -0,0 +1,223 @@ +package com.futo.platformplayer.dialogs + +import android.app.AlertDialog +import android.app.PendingIntent.* +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.graphics.Color +import android.graphics.drawable.Animatable +import android.os.Bundle +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import android.text.method.ScrollingMovementMethod +import android.view.LayoutInflater +import android.view.View +import android.view.WindowManager +import android.widget.Button +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.* +import com.futo.platformplayer.receivers.InstallReceiver +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateUpdate +import com.futo.platformplayer.stores.v2.ManagedStore +import kotlinx.coroutines.* + +class MigrateDialog : AlertDialog { + companion object { + private val TAG = "MigrateDialog"; + } + private val _context: Context; + + private lateinit var _buttonIgnore: Button; + private lateinit var _buttonDelete: Button; + private lateinit var _buttonRestore: LinearLayout; + + private lateinit var _buttonDeleteFailed: Button; + private lateinit var _buttonOk: LinearLayout; + private lateinit var _buttonRetry: Button; + + private lateinit var _migrate_type_text: TextView; + private lateinit var _migrate_count_text: TextView; + + private lateinit var _migrate_result_restored_text: TextView; + private lateinit var _migrate_result_failed_text: TextView; + private lateinit var _migrate_result_fplugin_text: TextView; + private lateinit var _migrate_result_failed_count_text: TextView; + + private lateinit var _uiChoiceTop: FrameLayout; + private lateinit var _uiProgressTop: FrameLayout; + + private lateinit var _uiChoiceBot: LinearLayout; + private lateinit var _uiResultBot: LinearLayout; + + private lateinit var _textProgress: TextView; + private lateinit var _updateSpinner: ImageView; + + private var _isRestoring: Boolean = false; + + private val _store: ManagedStore<*>; + private val _onConcluded: ()->Unit; + + + constructor(context: Context, toMigrate: ManagedStore<*>, onConcluded: ()->Unit): super(context) { + _context = context; + _store = toMigrate; + _onConcluded = onConcluded; + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_migrate, null)); + + _buttonIgnore = findViewById(R.id.button_ignore); + _buttonDelete = findViewById(R.id.button_delete); + _buttonRestore = findViewById(R.id.button_restore); + + _buttonOk = findViewById(R.id.button_ok); + _buttonRetry = findViewById(R.id.button_retry); + _buttonDeleteFailed = findViewById(R.id.button_delete_failed); + + _migrate_type_text = findViewById(R.id.migrate_type_text); + _migrate_count_text = findViewById(R.id.migrate_count_text); + + _migrate_result_restored_text = findViewById(R.id.migrate_result_restored_text); + _migrate_result_failed_text = findViewById(R.id.migrate_result_failed_text); + _migrate_result_fplugin_text = findViewById(R.id.migrate_result_fplugin_text); + _migrate_result_failed_count_text = findViewById(R.id.migrate_result_failed_count_text); + + _uiChoiceTop = findViewById(R.id.dialog_ui_choice_top); + _uiProgressTop = findViewById(R.id.dialog_ui_progress_top); + + _uiChoiceBot = findViewById(R.id.dialog_ui_bottom_choice); + _uiResultBot = findViewById(R.id.dialog_ui_bottom_result) + + _textProgress = findViewById(R.id.text_progress); + _updateSpinner = findViewById(R.id.update_spinner); + + val toMigrateCount = _store.getMissingReconstructionCount(); + _migrate_type_text.text = _store.name; + _migrate_count_text.text = "${toMigrateCount} items"; + + _migrate_result_failed_text.movementMethod = ScrollingMovementMethod.getInstance() + + _buttonIgnore.setOnClickListener { + UIDialogs.toast(_context, "${toMigrateCount} items will be invisible\nWe will ask again next boot"); + dismiss(); + }; + _buttonDelete.setOnClickListener { + _store.deleteMissing(); + UIDialogs.toast(_context, "Deleted ${toMigrateCount} failed items"); + dismiss(); + }; + _buttonRestore.setOnClickListener { + if (_isRestoring) + return@setOnClickListener; + _isRestoring = true; + restore(); + }; + + _buttonRetry.setOnClickListener { + restore(); + }; + } + + override fun dismiss() { + super.dismiss(); + _onConcluded.invoke(); + } + + private fun restore() { + _uiChoiceTop.visibility = View.GONE; + _uiChoiceBot.visibility = View.GONE; + _uiResultBot.visibility = View.GONE; + _uiProgressTop.visibility = View.VISIBLE; + + _textProgress.text = "0/${_store.getMissingReconstructionCount()}"; + + setCancelable(false); + setCanceledOnTouchOutside(false); + window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + + _updateSpinner.drawable?.assume()?.start(); + + val scope = StateApp.instance.scopeOrNull; + scope?.launch(Dispatchers.IO) { + try { + val migrationResult = _store.reconstructMissing { finished, total -> + scope.launch(Dispatchers.Main) { + _textProgress.text = "${finished}/${total}"; + } + }; + + withContext(Dispatchers.Main) { + try { + val realFailures = migrationResult.exceptions.filter { it !is NoPlatformClientException }; + val pluginFailures = migrationResult.exceptions.filter { it is NoPlatformClientException }; + + _migrate_result_restored_text.text = "Restored ${migrationResult.success} items"; + _migrate_result_fplugin_text.visibility = View.GONE; + + if(realFailures.isNotEmpty() || migrationResult.messages.isNotEmpty()) { + val messagesText = migrationResult.messages.map { it }.joinToString("\n") + (if(migrationResult.messages.isNotEmpty()) "\n" else ""); + val errorText = realFailures.map { it.message }.joinToString("\n"); + val spannable = SpannableString(messagesText + errorText); + spannable.setSpan(ForegroundColorSpan(Color.WHITE), 0, messagesText.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + spannable.setSpan(ForegroundColorSpan(Color.RED), messagesText.length, spannable.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + + _migrate_result_failed_text.text = spannable + _migrate_result_failed_text.visibility = View.VISIBLE; + } + else + _migrate_result_failed_text.visibility = View.GONE; + + + if (realFailures.isEmpty()) { + _migrate_result_failed_count_text.visibility = View.GONE; + _buttonDeleteFailed.visibility = View.GONE; + _buttonRetry.visibility = View.GONE; + } else { + _migrate_result_failed_count_text.visibility = View.VISIBLE; + _migrate_result_failed_count_text.text = "(${migrationResult.exceptions.size} failed)" + _buttonDeleteFailed.visibility = View.VISIBLE; + _buttonRetry.visibility = View.VISIBLE; + } + + if(pluginFailures.isEmpty()) { + _migrate_result_fplugin_text.visibility = View.GONE; + } else { + _migrate_result_fplugin_text.visibility = View.VISIBLE; + _migrate_result_fplugin_text.text = "Plugin not enabled for ${pluginFailures} items"; + } + + _buttonDeleteFailed.setOnClickListener { + _store.deleteMissing(); + UIDialogs.toast(_context, "Deleted ${realFailures} failed items", false); + dismiss(); + }; + _buttonOk.setOnClickListener { + if(migrationResult.exceptions.size > 0) + UIDialogs.toast(_context, "${migrationResult.exceptions.size} items will be invisible\nWe will ask again next boot"); + dismiss(); + } + + _uiProgressTop.visibility = View.GONE; + _uiChoiceTop.visibility = View.VISIBLE; + _uiResultBot.visibility = View.VISIBLE; + } catch (e: Throwable) { + Logger.e(TAG, "Failed to update import UI.", e) + } + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to import reconstruction.", e) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ProgressDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ProgressDialog.kt new file mode 100644 index 00000000..fea26588 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ProgressDialog.kt @@ -0,0 +1,62 @@ +package com.futo.platformplayer.dialogs + +import android.app.AlertDialog +import android.app.PendingIntent.* +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.graphics.drawable.Animatable +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.widget.Button +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.receivers.InstallReceiver +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.states.StateApp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ProgressDialog : AlertDialog { + companion object { + private val TAG = "AutoUpdateDialog"; + } + + private lateinit var _text: TextView; + private lateinit var _textProgress: TextView; + private lateinit var _updateSpinner: ImageView; + + private val _handler: ((ProgressDialog) -> Unit); + + constructor(context: Context, act: ((ProgressDialog) -> Unit)) : super(context) { + _handler = act; + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_progress, null)); + + _text = findViewById(R.id.text_dialog); + _textProgress = findViewById(R.id.text_progress); + _updateSpinner = findViewById(R.id.update_spinner); + setCancelable(false); + setCanceledOnTouchOutside(false); + _text.text = ""; + (_updateSpinner?.drawable as Animatable?)?.start(); + + _handler(this); + } + + fun setProgress(progress: Float) { setProgress(progress.toDouble()); } + fun setProgress(progress: Double) { _textProgress.text = "${Math.floor((progress * 100)).toInt()}%" } + fun setProgress(percentage: String) { _textProgress.text = percentage; } + fun setText(str: String) { _text.text = str; } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/downloads/PlaylistDownloadDescriptor.kt b/app/src/main/java/com/futo/platformplayer/downloads/PlaylistDownloadDescriptor.kt new file mode 100644 index 00000000..cbbd195a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/downloads/PlaylistDownloadDescriptor.kt @@ -0,0 +1,8 @@ +package com.futo.platformplayer.downloads + +@kotlinx.serialization.Serializable +data class PlaylistDownloadDescriptor( + val id: String, + val targetPxCount: Long?, + val targetBitrate: Long? +); \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt new file mode 100644 index 00000000..16e4dfbb --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -0,0 +1,593 @@ +package com.futo.platformplayer.downloads + +import com.futo.platformplayer.Settings +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateDownloads +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.streams.sources.* +import com.futo.platformplayer.api.media.models.streams.sources.other.IStreamMetaDataSource +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName +import com.futo.platformplayer.helpers.VideoHelper +import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer +import com.futo.platformplayer.serializers.OffsetDateTimeSerializer +import com.futo.platformplayer.toHumanBitrate +import com.futo.platformplayer.toHumanBytesSpeed +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.time.OffsetDateTime +import java.util.concurrent.CancellationException +import java.util.concurrent.ForkJoinPool +import java.util.concurrent.ForkJoinTask +import java.util.concurrent.ThreadLocalRandom + +@kotlinx.serialization.Serializable +class VideoDownload { + var state: State = State.QUEUED; + + var video: SerializedPlatformVideo? = null; + var videoDetails: SerializedPlatformVideoDetails? = null; + + @kotlinx.serialization.Transient + val videoEither: IPlatformVideo get() = videoDetails ?: video ?: throw IllegalStateException("Missing video?"); + + @kotlinx.serialization.Transient + val id: PlatformID get() = videoEither.id + @kotlinx.serialization.Transient + val name: String get() = videoEither.name; + @kotlinx.serialization.Transient + val thumbnail: String? get() = videoDetails?.thumbnails?.getHQThumbnail() ?: video?.thumbnails?.getHQThumbnail(); + + var targetPixelCount: Long? = null; + var targetBitrate: Long? = null; + var videoSource: VideoUrlSource?; + var audioSource: AudioUrlSource?; + var subtitleSource: SubtitleRawSource?; + @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) + var prepareTime: OffsetDateTime? = null; + + var progress: Double = 0.0; + var isCancelled = false; + + var downloadSpeedVideo: Long = 0; + var downloadSpeedAudio: Long = 0; + val downloadSpeed: Long get() = downloadSpeedVideo + downloadSpeedAudio; + + var error: String? = null; + + var videoFilePath: String? = null; + var videoFileName: String? = null; + var videoFileSize: Long? = null; + + var audioFilePath: String? = null; + var audioFileName: String? = null; + var audioFileSize: Long? = null; + + var subtitleFilePath: String? = null; + var subtitleFileName: String? = null; + + var groupType: String? = null; + var groupID: String? = null; + + @kotlinx.serialization.Transient + val onStateChanged = Event1(); + @kotlinx.serialization.Transient + val onProgressChanged = Event1(); + + + fun changeState(newState: State) { + state = newState; + onStateChanged.emit(newState); + } + + constructor(video: IPlatformVideo, targetPixelCount: Long? = null, targetBitrate: Long? = null) { + this.video = SerializedPlatformVideo.fromVideo(video); + this.videoSource = null; + this.audioSource = null; + this.subtitleSource = null; + this.targetPixelCount = targetPixelCount; + this.targetBitrate = targetBitrate; + } + constructor(video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: SubtitleRawSource?) { + this.video = SerializedPlatformVideo.fromVideo(video); + this.videoDetails = SerializedPlatformVideoDetails.fromVideo(video, if (subtitleSource != null) listOf(subtitleSource) else listOf()); + this.videoSource = VideoUrlSource.fromUrlSource(videoSource); + this.audioSource = AudioUrlSource.fromUrlSource(audioSource); + this.subtitleSource = subtitleSource; + this.prepareTime = OffsetDateTime.now(); + } + + fun withGroup(groupType: String, groupID: String): VideoDownload { + this.groupType = groupType; + this.groupID = groupID; + return this; + } + + fun getDownloadInfo() : String { + val videoInfo = if(videoSource != null) + "${videoSource!!.width}x${videoSource!!.height} (${videoSource!!.container})" + else if(targetPixelCount != null && targetPixelCount!! > 0) { + val guessWidth = ((4 * Math.sqrt(targetPixelCount!!.toDouble())) / 3).toInt(); + val guessHeight = ((3 * Math.sqrt(targetPixelCount!!.toDouble())) / 4).toInt(); + "${guessWidth}x${guessHeight}" + } + else null; + val audioInfo = if(audioSource != null) + audioSource!!.bitrate.toHumanBitrate(); + else if(targetBitrate != null && targetBitrate!! > 0) + targetBitrate!!.toHumanBitrate(); + else null; + + val items = arrayOf(videoInfo, audioInfo).filter { it != null }; + + return items.joinToString(" • "); + } + + suspend fun prepare() { + Logger.i(TAG, "VideoDownload Prepare [${name}]"); + if(video == null && videoDetails == null) + throw IllegalStateException("Missing information for download to complete"); + if(targetPixelCount == null && targetBitrate == null && videoSource == null && audioSource == null) + throw IllegalStateException("No sources or query values set"); + + //Fetch full video object and determine source + if(video != null && videoDetails == null) { + val original = StatePlatform.instance.getContentDetails(video!!.url).await(); + if(original !is IPlatformVideoDetails) + throw IllegalStateException("Original content is not media?"); + + videoDetails = SerializedPlatformVideoDetails.fromVideo(original, if (subtitleSource != null) listOf(subtitleSource!!) else listOf()); + if(videoSource == null && targetPixelCount != null) { + val vsource = VideoHelper.selectBestVideoSource(videoDetails!!.video, targetPixelCount!!.toInt(), arrayOf()) + ?: throw IllegalStateException("Could not find a valid video source for video"); + if(vsource is IVideoUrlSource) + videoSource = VideoUrlSource.fromUrlSource(vsource); + else + throw IllegalStateException("Download video source is not a url source"); + } + + if(audioSource == null && targetBitrate != null) { + val asource = VideoHelper.selectBestAudioSource(videoDetails!!.video, arrayOf(), null, targetPixelCount) + ?: if(videoSource != null ) null + else throw IllegalStateException("Could not find a valid audio source for video"); + if(asource == null) + audioSource = null; + else if(asource is IAudioUrlSource) + audioSource = AudioUrlSource.fromUrlSource(asource); + else + throw IllegalStateException("Download audio source is not a url source"); + } + } + } + suspend fun download(client: ManagedHttpClient, onProgress: ((Double) -> Unit)? = null) = coroutineScope { + Logger.i(TAG, "VideoDownload Download [${name}]"); + if(videoDetails == null || (videoSource == null && audioSource == null)) + throw IllegalStateException("Missing information for download to complete"); + val downloadDir = StateDownloads.instance.getDownloadsDirectory(); + + if(videoDetails!!.id.value == null) + throw IllegalStateException("Video has no id"); + + if(isCancelled) throw CancellationException("Download got cancelled"); + + if(videoSource != null) { + videoFileName = "${videoDetails!!.id.value!!} [${videoSource!!.width}x${videoSource!!.height}].${videoContainerToExtension(videoSource!!.container)}".sanitizeFileName(); + videoFilePath = File(downloadDir, videoFileName!!).absolutePath; + } + if(audioSource != null) { + audioFileName = "${videoDetails!!.id.value!!} [${audioSource!!.bitrate}].${audioContainerToExtension(audioSource!!.container)}".sanitizeFileName(); + audioFilePath = File(downloadDir, audioFileName!!).absolutePath; + } + if(subtitleSource != null) { + subtitleFileName = "${videoDetails!!.id.value!!} [${subtitleSource!!.name}].${subtitleContainerToExtension(subtitleSource!!.format)}".sanitizeFileName(); + subtitleFilePath = File(downloadDir, subtitleFileName!!).absolutePath; + } + val progressLock = Object(); + val sourcesToDownload = mutableListOf>(); + + var lastVideoLength: Long = 0; + var lastVideoRead: Long = 0; + var lastAudioLength: Long = 0; + var lastAudioRead: Long = 0; + + if(videoSource != null) { + sourcesToDownload.add(async { + Logger.i(TAG, "Started downloading video"); + videoFileSize = downloadSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!)) { length, totalRead, speed -> + synchronized(progressLock) { + lastVideoLength = length; + lastVideoRead = totalRead; + downloadSpeedVideo = speed; + if(videoFileSize == null) + videoFileSize = lastVideoLength; + + val totalLength = lastVideoLength + lastAudioLength; + val total = lastVideoRead + lastAudioRead; + if(totalLength > 0) { + val percentage = (total / totalLength.toDouble()); + onProgress?.invoke(percentage); + progress = percentage; + onProgressChanged.emit(percentage); + } + } + } + }); + } + if(audioSource != null) { + sourcesToDownload.add(async { + Logger.i(TAG, "Started downloading audio"); + audioFileSize = downloadSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!)) { length, totalRead, speed -> + synchronized(progressLock) { + lastAudioLength = length; + lastAudioRead = totalRead; + downloadSpeedAudio = speed; + if(audioFileSize == null) + audioFileSize = lastAudioLength; + + val totalLength = lastVideoLength + lastAudioLength; + val total = lastVideoRead + lastAudioRead; + if(totalLength > 0) { + val percentage = (total / totalLength.toDouble()); + onProgress?.invoke(percentage); + progress = percentage; + onProgressChanged.emit(percentage); + } + } + } + }); + } + if (subtitleSource != null) { + sourcesToDownload.add(async { + File(downloadDir, subtitleFileName!!).writeText(subtitleSource!!._subtitles) + }); + } + + try { + awaitAll(*sourcesToDownload.toTypedArray()); + } + catch(runtimeEx: RuntimeException) { + if(runtimeEx.cause != null) + throw runtimeEx.cause!!; + else + throw runtimeEx; + } + catch(ex: Throwable) { + throw ex; + } + } + private fun downloadSource(name: String, client: ManagedHttpClient, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long { + if(targetFile.exists()) + targetFile.delete(); + + targetFile.createNewFile(); + + var sourceLength: Long? = null; + + val fileStream = FileOutputStream(targetFile); + + try{ + val head = client.tryHead(videoUrl); + if(Settings.instance.downloads.byteRangeDownload && head?.containsKey("accept-ranges") == true && head.containsKey("content-length")) + { + val concurrency = Settings.instance.downloads.getByteRangeThreadCount(); + Logger.i(TAG, "Download ${name} ByteRange Parallel (${concurrency})"); + sourceLength = head["content-length"]!!.toLong(); + onProgress(sourceLength, 0, 0); + downloadSource_Ranges(name, client, fileStream, videoUrl, sourceLength, 1024*512, concurrency, onProgress); + } + else { + Logger.i(TAG, "Download ${name} Sequential"); + sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, onProgress); + } + + Logger.i(TAG, "${name} downloadSource Finished"); + } + catch(ioex: IOException) { + if(targetFile.exists() ?: false) + targetFile.delete(); + if(ioex.message?.contains("ENOSPC") ?: false) + throw Exception("Not enough space on device", ioex); + else + throw ioex; + } + catch(ex: Throwable) { + if(targetFile.exists() ?: false) + targetFile.delete(); + throw ex; + } + finally { + fileStream?.close(); + } + return sourceLength!!; + } + private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, onProgress: (Long, Long, Long) -> Unit): Long { + val progressRate: Int = 4096 * 25; + var lastProgressCount: Int = 0; + val speedRate: Int = 4096 * 25; + var readSinceLastSpeedTest: Long = 0; + var timeSinceLastSpeedTest: Long = System.currentTimeMillis(); + + var lastSpeed: Long = 0; + + val result = client.get(url); + if (!result.isOk) + throw IllegalStateException("Failed to download source. Web[${result.code}] Error"); + if (result.body == null) + throw IllegalStateException("Failed to download source. Web[${result.code}] No response"); + + val sourceLength = result.body.contentLength(); + val sourceStream = result.body.byteStream(); + + var totalRead: Long = 0; + var read = 0; + + val buffer = ByteArray(4096); + + do { + read = sourceStream.read(buffer); + if (read < 0) + break; + + fileStream.write(buffer, 0, read); + + totalRead += read; + + readSinceLastSpeedTest += read; + if (totalRead / progressRate > lastProgressCount) { + onProgress(sourceLength, totalRead, lastSpeed); + lastProgressCount++; + } + if (readSinceLastSpeedTest > speedRate) { + val lastSpeedTime = timeSinceLastSpeedTest; + timeSinceLastSpeedTest = System.currentTimeMillis(); + val timeSince = timeSinceLastSpeedTest - lastSpeedTime; + if (timeSince > 0) + lastSpeed = (readSinceLastSpeedTest / (timeSince / 1000.0)).toLong(); + readSinceLastSpeedTest = 0; + } + + if (isCancelled) + throw IllegalStateException("Cancelled"); + } while (read > 0); + + lastSpeed = 0; + onProgress(sourceLength, totalRead, 0); + return sourceLength; + } + private fun downloadSource_Ranges(name: String, client: ManagedHttpClient, fileStream: FileOutputStream, url: String, sourceLength: Long, rangeSize: Int, concurrency: Int = 1, onProgress: (Long, Long, Long) -> Unit) { + val progressRate: Int = 4096 * 5; + var lastProgressCount: Int = 0; + val speedRate: Int = 4096 * 5; + var readSinceLastSpeedTest: Long = 0; + var timeSinceLastSpeedTest: Long = System.currentTimeMillis(); + + var lastSpeed: Long = 0; + + var reqCount = -1; + var totalRead: Long = 0; + + val pool = ForkJoinPool(concurrency); + + while(totalRead < sourceLength) { + reqCount++; + + Logger.i(TAG, "Download ${name} Batch #${reqCount} [${concurrency}] (${lastSpeed.toHumanBytesSpeed()})"); + + val byteRangeResults = requestByteRangeParallel(client, pool, url, sourceLength, concurrency, totalRead, + rangeSize, 1024 * 64); + + for(byteRange in byteRangeResults) { + val read = ((byteRange.third - byteRange.second) + 1).toInt(); + + fileStream.write(byteRange.first, 0, read); + + totalRead += read; + readSinceLastSpeedTest += read; + } + + if(readSinceLastSpeedTest > speedRate) { + val lastSpeedTime = timeSinceLastSpeedTest; + timeSinceLastSpeedTest = System.currentTimeMillis(); + val timeSince = timeSinceLastSpeedTest - lastSpeedTime; + if(timeSince > 0) + lastSpeed = (readSinceLastSpeedTest / (timeSince / 1000.0)).toLong(); + readSinceLastSpeedTest = 0; + } + if(totalRead / progressRate > lastProgressCount) { + onProgress(sourceLength, totalRead, lastSpeed); + lastProgressCount++; + } + + if(isCancelled) + throw IllegalStateException("Cancelled"); + } + onProgress(sourceLength, totalRead, 0); + } + + private fun requestByteRangeParallel(client: ManagedHttpClient, pool: ForkJoinPool, url: String, totalLength: Long, concurrency: Int, rangePosition: Long, rangeSize: Int, rangeVariance: Int = -1): List> { + val tasks = mutableListOf>>(); + var readPosition = rangePosition; + for(i in 0 until concurrency) { + if(readPosition >= totalLength - 1) + continue; + + val toRead = rangeSize + (if(rangeVariance >= 1) ThreadLocalRandom.current().nextInt(rangeVariance * -1, rangeVariance) else 0); + val rangeStart = readPosition; + val rangeEnd = if(rangeStart + toRead > totalLength) + totalLength - 1; + else readPosition + toRead; + + tasks.add(pool.submit> { + return@submit requestByteRange(client, url, rangeStart, rangeEnd); + }); + readPosition = rangeEnd + 1; + } + + return tasks.map { it.get() }; + } + private fun requestByteRange(client: ManagedHttpClient, url: String, rangeStart: Long, rangeEnd: Long): Triple { + val toRead = rangeEnd - rangeStart; + val req = client.get(url, mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}"))); + if(!req.isOk) + throw IllegalStateException("Range request failed Code [${req.code}] due to: ${req.message}"); + if(req.body == null) + throw IllegalStateException("Range request failed, No body"); + val read = req.body.contentLength(); + + if(read < toRead) + throw IllegalStateException("Byte-Range request attempted to provide less (${read} < ${toRead})"); + + return Triple(req.body.bytes(), rangeStart, rangeEnd); + } + + fun validate() { + Logger.i(TAG, "VideoDownload Validate [${name}]"); + if(videoSource != null) { + if(videoFilePath == null) + throw IllegalStateException("Missing video file name after download"); + val expectedFile = File(videoFilePath!!); + if(!expectedFile.exists()) + throw IllegalStateException("Video file missing after download"); + if(expectedFile.length() != videoFileSize) + throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}"); + } + if(audioSource != null) { + if(audioFilePath == null) + throw IllegalStateException("Missing audio file name after download"); + val expectedFile = File(audioFilePath!!); + if(!expectedFile.exists()) + throw IllegalStateException("Audio file missing after download"); + if(expectedFile.length() != audioFileSize) + throw IllegalStateException("Expected size [${audioFileSize}], but found ${expectedFile.length()}"); + } + if(subtitleSource != null) { + if(subtitleFilePath == null) + throw IllegalStateException("Missing subtitle file name after download"); + val expectedFile = File(subtitleFilePath!!); + if(!expectedFile.exists()) + throw IllegalStateException("Subtitle file missing after download"); + } + } + fun complete() { + Logger.i(TAG, "VideoDownload Complete [${name}]"); + val existing = StateDownloads.instance.getCachedVideo(id); + val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSource!!, it, videoFileSize ?: 0) }; + val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(audioSource!!, it, audioFileSize ?: 0) }; + val localSubtitleSource = subtitleFilePath?.let { LocalSubtitleSource.fromSource(subtitleSource!!, it) }; + + if(localVideoSource != null && videoSource != null && videoSource is IStreamMetaDataSource) + localVideoSource.streamMetaData = (videoSource as IStreamMetaDataSource).streamMetaData; + + if(localAudioSource != null && audioSource != null && audioSource is IStreamMetaDataSource) + localAudioSource.streamMetaData = (audioSource as IStreamMetaDataSource).streamMetaData; + + if(existing != null) { + existing.videoSerialized = videoDetails!!; + if(localVideoSource != null) { + val newVideos = ArrayList(existing.videoSource); + newVideos.add(localVideoSource); + existing.videoSource = newVideos; + } + if(localAudioSource != null) { + val newAudios = ArrayList(existing.audioSource); + newAudios.add(localAudioSource); + existing.audioSource = newAudios; + } + if (localSubtitleSource != null) { + val newSubtitles = ArrayList(existing.subtitlesSources); + newSubtitles.add(localSubtitleSource); + existing.subtitlesSources = newSubtitles; + } + StateDownloads.instance.updateCachedVideo(existing); + } + else { + val newVideo = VideoLocal(videoDetails!!); + if(localVideoSource != null) + newVideo.videoSource.add(localVideoSource); + if(localAudioSource != null) + newVideo.audioSource.add(localAudioSource); + if (localSubtitleSource != null) + newVideo.subtitlesSources.add(localSubtitleSource); + newVideo.groupID = groupID; + newVideo.groupType = groupType; + StateDownloads.instance.updateCachedVideo(newVideo); + } + } + + enum class State { + QUEUED, + PREPARING, + DOWNLOADING, + VALIDATING, + FINALIZING, + COMPLETED, + ERROR; + + override fun toString(): String { + val lowercase = super.toString().lowercase(); + if(lowercase.length == 0) + return lowercase; + return lowercase[0].uppercase() + lowercase.substring(1); + } + } + + companion object { + const val TAG = "VideoDownload"; + const val GROUP_PLAYLIST = "Playlist"; + + fun videoContainerToExtension(container: String): String? { + if (container.contains("video/mp4")) + return "mp4"; + else if (container.contains("application/x-mpegURL")) + return "m3u8"; + else if (container.contains("video/3gpp")) + return "3gp"; + else if (container.contains("video/quicktime")) + return "mov"; + else if (container.contains("video/webm")) + return "webm"; + else if (container.contains("video/x-matroska")) + return "mkv"; + else + return "video"; + } + + fun audioContainerToExtension(container: String): String { + if (container.contains("audio/mp4")) + return "mp4a"; + else if (container.contains("audio/mpeg")) + return "mpga"; + else if (container.contains("audio/mp3")) + return "mp3"; + else if (container.contains("audio/webm")) + return "webma"; + else + return "audio"; + } + + fun subtitleContainerToExtension(container: String?): String { + if (container == null) + return "subtitle"; + + if (container.contains("text/vtt")) + return "vtt"; + else if (container.contains("text/plain")) + return "srt"; + else if (container.contains("application/x-subrip")) + return "srt"; + else + return "subtitle"; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt new file mode 100644 index 00000000..987dd8c6 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt @@ -0,0 +1,261 @@ +package com.futo.platformplayer.downloads + +import android.os.Environment +import com.arthenica.ffmpegkit.* +import com.futo.platformplayer.api.media.models.streams.sources.* +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.toHumanBitrate +import kotlinx.coroutines.* +import java.io.* +import java.util.concurrent.CancellationException +import java.util.concurrent.Executors +import kotlin.coroutines.resumeWithException + +@kotlinx.serialization.Serializable +class VideoExport { + var state: State = State.QUEUED; + + var videoLocal: VideoLocal; + var videoSource: LocalVideoSource?; + var audioSource: LocalAudioSource?; + var subtitleSource: LocalSubtitleSource?; + + var progress: Double = 0.0; + var isCancelled = false; + + var error: String? = null; + + @kotlinx.serialization.Transient + val onStateChanged = Event1(); + @kotlinx.serialization.Transient + val onProgressChanged = Event1(); + + fun changeState(newState: State) { + state = newState; + onStateChanged.emit(newState); + } + + constructor(videoLocal: VideoLocal, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?) { + this.videoLocal = videoLocal; + this.videoSource = videoSource; + this.audioSource = audioSource; + this.subtitleSource = subtitleSource; + } + + suspend fun export(onProgress: ((Double) -> Unit)? = null): File = coroutineScope { + if(isCancelled) throw CancellationException("Export got cancelled"); + + val v = videoSource; + val a = audioSource; + val s = subtitleSource; + + var sourceCount = 0; + if (v != null) sourceCount++; + if (a != null) sourceCount++; + if (s != null) sourceCount++; + + var outputFile: File? = null; + val moviesRoot = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES); + val musicRoot = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC); + val moviesGrayjay = File(moviesRoot, "Grayjay"); + val musicGrayjay = File(musicRoot, "Grayjay"); + if(!moviesGrayjay.exists()) + moviesGrayjay.mkdirs(); + if(!musicGrayjay.exists()) + musicGrayjay.mkdirs(); + + if (sourceCount > 1) { + val outputFileName = toSafeFileName(videoLocal.name) + ".mp4"// + VideoDownload.videoContainerToExtension(v.container); + val f = File(moviesGrayjay, outputFileName); + + Logger.i(TAG, "Combining video and audio through FFMPEG."); + combine(a?.filePath, v?.filePath, s?.filePath, f.absolutePath, videoLocal.duration.toDouble()) { progress -> onProgress?.invoke(progress) }; + outputFile = f; + } else if (v != null) { + val outputFileName = toSafeFileName(videoLocal.name) + "." + VideoDownload.videoContainerToExtension(v.container); + val f = File(moviesGrayjay, outputFileName); + Logger.i(TAG, "Copying video."); + copy(v.filePath, f.absolutePath) { progress -> onProgress?.invoke(progress) }; + outputFile = f; + } else if (a != null) { + val outputFileName = toSafeFileName(videoLocal.name) + "." + VideoDownload.audioContainerToExtension(a.container); + val f = File(musicGrayjay, outputFileName); + Logger.i(TAG, "Copying audio."); + copy(a.filePath, f.absolutePath) { progress -> onProgress?.invoke(progress) }; + outputFile = f; + } else { + throw Exception("Cannot export when no audio or video source is set."); + } + + onProgressChanged.emit(100.0); + return@coroutineScope outputFile; + } + + private fun toSafeFileName(input: String): String { + val safeCharacters = ('a'..'z') + ('A'..'Z') + ('0'..'9') + listOf('-', '_') + return input.map { if (it in safeCharacters) it else '_' }.joinToString(separator = "") + } + + private suspend fun combine(inputPathAudio: String?, inputPathVideo: String?, inputPathSubtitles: String?, outputPath: String, duration: Double, onProgress: ((Double) -> Unit)? = null) = withContext(Dispatchers.IO) { + suspendCancellableCoroutine { continuation -> + //ffmpeg -i a.mp4 -i b.m4a -scodec mov_text -i c.vtt -map 0:v -map 1:a -map 2 -c:v copy -c:a copy -c:s mov_text output.mp4 + + val cmdBuilder = StringBuilder("-y") + var counter = 0 + + if (inputPathVideo != null) { + cmdBuilder.append(" -i $inputPathVideo") + } + if (inputPathAudio != null) { + cmdBuilder.append(" -i $inputPathAudio") + } + if (inputPathSubtitles != null) { + val subtitleExtension = File(inputPathSubtitles).extension + + val codec = when (subtitleExtension.lowercase()) { + "srt" -> "mov_text" + "vtt" -> "webvtt" + else -> throw Exception("Unsupported subtitle format: $subtitleExtension") + } + + cmdBuilder.append(" -scodec $codec -i $inputPathSubtitles") + } + + if (inputPathVideo != null) { + cmdBuilder.append(" -map ${counter++}:v") + } + if (inputPathAudio != null) { + cmdBuilder.append(" -map ${counter++}:a") + } + + if (inputPathSubtitles != null) { + cmdBuilder.append(" -map ${counter++}") + } + + if (inputPathVideo != null) { + cmdBuilder.append(" -c:v copy") + } + if (inputPathAudio != null) { + cmdBuilder.append(" -c:a copy") + } + if (inputPathAudio != null) { + cmdBuilder.append(" -c:s mov_text") + } + + cmdBuilder.append(" $outputPath") + + val cmd = cmdBuilder.toString() + Logger.i(TAG, "Used command: $cmd"); + + val statisticsCallback = StatisticsCallback { statistics -> + val time = statistics.time.toDouble() / 1000.0 + val progressPercentage = (time / duration) + onProgress?.invoke(progressPercentage) + } + + val executorService = Executors.newSingleThreadExecutor() + val session = FFmpegKit.executeAsync(cmd, + { session -> + if (ReturnCode.isSuccess(session.returnCode)) { + continuation.resumeWith(Result.success(Unit)) + } else { + val errorMessage = if (ReturnCode.isCancel(session.returnCode)) { + "Command cancelled" + } else { + "Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}" + } + continuation.resumeWithException(RuntimeException(errorMessage)) + } + }, + LogCallback { Logger.v(TAG, it.message) }, + statisticsCallback, + executorService + ) + + continuation.invokeOnCancellation { + session.cancel() + } + } + } + + private suspend fun copy(fromPath: String, toPath: String, bufferSize: Int = 8192, onProgress: ((Double) -> Unit)? = null) { + withContext(Dispatchers.IO) { + var inputStream: FileInputStream? = null + var outputStream: FileOutputStream? = null + + try { + val srcFile = File(fromPath) + if (!srcFile.exists()) { + throw IOException("Source file not found.") + } + + val dstFile = File(toPath) + val parentDir = dstFile.parentFile ?: throw IOException("Non existent parent dir.") + + if (!parentDir.exists()) { + if (!parentDir.mkdirs()) { + throw IOException("Failed to create destination directory.") + } + } + + inputStream = FileInputStream(srcFile) + outputStream = FileOutputStream(dstFile) + + val buffer = ByteArray(bufferSize) + val totalBytes = srcFile.length() + var bytesCopied: Long = 0 + + var bytesRead: Int + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + bytesCopied += bytesRead.toLong() + + onProgress?.let { + withContext(Dispatchers.Main) { + it(bytesCopied / totalBytes.toDouble()) + } + } + } + } catch (e: Exception) { + throw IOException("Error occurred while copying file: ${e.message}", e) + } finally { + inputStream?.close() + outputStream?.close() + } + } + } + + fun getExportInfo() : String { + val tokens = ArrayList(); + val v = videoSource; + if (v != null) { + tokens.add("${v.width}x${v.height} (${v.container})"); + } + + val a = audioSource; + if (a != null) { + tokens.add(a.bitrate.toHumanBitrate()); + } + + return tokens.joinToString(" • "); + } + + enum class State { + QUEUED, + EXPORTING, + COMPLETED, + ERROR; + + override fun toString(): String { + val lowercase = super.toString().lowercase(); + if(lowercase.length == 0) + return lowercase; + return lowercase[0].uppercase() + lowercase.substring(1); + } + } + + companion object { + private const val TAG = "VideoExport" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt new file mode 100644 index 00000000..bf8cb601 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt @@ -0,0 +1,116 @@ +package com.futo.platformplayer.downloads + +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.LocalVideoMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.LocalVideoUnMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.sources.* +import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.stores.v2.IStoreItem +import java.io.File +import java.time.OffsetDateTime + +//TODO: Better name +@kotlinx.serialization.Serializable +class VideoLocal: IPlatformVideoDetails, IStoreItem { + var videoSerialized: SerializedPlatformVideoDetails; + + var groupType: String? = null; + var groupID: String? = null; + + var videoSource: ArrayList = arrayListOf(); + var audioSource: ArrayList = arrayListOf(); + var subtitlesSources: ArrayList = arrayListOf(); + + override val contentType: ContentType get() = ContentType.MEDIA; + override val id: PlatformID get() = videoSerialized.id; + override val name: String get() = videoSerialized.name; + override val description: String get() = videoSerialized.description; + + override val thumbnails: Thumbnails get() = videoSerialized.thumbnails; + override val author: PlatformAuthorLink get() = videoSerialized.author; + + override val datetime: OffsetDateTime? get() = videoSerialized.datetime; + + override val url: String get() = videoSerialized.url; + override val shareUrl: String get() = videoSerialized.shareUrl; + + @kotlinx.serialization.Transient + override val video: IVideoSourceDescriptor get() = if(!audioSource.isEmpty()) + LocalVideoUnMuxedSourceDescriptor(this) + else + LocalVideoMuxedSourceDescriptor(this); + override val preview: IVideoSourceDescriptor? get() = videoSerialized.preview; + + override val live: IVideoSource? get() = videoSerialized.live; + override val dash: IDashManifestSource? get() = videoSerialized.dash; + override val hls: IHLSManifestSource? get() = videoSerialized.hls; + + override val duration: Long get() = videoSerialized.duration; + override val viewCount: Long get() = videoSerialized.viewCount; + + override val rating: IRating get() = videoSerialized.rating; + + override val isLive: Boolean get() = videoSerialized.isLive; + + //TODO: Offline subtitles + override val subtitles: List = listOf(); + + constructor(video: SerializedPlatformVideoDetails) { + this.videoSerialized = video; + } + constructor(video: IPlatformVideoDetails, subtitleSources: List) { + this.videoSerialized = SerializedPlatformVideoDetails.fromVideo(video, subtitleSources); + } + + override fun getComments(client: IPlatformClient): IPager? = null; + override fun getPlaybackTracker(): IPlaybackTracker? = null; + + fun toPlatformVideo() : IPlatformVideoDetails { + throw NotImplementedError(); + } + + fun getSimilarVideo(targetPixelCount: Int): LocalVideoSource? { + return videoSource.filter { + val px = it.height * it.width; + val diff = Math.abs(px - targetPixelCount); + val max = Math.max(targetPixelCount, px); + return@filter (diff.toFloat() / max) < 0.15f; + }.firstOrNull(); + } + fun getSimilarAudio(targetBitrate: Int): LocalAudioSource? { + return audioSource.filter { + val diff = Math.abs(it.bitrate - targetBitrate); + val max = Math.max(it.bitrate, targetBitrate); + return@filter (diff.toFloat() / max) < 0.15f; + }.firstOrNull(); + } + + override fun onDelete() { + for(srcFile in videoSource) { + val file = File(srcFile.filePath); + if (file.exists()) + file.delete(); + } + for(srcFile in audioSource) { + val file = File(srcFile.filePath); + if (file.exists()) + file.delete(); + } + for(srcFile in subtitlesSources) { + val file = File(srcFile.filePath); + if (file.exists()) + file.delete(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/encryption/EncryptionProvider.kt b/app/src/main/java/com/futo/platformplayer/encryption/EncryptionProvider.kt new file mode 100644 index 00000000..86d1d885 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/encryption/EncryptionProvider.kt @@ -0,0 +1,69 @@ +package com.futo.platformplayer.encryption + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.security.Key +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +class EncryptionProvider { + private val _keyStore: KeyStore; + private val secretKey: Key? get() = _keyStore.getKey(KEY_ALIAS, null); + + constructor() { + _keyStore = KeyStore.getInstance(AndroidKeyStore); + _keyStore.load(null); + + if (!_keyStore.containsAlias(KEY_ALIAS)) { + val keyGenerator: KeyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, AndroidKeyStore) + keyGenerator.init(KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setRandomizedEncryptionRequired(false) + .build()); + + keyGenerator.generateKey(); + } + } + + fun encrypt(decrypted: String, password: String? = null): String { + val encodedBytes = encrypt(decrypted.toByteArray(), password); + val encrypted = Base64.encodeToString(encodedBytes, Base64.DEFAULT); + return encrypted; + } + fun encrypt(decrypted: ByteArray, password: String? = null): ByteArray { + val c: Cipher = Cipher.getInstance(AES_MODE); + val keyToUse = if(password == null) secretKey else SecretKeySpec(password.toByteArray(), "AES"); + c.init(Cipher.ENCRYPT_MODE, keyToUse, GCMParameterSpec(128, FIXED_IV)); + val encodedBytes: ByteArray = c.doFinal(decrypted); + return encodedBytes; + } + + fun decrypt(encrypted: String, password: String? = null): String { + val c = Cipher.getInstance(AES_MODE); + val keyToUse = if(password == null) secretKey else SecretKeySpec(password.toByteArray(), "AES"); + c.init(Cipher.DECRYPT_MODE, keyToUse, GCMParameterSpec(128, FIXED_IV)); + val decrypted = String(c.doFinal(Base64.decode(encrypted, Base64.DEFAULT))); + return decrypted; + } + fun decrypt(encrypted: ByteArray, password: String? = null): ByteArray { + val c = Cipher.getInstance(AES_MODE); + val keyToUse = if(password == null) secretKey else SecretKeySpec(password.toByteArray(), "AES"); + c.init(Cipher.DECRYPT_MODE, keyToUse, GCMParameterSpec(128, FIXED_IV)); + return c.doFinal(encrypted); + } + + companion object { + val instance: EncryptionProvider = EncryptionProvider(); + + private val FIXED_IV = byteArrayOf(12, 43, 127, 2, 99, 22, 6, 78, 24, 53, 8, 101); + private const val AndroidKeyStore = "AndroidKeyStore"; + private const val KEY_ALIAS = "FUTOMedia_Key"; + private const val AES_MODE = "AES/GCM/NoPadding"; + private val TAG = "EncryptionProvider"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt b/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt new file mode 100644 index 00000000..e0b3bce1 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt @@ -0,0 +1,265 @@ +package com.futo.platformplayer.engine + +import android.content.Context +import com.caoccao.javet.exceptions.JavetCompilationException +import com.caoccao.javet.exceptions.JavetExecutionException +import com.caoccao.javet.interop.V8Host +import com.caoccao.javet.interop.V8Runtime +import com.caoccao.javet.values.V8Value +import com.caoccao.javet.values.primitive.V8ValueBoolean +import com.caoccao.javet.values.primitive.V8ValueInteger +import com.caoccao.javet.values.primitive.V8ValueString +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.* +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.engine.exceptions.* +import com.futo.platformplayer.engine.internal.V8Converter +import com.futo.platformplayer.engine.packages.* +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateAssets +import kotlinx.coroutines.* + +class V8Plugin { + val config: IV8PluginConfig; + private val _client: ManagedHttpClient; + private val _clientAuth: ManagedHttpClient; + + val httpClient: ManagedHttpClient get() = _client; + val httpClientAuth: ManagedHttpClient get() = _clientAuth; + + var _runtime : V8Runtime? = null; + + private val _deps : LinkedHashMap = LinkedHashMap(); + private val _depsPackages : MutableList = mutableListOf(); + private var _script : String? = null; + + val onStopped = Event1(); + + constructor(context: Context, config: IV8PluginConfig, script: String? = null, client: ManagedHttpClient = ManagedHttpClient(), clientAuth: ManagedHttpClient = ManagedHttpClient()) { + this._client = client; + this._clientAuth = clientAuth; + this.config = config; + this._script = script; + withDependency(PackageBridge(this, config)); + + for(pack in config.packages) + withDependency(getPackage(context, pack)); + } + + fun withDependency(context: Context, assetPath: String) : V8Plugin { + if(!_deps.containsKey(assetPath)) + _deps.put(assetPath, getAssetFile(context, assetPath)); + return this; + } + fun withDependency(name: String, script: String) : V8Plugin { + if(!_deps.containsKey(name)) + _deps.put(name, script); + return this; + } + fun withDependency(v8Package: V8Package) : V8Plugin { + _depsPackages.add(v8Package); + return this; + } + fun withScript(script: String) : V8Plugin { + _script = script; + return this; + } + + fun getPackages(): List { + return _depsPackages.toList(); + } + fun getPackageVariables(): List { + return _depsPackages.filter { it.variableName != null }.map { it.variableName!! }.toList(); + } + fun getPackageByVariableName(varName: String): V8Package? { + return _depsPackages.firstOrNull { it.variableName == varName }; + } + + fun start() { + val script = _script ?: throw IllegalStateException("Attempted to start V8 without script"); + synchronized(this) { + if (_runtime != null) + return; + + val host = V8Host.getV8Instance(); + val options = host.jsRuntimeType.getRuntimeOptions(); + _runtime = host.createV8Runtime(options); + if (!host.isIsolateCreated) + throw IllegalStateException("Isolate not created"); + + //Setup bridge + _runtime?.let { + it.converter = V8Converter(); + + for (pack in _depsPackages) { + if (pack.variableName != null) + it.createV8ValueObject().use { v8valueObject -> + it.globalObject.set(pack.variableName, v8valueObject); + v8valueObject.bind(pack); + }; + catchScriptErrors("Package Dep[${pack.name}]") { + for (packScript in pack.getScripts()) + it.getExecutor(packScript).executeVoid(); + } + } + + //Load deps + for (dep in _deps) + catchScriptErrors("Dep[${dep.key}]") { + it.getExecutor(dep.value).executeVoid() + }; + + + if (config.allowEval) + it.allowEval(true); + + //Load plugin + catchScriptErrors("Plugin[${config.name}]") { + it.getExecutor(script).executeVoid() + }; + } + } + } + fun stop(){ + Logger.i(TAG, "Stopping plugin [${config.name}]"); + synchronized(this) { + _runtime?.let { + _runtime = null; + if(!it.isClosed && !it.isDead) + it.close(); + }; + } + onStopped.emit(this); + } + + fun execute(js: String) : V8Value { + return executeTyped(js); + } + fun executeTyped(js: String) : T { + val runtime = _runtime ?: throw IllegalStateException("JSPlugin not started yet"); + return catchScriptErrors("Plugin[${config.name}]", js) { runtime.getExecutor(js).execute() }; + } + fun executeBoolean(js: String) : Boolean? = catchScriptErrors("Plugin[${config.name}]") { executeTyped(js).value }; + fun executeString(js: String) : String? = catchScriptErrors("Plugin[${config.name}]") { executeTyped(js).value }; + fun executeInteger(js: String) : Int? = catchScriptErrors("Plugin[${config.name}]") { executeTyped(js).value }; + + private fun getPackage(context: Context, packageName: String): V8Package { + //TODO: Auto get all package types? + return when(packageName) { + "DOMParser" -> PackageDOMParser(context, this) + "Http" -> PackageHttp(this, config) + "Utilities" -> PackageUtilities(this, config) + else -> throw ScriptCompilationException(config, "Unknown package [${packageName}] required for plugin ${config.name}"); + }; + } + + fun catchScriptErrors(context: String, code: String? = null, handle: ()->T): T { + return catchScriptErrors(this.config, context, code, handle); + } + + companion object { + private val REGEX_EX_FALLBACK = Regex(".*throw.*?[\"](.*)[\"].*"); + private val REGEX_EX_FALLBACK2 = Regex(".*throw.*?['](.*)['].*"); + + val TAG = "V8Plugin"; + + fun catchScriptErrors(config: IV8PluginConfig, context: String, code: String? = null, handle: ()->T): T { + var codeStripped = code; + if(codeStripped != null) { //TODO: Improve code stripped + if (codeStripped.contains("(") && codeStripped.contains(")")) + { + val start = codeStripped.indexOf("("); + val end = codeStripped.lastIndexOf(")"); + codeStripped = codeStripped.substring(0, start) + "(...)" + codeStripped.substring(end + 1); + } + } + try { + val result = handle(); + + if(result is V8ValueObject) { + val type = result.getString("plugin_type"); + if(type != null && type.endsWith("Exception")) + Companion.throwExceptionFromV8( + config, + result.getOrThrow(config, "plugin_type", "V8Plugin"), + result.getOrThrow(config, "message", "V8Plugin"), + null, + null, + codeStripped + ); + } + + + return result; + } + catch(scriptEx: JavetCompilationException) { + throw ScriptCompilationException(config, "Compilation: ${scriptEx.message}\n(${scriptEx.scriptingError.lineNumber})[${scriptEx.scriptingError.startColumn}-${scriptEx.scriptingError.endColumn}]: ${scriptEx.scriptingError.sourceLine}", null, codeStripped); + } + catch(executeEx: JavetExecutionException) { + val exMessage = extractJSExceptionMessage(executeEx); + + if(executeEx.scriptingError?.context?.containsKey("plugin_type") == true) + throwExceptionFromV8( + config, + executeEx.scriptingError.context["plugin_type"].toString(), + (exMessage ?: ""), + executeEx, + executeEx.scriptingError?.stack, + codeStripped + ); + + throw ScriptExecutionException(config, "${exMessage}", null, executeEx.scriptingError?.stack, codeStripped); + } + catch(ex: Exception) { + throw ex; + } + } + + private fun throwExceptionFromV8(config: IV8PluginConfig, pluginType: String, msg: String, innerEx: Exception? = null, stack: String? = null, code: String? = null) { + when(pluginType) { + "ScriptException" -> throw ScriptException(config, msg, innerEx, stack, code); + "AgeException" -> throw ScriptAgeException(config, msg, innerEx, stack, code); + "UnavailableException" -> throw ScriptUnavailableException(config, msg, innerEx, stack, code); + "ScriptExecutionException" -> throw ScriptExecutionException(config, msg, innerEx, stack, code); + "ScriptCompilationException" -> throw ScriptCompilationException(config, msg, innerEx, code); + "ScriptImplementationException" -> throw ScriptImplementationException(config, msg, innerEx, null, code); + "ScriptTimeoutException" -> throw ScriptTimeoutException(config, msg, innerEx); + "NoInternetException" -> throw NoInternetException(config, msg, innerEx, stack, code); + else -> throw ScriptExecutionException(config, msg, innerEx, stack, code); + } + } + + private fun extractJSExceptionMessage(ex: JavetExecutionException) : String? { + val lineInfo = " (${ex.scriptingError.lineNumber})[${ex.scriptingError.startColumn}-${ex.scriptingError.endColumn}]"; + + if(ex.message == null || ex.message == "") { + if(!ex.scriptingError?.message.isNullOrEmpty()) + return ex.scriptingError?.message!! + lineInfo; + else if(!ex.scriptingError?.sourceLine?.isNullOrEmpty()!!) { + val source = ex.scriptingError.sourceLine; + val matchReg1 = REGEX_EX_FALLBACK.matchEntire(source); + val matchReg2 = REGEX_EX_FALLBACK2.matchEntire(source); + if(matchReg1 != null) + return matchReg1.groupValues[1] + lineInfo; + if(matchReg2 != null) + return matchReg2.groupValues[1] + lineInfo; + } + } + else if(!ex.scriptingError?.detailedMessage.isNullOrEmpty()) + return ex.scriptingError.detailedMessage + lineInfo; + else if(!ex.scriptingError?.message.isNullOrEmpty()) + return ex.scriptingError.message + lineInfo; + return ex.message + lineInfo; + } + + private fun getAssetFile(context: Context, path: String) : String { + return StateAssets.readAsset(context, path) ?: throw java.lang.IllegalStateException("script ${path} not found"); + } + } + + + /** + * Methods available for scripts (bridge object) + */ +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/engine/V8PluginConfig.kt b/app/src/main/java/com/futo/platformplayer/engine/V8PluginConfig.kt new file mode 100644 index 00000000..df6ad4bc --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/engine/V8PluginConfig.kt @@ -0,0 +1,29 @@ +package com.futo.platformplayer.engine + +interface IV8PluginConfig { + val name: String; + val allowEval: Boolean; + val allowUrls: List; + val packages: List; +} + +@kotlinx.serialization.Serializable +class V8PluginConfig : IV8PluginConfig { + override val name: String; + override val allowEval: Boolean; + override val allowUrls: List; + override val packages: List; + + constructor() { + name = "Unknown"; + allowEval = false; + allowUrls = listOf(); + packages = listOf(); + } + constructor(name: String, allowEval: Boolean, allowUrls: List, packages: List = listOf()) { + this.name = name; + this.allowEval = allowEval; + this.allowUrls = allowUrls; + this.packages = packages; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/engine/dev/V8RemoteObject.kt b/app/src/main/java/com/futo/platformplayer/engine/dev/V8RemoteObject.kt new file mode 100644 index 00000000..9aa0c6cc --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/engine/dev/V8RemoteObject.kt @@ -0,0 +1,143 @@ +package com.futo.platformplayer.engine.dev + +import com.caoccao.javet.annotations.V8Function +import com.caoccao.javet.annotations.V8Property +import com.futo.platformplayer.logging.Logger +import com.google.gson.GsonBuilder +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonNull +import com.google.gson.JsonObject +import com.google.gson.JsonSerializationContext +import com.google.gson.JsonSerializer +import java.lang.reflect.Type +import java.util.stream.IntStream.range +import kotlin.reflect.* +import kotlin.reflect.full.declaredFunctions +import kotlin.reflect.full.hasAnnotation +import kotlin.reflect.full.instanceParameter +import kotlin.reflect.jvm.javaMethod + + +/** + * Serializable object wrapper that communicates complex V8 objects to a format understood by the dev portal + * It allows plugins in development to communicate with package logic on the phone + * It does this by embedding object ids and function names into the object, which dev portal can then intercept and call via special endpoints + */ +class V8RemoteObject { + private val _id: String; + private val _class: KClass<*>; + val obj: Any; + + val requiresRegistration: Boolean; + + constructor(id: String, obj: Any) { + this._id = id; + this._class = obj::class; + this.obj = obj; + this.requiresRegistration = getV8Functions(_class).isNotEmpty() || getV8Properties(_class).isNotEmpty(); + } + + fun prop(propName: String): Any? { + val propMethod = getV8Property(_class, propName); + return propMethod.call(obj); + } + fun call(methodName: String, array: JsonArray): Any? { + val propMethod = getV8Function(_class, methodName); + + val map = mutableMapOf(); + var instanceParaCount = 0; + for(i in range(0, propMethod.parameters.size)) { + val para = propMethod.parameters[i]; + if(para == propMethod.instanceParameter) { + map.put(para, obj); + instanceParaCount++; + } + else if(i - instanceParaCount < array.size()) + map.put(para, gsonStandard.fromJson(array.get(i - instanceParaCount), propMethod.javaMethod!!.parameterTypes[i - instanceParaCount])); + } + + return propMethod.callBy(map) + } + + + fun serialize(): String { + return _gson.toJson(this); + } + + class Serializer : JsonSerializer { + override fun serialize(src: V8RemoteObject?, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement { + try { + if (src == null) + return JsonNull.INSTANCE; + if (!src.requiresRegistration) + return gsonStandard.toJsonTree(src.obj, src.obj.javaClass); + else { + val obj = _gson.toJsonTree(src.obj) as JsonObject; + obj.addProperty("__id", src._id); + + val methodsArray = JsonArray(); + for (method in getV8Functions(src._class)) + methodsArray.add(method.name); + obj.add("__methods", methodsArray); + + val propsArray = JsonArray(); + for (method in getV8Properties(src._class)) + propsArray.add(method.name); + obj.add("__props", propsArray); + + return obj; + } + } + catch(ex: StackOverflowError) { + val msg = "Recursive structure for class [${src?._class?.simpleName}], can't serialize..: ${ex.message}"; + Logger.e("V8RemoteObject", msg); + throw IllegalArgumentException(msg); + } + } + } + + companion object { + val gsonStandard = GsonBuilder() + .serializeNulls() + .setPrettyPrinting() + .create(); + private val _gson = GsonBuilder() + .registerTypeAdapter(V8RemoteObject::class.java, Serializer()) + .create(); + + private val _classV8Functions: HashMap, List>> = hashMapOf(); + private val _classV8Props: HashMap, List>> = hashMapOf(); + + + fun getV8Functions(clazz: KClass<*>): List> { + if(!_classV8Functions.containsKey(clazz)) + _classV8Functions.put(clazz, clazz.declaredFunctions.filter { it.hasAnnotation() }.toList()); + return _classV8Functions.get(clazz)!!; + } + fun getV8Function(clazz: KClass<*>, name: String): KFunction<*> { + val functions = getV8Functions(clazz); + val method = functions.firstOrNull { it.name == name }; + if(method == null) + throw IllegalArgumentException("Non-existent property ${name}"); + return method; + } + fun getV8Properties(clazz: KClass<*>): List> { + if(!_classV8Props.containsKey(clazz)) + _classV8Props.put(clazz, clazz.declaredFunctions.filter { it.hasAnnotation() }.toList()); + return _classV8Props.get(clazz)!!; + } + fun getV8Property(clazz: KClass<*>, name: String): KFunction<*> { + val props = getV8Properties(clazz); + val method = props.firstOrNull { it.name == name }; + if(method == null) + throw IllegalArgumentException("Non-existent property ${name}"); + return method; + } + + + fun List.serialize() : String { + return _gson.toJson(this); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/engine/exceptions/NoInternetException.kt b/app/src/main/java/com/futo/platformplayer/engine/exceptions/NoInternetException.kt new file mode 100644 index 00000000..bce39025 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/engine/exceptions/NoInternetException.kt @@ -0,0 +1,17 @@ +package com.futo.platformplayer.engine.exceptions + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrThrow + +open class NoInternetException(config: IV8PluginConfig, error: String, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptException(config, error, ex, stack, code) { + + + + + companion object { + fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : NoInternetException { + return NoInternetException(config, obj.getOrThrow(config, "message", "NoInternetException")); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/engine/exceptions/PluginException.kt b/app/src/main/java/com/futo/platformplayer/engine/exceptions/PluginException.kt new file mode 100644 index 00000000..09941166 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/engine/exceptions/PluginException.kt @@ -0,0 +1,7 @@ +package com.futo.platformplayer.engine.exceptions + +import com.futo.platformplayer.engine.IV8PluginConfig + +open class PluginException(val config: IV8PluginConfig, msg: String, ex: Exception? = null, val code: String? = null): Exception("[${config.name}] " + msg, ex) { + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptAgeException.kt b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptAgeException.kt new file mode 100644 index 00000000..ef1ca13f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptAgeException.kt @@ -0,0 +1,17 @@ +package com.futo.platformplayer.engine.exceptions + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrThrow + +open class ScriptAgeException(config: IV8PluginConfig, error: String, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptException(config, error, ex, stack, code) { + + + + + companion object { + fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptException { + return ScriptException(config, obj.getOrThrow(config, "message", "ScriptAgeException")); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptCompilationException.kt b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptCompilationException.kt new file mode 100644 index 00000000..2db245d3 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptCompilationException.kt @@ -0,0 +1,14 @@ +package com.futo.platformplayer.engine.exceptions + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrThrow + +class ScriptCompilationException(config: IV8PluginConfig, error: String, ex: Exception? = null, code: String? = null) : PluginException(config, error, ex, code) { + + companion object { + fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptCompilationException { + return ScriptCompilationException(config, obj.getOrThrow(config, "message", "ScriptCompilationException")); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptException.kt b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptException.kt new file mode 100644 index 00000000..cf038a23 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptException.kt @@ -0,0 +1,17 @@ +package com.futo.platformplayer.engine.exceptions + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrThrow + +open class ScriptException(config: IV8PluginConfig, error: String, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptExecutionException(config, error, ex, stack, code) { + + + + + companion object { + fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptException { + return ScriptException(config, obj.getOrThrow(config, "message", "ScriptException")); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptExecutionException.kt b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptExecutionException.kt new file mode 100644 index 00000000..28b9b0e9 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptExecutionException.kt @@ -0,0 +1,17 @@ +package com.futo.platformplayer.engine.exceptions + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrThrow + +open class ScriptExecutionException(config: IV8PluginConfig, error: String, ex: Exception? = null, val stack: String? = null, code: String? = null) : PluginException(config, error, ex, code) { + + + + + companion object { + fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptExecutionException { + return ScriptExecutionException(config, obj.getOrThrow(config, "message", "ScriptExecutionException")); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptImplementationException.kt b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptImplementationException.kt new file mode 100644 index 00000000..dd2aaf7a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptImplementationException.kt @@ -0,0 +1,14 @@ +package com.futo.platformplayer.engine.exceptions + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrThrow + +class ScriptImplementationException(config: IV8PluginConfig, error: String, ex: Exception? = null, var pluginId: String? = null, code: String? = null) : PluginException(config, error, ex, code) { + + companion object { + fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptImplementationException { + return ScriptImplementationException(config, obj.getOrThrow(config, "message", "ScriptImplementationException")); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptTimeoutException.kt b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptTimeoutException.kt new file mode 100644 index 00000000..6f883854 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptTimeoutException.kt @@ -0,0 +1,13 @@ +package com.futo.platformplayer.engine.exceptions + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrThrow + +class ScriptTimeoutException(config: IV8PluginConfig, error: String, ex: Exception? = null) : ScriptException(config, error, ex) { + companion object { + fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptTimeoutException { + return ScriptTimeoutException(config, obj.getOrThrow(config, "message", "ScriptException")); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptUnavailableException.kt b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptUnavailableException.kt new file mode 100644 index 00000000..5d331b8b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptUnavailableException.kt @@ -0,0 +1,14 @@ +package com.futo.platformplayer.engine.exceptions + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.getOrThrow + +class ScriptUnavailableException(config: IV8PluginConfig, error: String, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptException(config, error, ex, stack, code) { + + companion object { + fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptException { + return ScriptUnavailableException(config, obj.getOrThrow(config, "message", "ScriptUnavailableException")); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptValidationException.kt b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptValidationException.kt new file mode 100644 index 00000000..a0c27edf --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptValidationException.kt @@ -0,0 +1,3 @@ +package com.futo.platformplayer.engine.exceptions + +class ScriptValidationException(error: String, ex: Exception? = null) : Exception(error, ex); \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/engine/internal/IV8Object.kt b/app/src/main/java/com/futo/platformplayer/engine/internal/IV8Object.kt new file mode 100644 index 00000000..27390d67 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/engine/internal/IV8Object.kt @@ -0,0 +1,9 @@ +package com.futo.platformplayer.engine.internal + +import com.caoccao.javet.interop.V8Runtime +import com.caoccao.javet.values.V8Value + + +interface IV8Convertable { + fun toV8(runtime: V8Runtime) : V8Value?; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/engine/internal/V8BindObject.kt b/app/src/main/java/com/futo/platformplayer/engine/internal/V8BindObject.kt new file mode 100644 index 00000000..4e861b72 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/engine/internal/V8BindObject.kt @@ -0,0 +1,33 @@ +package com.futo.platformplayer.engine.internal + +import com.caoccao.javet.annotations.V8Function +import com.caoccao.javet.interop.V8Runtime +import com.caoccao.javet.values.V8Value +import com.caoccao.javet.values.reference.V8ValueObject + +open class V8BindObject : IV8Convertable { + protected var _runtimeObj: V8ValueObject? = null; + protected var _isDisposed: Boolean = false + private set; + + + override fun toV8(runtime: V8Runtime): V8Value? { + synchronized(this) { + if(_runtimeObj != null) + return _runtimeObj; + + val v8Obj = runtime.createV8ValueObject(); + v8Obj.bind(this); + _runtimeObj = v8Obj; + return v8Obj; + } + } + + @V8Function + open fun dispose() { + if(!_isDisposed) { + //_runtimeObj?.v8Runtime?.v8Internal?.removeReference(_runtimeObj); + //_isDisposed = true; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/engine/internal/V8Converter.kt b/app/src/main/java/com/futo/platformplayer/engine/internal/V8Converter.kt new file mode 100644 index 00000000..62b6c149 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/engine/internal/V8Converter.kt @@ -0,0 +1,28 @@ +package com.futo.platformplayer.engine.internal + +import com.caoccao.javet.annotations.V8Convert +import com.caoccao.javet.interop.V8Runtime +import com.caoccao.javet.interop.converters.JavetObjectConverter +import com.caoccao.javet.interop.converters.JavetProxyConverter +import com.caoccao.javet.values.V8Value +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.engine.V8Plugin + + +class V8Converter : JavetObjectConverter() { + + + override fun toV8Value(v8Runtime: V8Runtime, obj: Any?, depth: Int): T? { + if (obj == null) + return null; + + val value: V8Value? = super.toV8Value(v8Runtime, obj, depth) + if (value != null && !value.isUndefined) + return value as T; + if (obj != null) { + if (obj is IV8Convertable) + return obj.toV8(v8Runtime) as T; + } + return null; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt new file mode 100644 index 00000000..0760a609 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt @@ -0,0 +1,65 @@ +package com.futo.platformplayer.engine.packages + +import com.caoccao.javet.annotations.V8Function +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateDeveloper +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.engine.V8Plugin +import com.futo.platformplayer.states.StateApp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class PackageBridge : V8Package { + @Transient + private val _config: IV8PluginConfig; + @Transient + private val _client: ManagedHttpClient + @Transient + private val _clientAuth: ManagedHttpClient + + override val name: String get() = "Bridge"; + override val variableName: String get() = "bridge"; + + constructor(plugin: V8Plugin, config: IV8PluginConfig): super(plugin) { + _config = config; + _client = plugin.httpClient; + _clientAuth = plugin.httpClientAuth; + } + + @V8Function + fun toast(str: String) { + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { + try { + UIDialogs.toast(str); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to show toast.", e); + } + } + } + @V8Function + fun log(str: String?) { + Logger.i(_config.name, str ?: "null"); + if(_config is SourcePluginConfig && _config.id == StateDeveloper.DEV_ID) + StateDeveloper.instance.logDevInfo(StateDeveloper.instance.currentDevID ?: "", str ?: "null"); + } + + @V8Function + fun throwTest(str: String) { + throw IllegalStateException(str); + } + + @V8Function + fun isLoggedIn(): Boolean { + if (_clientAuth is JSHttpClient) + return _clientAuth.isLoggedIn; + return false; + } + + companion object { + private const val TAG = "PackageBridge"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageDOMParser.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageDOMParser.kt new file mode 100644 index 00000000..9c5cacbd --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageDOMParser.kt @@ -0,0 +1,153 @@ +package com.futo.platformplayer.engine.packages + +import android.content.Context +import android.util.Log +import com.caoccao.javet.annotations.V8Allow +import com.caoccao.javet.annotations.V8Convert +import com.caoccao.javet.annotations.V8Function +import com.caoccao.javet.annotations.V8Property +import com.caoccao.javet.enums.V8ConversionMode +import com.caoccao.javet.enums.V8ProxyMode +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.engine.V8Plugin +import com.futo.platformplayer.engine.dev.V8RemoteObject +import com.futo.platformplayer.engine.internal.V8BindObject +import org.jsoup.Jsoup +import org.jsoup.nodes.Element + + +class PackageDOMParser : V8Package { + override val name: String get() = "DOMParser"; + override val variableName: String = "domParser"; + + constructor(context: Context, v8Plugin: V8Plugin): super(v8Plugin) { + //v8Plugin.withDependency(context, "/scripts/some/package/path"); + } + + @V8Function + fun parseFromString(html: String): DOMNode { + val dom = DOMNode.parse(this, html); + return dom; + } + + @V8Convert(mode = V8ConversionMode.AllowOnly, proxyMode = V8ProxyMode.Class) + class DOMNode: V8BindObject { + @Transient + private val _children: ArrayList = arrayListOf(); + + @Transient + private val _element: Element; + @Transient + private val _package: PackageDOMParser; + + @V8Property + fun nodeType(): String = _element.tagName(); + @V8Property + fun childNodes(): List { + val results = _element.children().map { DOMNode(_package, it) }.toList(); + if(results != null) + _children.addAll(results); + return results; + } + @V8Property + fun firstChild(): DOMNode? { + val result = _element.firstElementChild()?.let { DOMNode(_package, it) }; + if(result != null) + _children.add(result); + return result; + } + @V8Property + fun lastChild(): DOMNode? { + val result = _element.firstElementChild()?.let { DOMNode(_package, it) }; + if(result != null) + _children.add(result); + return result; + } + @V8Property + fun parentNode(): DOMNode? { + val result = _element.parent()?.let { DOMNode(_package, it) }; + if(result != null) + _children.add(result); + return result; + } + @V8Property + fun attributes(): Map = _element.attributes().dataset(); + @V8Property + fun innerHTML(): String = _element.html(); + @V8Property + fun outerHTML(): String = _element.outerHtml(); + @V8Property + fun textContent(): String = _element.text(); + @V8Property + fun text(): String = _element.text().ifEmpty { data() }; + @V8Property + fun data(): String = _element.data(); + + @V8Property + fun classList(): List = _element.classNames().toList() + + @V8Property + fun className(): String = _element.className(); + + + constructor(parser: PackageDOMParser, element: Element) { + _package = parser; + _element = element; + } + + @V8Function + fun getAttribute(key: String): String { + return _element.attr(key); + } + @V8Function + fun getElementById(id: String): DOMNode? { + val node = _element.getElementById(id)?.let { DOMNode(_package, it) }; + if(node != null) + _children.add(node); + return node; + } + @V8Function + fun getElementsByClassName(className: String): List { + val results = _element.getElementsByClass(className).map { DOMNode(_package, it) }.toList(); + _children.addAll(results); + return results; + } + @V8Function + fun getElementsByTagName(tagName: String): List { + val results = _element.getElementsByTag(tagName).map { DOMNode(_package, it) }.toList(); + _children.addAll(results); + return results; + } + @V8Function + fun getElementsByName(name: String): List { + val results = _element.getElementsByAttributeValue("name", name).map { DOMNode(_package, it) }.toList(); + _children.addAll(results); + return results; + } + + @V8Function + fun querySelector(query: String): DOMNode? { + val result = _element.selectFirst(query) ?: return null; + return DOMNode(_package, result); + } + @V8Function + fun querySelectorAll(query: String): List { + val results = _element.select(query) ?: return listOf(); + return results.map { DOMNode(_package, it) }; + } + + @V8Function + override fun dispose() { + for(child in _children) + child.dispose(); + _children.clear(); + super.dispose(); + } + + companion object { + fun parse(parser: PackageDOMParser, str: String): DOMNode { + return DOMNode(parser, Jsoup.parse(str)); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt new file mode 100644 index 00000000..36372af4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt @@ -0,0 +1,474 @@ +package com.futo.platformplayer.engine.packages + +import com.caoccao.javet.annotations.V8Convert +import com.caoccao.javet.annotations.V8Function +import com.caoccao.javet.annotations.V8Property +import com.caoccao.javet.enums.V8ConversionMode +import com.caoccao.javet.enums.V8ProxyMode +import com.caoccao.javet.interop.V8Runtime +import com.caoccao.javet.values.V8Value +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.engine.V8Plugin +import com.futo.platformplayer.engine.internal.IV8Convertable +import com.futo.platformplayer.engine.internal.V8BindObject +import com.futo.platformplayer.getOrThrow +import kotlinx.coroutines.CoroutineScope +import java.net.SocketTimeoutException +import kotlin.streams.toList + +class PackageHttp: V8Package { + @Transient + private val _config: IV8PluginConfig; + @Transient + private val _client: ManagedHttpClient + @Transient + private val _clientAuth: ManagedHttpClient + @Transient + private val _packageClient: PackageHttpClient; + @Transient + private val _packageClientAuth: PackageHttpClient + + + override val name: String get() = "Http"; + override val variableName: String get() = "http"; + + + constructor(plugin: V8Plugin, config: IV8PluginConfig): super(plugin) { + _config = config; + _client = plugin.httpClient; + _clientAuth = plugin.httpClientAuth; + _packageClient = PackageHttpClient(this, _client); + _packageClientAuth = PackageHttpClient(this, _clientAuth); + } + + @V8Function + fun newClient(withAuth: Boolean): PackageHttpClient { + return PackageHttpClient(this, if(withAuth) _clientAuth.clone() else _client.clone()); + } + @V8Function + fun getDefaultClient(withAuth: Boolean): PackageHttpClient { + return if(withAuth) _packageClientAuth else _packageClient; + } + + @V8Function + fun batch(): BatchBuilder { + return BatchBuilder(this); + } + + @V8Function + fun request(method: String, url: String, headers: MutableMap = HashMap(), useAuth: Boolean = false) : BridgeHttpResponse { + return if(useAuth) + _packageClientAuth.request(method, url, headers) + else + _packageClient.request(method, url, headers); + } + + @V8Function + fun requestWithBody(method: String, url: String, body:String, headers: MutableMap = HashMap(), useAuth: Boolean = false) : BridgeHttpResponse { + return if(useAuth) + _packageClientAuth.requestWithBody(method, url, body, headers) + else + _packageClient.requestWithBody(method, url, body, headers); + } + @V8Function + fun GET(url: String, headers: MutableMap = HashMap(), useAuth: Boolean = false) : BridgeHttpResponse { + return if(useAuth) + _packageClientAuth.GET(url, headers) + else + _packageClient.GET(url, headers); + } + @V8Function + fun POST(url: String, body: String, headers: MutableMap = HashMap(), useAuth: Boolean = false) : BridgeHttpResponse { + return if(useAuth) + _packageClientAuth.POST(url, body, headers) + else + _packageClient.POST(url, body, headers); + } + + @V8Function + fun socket(url: String, headers: Map? = null, useAuth: Boolean = false): SocketResult { + return if(useAuth) + _packageClientAuth.socket(url, headers) + else + _packageClient.socket(url, headers); + } + + private fun logExceptions(handle: ()->T): T { + try { + return handle(); + } + catch(ex: Exception) { + Logger.e("Plugin[${_config.name}]", ex.message, ex); + throw ex; + } + } + + @kotlinx.serialization.Serializable + class BridgeHttpResponse(val code: Int, val body: String?, val headers: Map>? = null) : IV8Convertable { + val isOk = code >= 200 && code < 300; + + override fun toV8(runtime: V8Runtime): V8Value? { + val obj = runtime.createV8ValueObject(); + obj.set("code", code); + obj.set("body", body); + obj.set("headers", headers); + obj.set("isOk", isOk); + return obj; + } + } + + //TODO: This object is currently re-wrapped each modification, this is due to an issue passing the same object back and forth, should be fixed in future. + @V8Convert(mode = V8ConversionMode.AllowOnly, proxyMode = V8ProxyMode.Class) + class BatchBuilder(private val _package: PackageHttp, existingRequests: MutableList> = mutableListOf()): V8BindObject() { + @Transient + private val _reqs = existingRequests; + + @V8Function + fun request(method: String, url: String, headers: MutableMap = HashMap(), useAuth: Boolean = false) : BatchBuilder { + return clientRequest(_package.getDefaultClient(useAuth), method, url, headers); + } + @V8Function + fun requestWithBody(method: String, url: String, body:String, headers: MutableMap = HashMap(), useAuth: Boolean = false) : BatchBuilder { + return clientRequestWithBody(_package.getDefaultClient(useAuth), method, url, body, headers); + } + @V8Function + fun GET(url: String, headers: MutableMap = HashMap(), useAuth: Boolean = false) : BatchBuilder + = clientGET(_package.getDefaultClient(useAuth), url, headers); + @V8Function + fun POST(url: String, body: String, headers: MutableMap = HashMap(), useAuth: Boolean = false) : BatchBuilder + = clientPOST(_package.getDefaultClient(useAuth), url, body, headers); + + //Client-specific + + @V8Function + fun clientRequest(client: PackageHttpClient, method: String, url: String, headers: MutableMap = HashMap()) : BatchBuilder { + _reqs.add(Pair(client, RequestDescriptor(method, url, headers))); + return BatchBuilder(_package, _reqs); + } + @V8Function + fun clientRequestWithBody(client: PackageHttpClient, method: String, url: String, body:String, headers: MutableMap = HashMap()) : BatchBuilder { + _reqs.add(Pair(client, RequestDescriptor(method, url, headers, body))); + return BatchBuilder(_package, _reqs); + } + @V8Function + fun clientGET(client: PackageHttpClient, url: String, headers: MutableMap = HashMap()) : BatchBuilder + = clientRequest(client, "GET", url, headers); + @V8Function + fun clientPOST(client: PackageHttpClient, url: String, body: String, headers: MutableMap = HashMap()) : BatchBuilder + = clientRequestWithBody(client, "POST", url, body, headers); + + + //Finalizer + @V8Function + fun execute(): List { + return _reqs.parallelStream().map { + if(it.second.body != null) + return@map it.first.requestWithBody(it.second.method, it.second.url, it.second.body!!, it.second.headers); + else + return@map it.first.request(it.second.method, it.second.url, it.second.headers); + }.toList(); + } + } + + + + @V8Convert(mode = V8ConversionMode.AllowOnly, proxyMode = V8ProxyMode.Class) + class PackageHttpClient : V8BindObject { + + @Transient + private val _package: PackageHttp; + @Transient + private val _client: ManagedHttpClient; + + @Transient + private val _defaultHeaders = mutableMapOf(); + + constructor(pack: PackageHttp, baseClient: ManagedHttpClient): super() { + _package = pack; + _client = baseClient; + } + + @V8Function + fun setDefaultHeaders(defaultHeaders: Map): PackageHttpClient { + for(pair in defaultHeaders) + _defaultHeaders[pair.key] = pair.value; + return this; + } + @V8Function + fun setDoApplyCookies(apply: Boolean): PackageHttpClient { + if(_client is JSHttpClient) + _client.doApplyCookies = apply; + return this; + } + @V8Function + fun setDoUpdateCookies(update: Boolean): PackageHttpClient { + if(_client is JSHttpClient) + _client.doUpdateCookies = update; + return this; + } + @V8Function + fun setDoAllowNewCookies(allow: Boolean): PackageHttpClient { + if(_client is JSHttpClient) + _client.doAllowNewCookies = allow; + return this; + } + + @V8Function + fun request(method: String, url: String, headers: MutableMap = HashMap()) : BridgeHttpResponse { + applyDefaultHeaders(headers); + return logExceptions { + return@logExceptions catchHttp { + val client = _client; + logRequest(method, url, headers, null); + val resp = client.requestMethod(method, url, headers); + val responseBody = resp.body?.string(); + logResponse(method, url, resp.code, resp.headers, responseBody); + return@catchHttp BridgeHttpResponse(resp.code, responseBody, sanitizeResponseHeaders(resp.headers)); + } + }; + } + @V8Function + fun requestWithBody(method: String, url: String, body:String, headers: MutableMap = HashMap()) : BridgeHttpResponse { + applyDefaultHeaders(headers); + return logExceptions { + catchHttp { + val client = _client; + logRequest(method, url, headers, body); + val resp = client.requestMethod(method, url, body, headers); + val responseBody = resp.body?.string(); + logResponse(method, url, resp.code, resp.headers, responseBody); + return@catchHttp BridgeHttpResponse(resp.code, responseBody, sanitizeResponseHeaders(resp.headers)); + } + }; + } + + @V8Function + fun GET(url: String, headers: MutableMap = HashMap()) : BridgeHttpResponse { + applyDefaultHeaders(headers); + return logExceptions { + catchHttp { + val client = _client; + logRequest("GET", url, headers, null); + val resp = client.get(url, headers); + val responseBody = resp.body?.string(); + logResponse("GET", url, resp.code, resp.headers, responseBody); + return@catchHttp BridgeHttpResponse(resp.code, responseBody, sanitizeResponseHeaders(resp.headers)); + } + }; + } + @V8Function + fun POST(url: String, body: String, headers: MutableMap = HashMap()) : BridgeHttpResponse { + applyDefaultHeaders(headers); + return logExceptions { + catchHttp { + val client = _client; + logRequest("POST", url, headers, body); + val resp = client.post(url, body, headers); + val responseBody = resp.body?.string(); + logResponse("POST", url, resp.code, resp.headers, responseBody); + return@catchHttp BridgeHttpResponse(resp.code, responseBody, sanitizeResponseHeaders(resp.headers)); + } + }; + } + + @V8Function + fun socket(url: String, headers: Map? = null): SocketResult { + val socketHeaders = headers?.toMutableMap() ?: HashMap(); + applyDefaultHeaders(socketHeaders); + return SocketResult(this, _client, url, socketHeaders ?: HashMap()); + } + + private fun applyDefaultHeaders(headerMap: MutableMap) { + synchronized(_defaultHeaders) { + for(toApply in _defaultHeaders) + if(!headerMap.containsKey(toApply.key)) + headerMap[toApply.key] = toApply.value; + } + } + + private fun sanitizeResponseHeaders(headers: Map>?): Map> { + val result = mutableMapOf>() + headers?.forEach { (header, values) -> + val lowerCaseHeader = header.lowercase() + if (WHITELISTED_RESPONSE_HEADERS.contains(lowerCaseHeader)) { + result[lowerCaseHeader] = values + } + } + return result + } + + private fun logRequest(method: String, url: String, headers: Map = HashMap(), body: String?) { + return; + + Logger.v(TAG) { + val stringBuilder = StringBuilder(); + stringBuilder.appendLine("HTTP request (useAuth = )"); + stringBuilder.appendLine("$method $url"); + + for (pair in headers) { + stringBuilder.appendLine("${pair.key}: ${pair.value}"); + } + + if (body != null) { + stringBuilder.appendLine(); + stringBuilder.appendLine(body); + } + + return@v stringBuilder.toString(); + }; + } + + private fun logResponse(method: String, url: String, responseCode: Int? = null, responseHeaders: Map> = HashMap(), responseBody: String? = null) { + return; + + Logger.v(TAG) { + val stringBuilder = StringBuilder(); + if (responseCode != null) { + stringBuilder.appendLine("HTTP response (${responseCode})"); + stringBuilder.appendLine("$method $url"); + + for (pair in responseHeaders) { + if (pair.key.equals("authorization", ignoreCase = true) || pair.key.equals("set-cookie", ignoreCase = true)) { + stringBuilder.appendLine("${pair.key}: @CENSOREDVALUE@"); + } else { + stringBuilder.appendLine("${pair.key}: ${pair.value.joinToString("; ")}"); + } + } + + if (responseBody != null) { + stringBuilder.appendLine(); + stringBuilder.appendLine(responseBody); + } + } else { + stringBuilder.appendLine("No response"); + } + + return@v stringBuilder.toString(); + }; + } + + fun logExceptions(handle: ()->T): T { + try { + return handle(); + } + catch(ex: Exception) { + Logger.e("Plugin[${_package._config.name}]", ex.message, ex); + throw ex; + } + } + + private fun catchHttp(handle: ()->BridgeHttpResponse): BridgeHttpResponse { + try{ + return handle(); + } + //Forward timeouts + catch(ex: SocketTimeoutException) { + return BridgeHttpResponse(408, null); + } + } + } + + @V8Convert(mode = V8ConversionMode.AllowOnly, proxyMode = V8ProxyMode.Class) + class SocketResult: V8BindObject { + private var _isOpen = false; + private var _socket: ManagedHttpClient.Socket? = null; + + private var _listeners: V8ValueObject? = null; + + private val _packageClient: PackageHttpClient; + private val _client: ManagedHttpClient; + private val _url: String; + private val _headers: Map; + + constructor(pack: PackageHttpClient, client: ManagedHttpClient, url: String, headers: Map) { + _packageClient = pack; + _client = client; + _url = url; + _headers = headers; + } + + @V8Property + fun isOpen(): Boolean = _isOpen; //TODO + + @V8Function + fun connect(socketObj: V8ValueObject) { + val hasOpen = socketObj.has("open"); + val hasMessage = socketObj.has("message"); + val hasClosing = socketObj.has("closing"); + val hasClosed = socketObj.has("closed"); + val hasFailure = socketObj.has("failure"); + + //socketObj.setWeak(); //We have to manage this lifecycle + _listeners = socketObj; + + _socket = _packageClient.logExceptions { + val client = _client; + return@logExceptions client.socket(_url, _headers.toMutableMap(), object: ManagedHttpClient.SocketListener { + override fun open() { + Logger.i(TAG, "Websocket opened: " + _url); + _isOpen = true; + if(hasOpen) + _listeners?.invokeVoid("open", arrayOf()); + } + override fun message(msg: String) { + if(hasMessage) { + try { + _listeners?.invokeVoid("message", msg); + } + catch(ex: Throwable) {} + } + } + override fun closing(code: Int, reason: String) { + if(hasClosing) + _listeners?.invokeVoid("closing", code, reason); + } + override fun closed(code: Int, reason: String) { + _isOpen = false; + if(hasClosed) + _listeners?.invokeVoid("closed", code, reason); + } + override fun failure(exception: Throwable) { + _isOpen = false; + Logger.e(TAG, "Websocket failure: ${exception.message} (${_url})", exception); + if(hasFailure) + _listeners?.invokeVoid("failure", exception.message); + } + }); + }; + } + + @V8Function + fun send(msg: String) { + _socket?.send(msg); + } + } + + data class RequestDescriptor( + val method: String, + val url: String, + val headers: MutableMap, + val body: String? = null, + val contentType: String? = null + ) + + private fun catchHttp(handle: ()->BridgeHttpResponse): BridgeHttpResponse { + try{ + return handle(); + } + //Forward timeouts + catch(ex: SocketTimeoutException) { + return BridgeHttpResponse(408, null); + } + } + + + + companion object { + private const val TAG = "PackageHttp"; + private val WHITELISTED_RESPONSE_HEADERS = listOf("content-type", "date", "content-length", "last-modified", "etag", "cache-control", "content-encoding", "content-disposition", "connection") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageUtilities.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageUtilities.kt new file mode 100644 index 00000000..98e64dbe --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageUtilities.kt @@ -0,0 +1,29 @@ +package com.futo.platformplayer.engine.packages + +import android.util.Base64 +import com.caoccao.javet.annotations.V8Function +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.engine.V8Plugin +import java.util.UUID + +class PackageUtilities : V8Package { + @Transient + private val _config: IV8PluginConfig; + + override val name: String get() = "Utilities"; + override val variableName: String get() = "utility"; + + constructor(plugin: V8Plugin, config: IV8PluginConfig): super(plugin) { + _config = config; + } + + @V8Function + fun toBase64(arr: ByteArray): String { + return Base64.encodeToString(arr, Base64.NO_WRAP); + } + + @V8Function + fun randomUUID(): String { + return UUID.randomUUID().toString(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/V8Package.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/V8Package.kt new file mode 100644 index 00000000..beb7c118 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/V8Package.kt @@ -0,0 +1,25 @@ +package com.futo.platformplayer.engine.packages + +import com.futo.platformplayer.engine.V8Plugin + +abstract class V8Package { + @Transient + protected val _plugin: V8Plugin; + @Transient + private val _scripts: MutableList = mutableListOf(); + + abstract val name: String; + @Transient + open val variableName: String? = null; + + constructor(v8Plugin: V8Plugin) { + _plugin = v8Plugin; + } + + fun withScript(str: String) { + _scripts.add(str); + } + fun getScripts() : List { + return _scripts.toList(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/exceptions/ChannelException.kt b/app/src/main/java/com/futo/platformplayer/exceptions/ChannelException.kt new file mode 100644 index 00000000..1309584f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/exceptions/ChannelException.kt @@ -0,0 +1,19 @@ +package com.futo.platformplayer.exceptions + +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel + +class ChannelException : Exception { + val url: String?; + val channel: IPlatformChannel?; + + val channelNameOrUrl: String? get() = channel?.name ?: url; + + constructor(url: String, ex: Throwable): super("Channel: ${url} failed", ex) { + this.url = url; + this.channel = null; + } + constructor(channel: IPlatformChannel, ex: Throwable): super("Channel: ${channel.name} failed (${ex.message})", ex) { + this.url = channel.url; + this.channel = channel; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/exceptions/MigrationException.kt b/app/src/main/java/com/futo/platformplayer/exceptions/MigrationException.kt new file mode 100644 index 00000000..9d1e74b6 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/exceptions/MigrationException.kt @@ -0,0 +1,5 @@ +package com.futo.platformplayer.exceptions + +class MigrationException(msg: String, inner: Throwable) : Throwable(msg, inner) { + +} diff --git a/app/src/main/java/com/futo/platformplayer/exceptions/ReconstructionException.kt b/app/src/main/java/com/futo/platformplayer/exceptions/ReconstructionException.kt new file mode 100644 index 00000000..a7f836a3 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/exceptions/ReconstructionException.kt @@ -0,0 +1,5 @@ +package com.futo.platformplayer.exceptions + +class ReconstructionException(val name: String? = null, message: String, innerException: Throwable): Exception(message, innerException) { + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelAboutFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelAboutFragment.kt new file mode 100644 index 00000000..9e28d6f9 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelAboutFragment.kt @@ -0,0 +1,155 @@ +package com.futo.platformplayer.fragment.channel.tab + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.fragment.app.Fragment +import com.bumptech.glide.Glide +import com.futo.platformplayer.* +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.views.platform.PlatformLinkView +import com.futo.polycentric.core.toName +import com.futo.polycentric.core.toURLInfoSystemLinkUrl + +class ChannelAboutFragment : Fragment, IChannelTabFragment { + private var _textName: TextView? = null; + private var _textMetadata: TextView? = null; + private var _textFindOn: TextView? = null; + private var _textDescription: TextView? = null; + private var _imageThumbnail: ImageView? = null; + private var _linksContainer: LinearLayout? = null; + + private var _lastChannel: IPlatformChannel? = null; + private var _lastPolycentricProfile: PolycentricProfile? = null; + + constructor() : super() { + + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = inflater.inflate(R.layout.fragment_channel_about, container, false); + + _textName = view.findViewById(R.id.text_channel_name); + _textMetadata = view.findViewById(R.id.text_channel_metadata); + _textDescription = view.findViewById(R.id.text_description); + _textDescription!!.setPlatformPlayerLinkMovementMethod(view.context); + _textFindOn = view.findViewById(R.id.text_find_on); + _imageThumbnail = view.findViewById(R.id.image_channel_thumbnail); + _linksContainer = view.findViewById(R.id.links_container); + _imageThumbnail?.clipToOutline = true; + _lastChannel?.also { + setChannel(it); + }; + _lastPolycentricProfile?.also { + setPolycentricProfile(it, animate = false); + } + + return view; + } + + override fun onDestroyView() { + super.onDestroyView(); + + _textName = null; + _textMetadata = null; + _textDescription = null; + _textFindOn = null; + _imageThumbnail = null; + _linksContainer = null; + } + + override fun setChannel(channel: IPlatformChannel) { + if(channel.description != null) + _textDescription?.text = channel.description!!.fixHtmlLinks(); + + _imageThumbnail?.let { + Glide.with(it) + .load(channel.thumbnail) + .placeholder(R.drawable.placeholder_channel_thumbnail) + .into(it); + }; + _textName?.text = channel.name; + + val metadata = "${channel.subscribers.toHumanNumber()} subscribers"; + _textMetadata?.text = metadata; + _lastChannel = channel; + setLinks(channel.links, channel.name); + } + + private fun setLinks(links: Map, name: String) { + val c = context; + val l = _linksContainer; + + if (c != null && l != null) { + l.removeAllViews(); + + if (links.isNotEmpty()) { + _textFindOn?.text = "Find $name on"; + _textFindOn?.visibility = View.VISIBLE; + + for (pair in links) { + val platformLinkView = PlatformLinkView(c); + platformLinkView.setPlatform(pair.key, pair.value); + l.addView(platformLinkView); + } + } else { + _textFindOn?.visibility = View.GONE; + } + } else { + _textFindOn?.visibility = View.GONE; + } + + } + + fun setPolycentricProfile(polycentricProfile: PolycentricProfile?, animate: Boolean) { + _lastPolycentricProfile = polycentricProfile; + + if (polycentricProfile == null) { + return; + } + + val map = hashMapOf(); + for (c in polycentricProfile.ownedClaims) { + try { + val url = c.claim.resolveChannelUrl(); + val name = c.claim.toName(); + if (url != null && name != null) { + map[name] = url; + } + } catch (e: Throwable) { + Logger.w(TAG, "Failed to parse claim=$c", e) + } + } + + if (map.isNotEmpty()) + setLinks(map, if (polycentricProfile.systemState.username.isNotBlank()) polycentricProfile.systemState.username else _lastChannel?.name ?: "") + + if(polycentricProfile.systemState.description.isNotBlank()) + _textDescription?.text = polycentricProfile.systemState.description.fixHtmlLinks(); + + if (polycentricProfile.systemState.username.isNotBlank()) + _textName?.text = polycentricProfile.systemState.username; + + val dp_80 = 80.dp(StateApp.instance.context.resources) + val avatar = polycentricProfile.systemState.avatar?.selectBestImage(dp_80 * dp_80)?.let { + it.toURLInfoSystemLinkUrl(polycentricProfile.system.toProto(), it.process, polycentricProfile.systemState.servers.toList()) + }; + + if (avatar != null && _imageThumbnail != null) + Glide.with(_imageThumbnail!!) + .load(avatar) + .into(_imageThumbnail!!); + } + + companion object { + val TAG = "AboutFragment"; + fun newInstance() = ChannelAboutFragment().apply { } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt new file mode 100644 index 00000000..be12e48f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt @@ -0,0 +1,344 @@ +package com.futo.platformplayer.fragment.channel.tab + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.UISlideOverlays +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.platforms.js.models.JSPager +import com.futo.platformplayer.api.media.structures.IAsyncPager +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.api.media.structures.IRefreshPager +import com.futo.platformplayer.api.media.structures.IReplacerPager +import com.futo.platformplayer.api.media.structures.MultiPager +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.engine.exceptions.PluginException +import com.futo.platformplayer.fragment.mainactivity.main.FeedView +import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.adapters.PreviewContentListAdapter +import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder +import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class ChannelContentsFragment : Fragment(), IChannelTabFragment { + private var _recyclerResults: RecyclerView? = null; + private var _llmVideo: LinearLayoutManager? = null; + private var _loading = false; + private var _pager_parent: IPager? = null; + private var _pager: IPager? = null; + private var _cache: FeedView.ItemCache? = null; + private var _channel: IPlatformChannel? = null; + private var _results: ArrayList = arrayListOf(); + private var _adapterResults: InsertedViewAdapterWithLoader? = null; + private var _lastPolycentricProfile: PolycentricProfile? = null; + + val onContentClicked = Event2(); + val onContentUrlClicked = Event2(); + val onChannelClicked = Event1(); + val onAddToClicked = Event1(); + + private fun getContentPager(channel: IPlatformChannel): IPager { + Logger.i(TAG, "getContentPager"); + + val lastPolycentricProfile = _lastPolycentricProfile; + var pager: IPager? = null; + if (lastPolycentricProfile != null) + pager= StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile); + + if(pager == null) + pager = StatePlatform.instance.getChannelContent(channel.url); + + return pager; + } + + private val _taskLoadVideos = TaskHandler>({lifecycleScope}, { + return@TaskHandler getContentPager(it); + }).success { + setLoading(false); + setPager(it); + }.exception { + Logger.w(TAG, "Failed to load initial videos.", it); + UIDialogs.showGeneralRetryErrorDialog(requireContext(), it.message ?: "", it, { loadNextPage() }); + }; + + private var _nextPageHandler: TaskHandler, List> = TaskHandler, List>({lifecycleScope}, { + if (it is IAsyncPager<*>) + it.nextPageAsync(); + else + it.nextPage(); + + processPagerExceptions(it); + return@TaskHandler it.getResults(); + }).success { + setLoading(false); + if (it.isEmpty()) { + return@success; + } + + val posBefore = _results.size; + val toAdd = it.filter { it is IPlatformVideo }.map { it as IPlatformVideo }; + _results.addAll(toAdd); + _adapterResults?.let { adapterVideo -> adapterVideo.notifyItemRangeInserted(adapterVideo.childToParentPosition(posBefore), toAdd.size); }; + }.exception { + Logger.w(TAG, "Failed to load next page.", it); + UIDialogs.showGeneralRetryErrorDialog(requireContext(), it.message ?: "", it, { loadNextPage() }); + }; + + private val _scrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy); + + val recyclerResults = _recyclerResults ?: return; + val llmVideo = _llmVideo ?: return; + + val visibleItemCount = recyclerResults.childCount; + val firstVisibleItem = llmVideo.findFirstVisibleItemPosition(); + val visibleThreshold = 15; + if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= _results.size) { + loadNextPage(); + } + } + }; + + override fun setChannel(channel: IPlatformChannel) { + val c = _channel; + if (c != null && c.url == channel.url) { + Logger.i(TAG, "setChannel skipped because previous was same"); + return; + } + + Logger.i(TAG, "setChannel setChannel=${channel}") + + _taskLoadVideos.cancel(); + + _channel = channel; + _results.clear(); + _adapterResults?.notifyDataSetChanged(); + + loadInitial(); + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = inflater.inflate(R.layout.fragment_channel_videos, container, false); + + _recyclerResults = view.findViewById(R.id.recycler_videos); + + _adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results).apply { + this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit); + this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit); + this.onChannelClicked.subscribe(this@ChannelContentsFragment.onChannelClicked::emit); + this.onAddToClicked.subscribe(this@ChannelContentsFragment.onAddToClicked::emit); + } + + _llmVideo = LinearLayoutManager(view.context); + _recyclerResults?.adapter = _adapterResults; + _recyclerResults?.layoutManager = _llmVideo; + _recyclerResults?.addOnScrollListener(_scrollListener); + + return view; + } + + override fun onDestroyView() { + super.onDestroyView(); + _recyclerResults?.removeOnScrollListener(_scrollListener); + _recyclerResults = null; + _pager = null; + + _taskLoadVideos.cancel(); + _nextPageHandler.cancel(); + } + + /* + private fun setPager(pager: IPager, cache: FeedFragment.ItemCache? = null) { + if (_pager_parent != null && _pager_parent is IRefreshPager<*>) { + (_pager_parent as IRefreshPager<*>).onPagerError?.remove(this); + (_pager_parent as IRefreshPager<*>).onPagerChanged?.remove(this); + _pager_parent = null; + } + + if(pager is IRefreshPager<*>) { + _pager_parent = pager; + _pager = pager.getCurrentPager() as IPager; + pager.onPagerChanged.subscribe { + lifecycleScope.launch(Dispatchers.Main) { + setPager(it as IPager); + } + }; + pager.onPagerError.subscribe { + Logger.e(TAG, "Search pager failed: ${it.message}", it); + if(it is PluginException) + UIDialogs.toast("Plugin [${it.config.name}] failed due to:\n${it.message}"); + else + UIDialogs.toast("Plugin failed due to:\n${it.message}"); + }; + } + else _pager = pager; + _cache = cache; + + processPagerExceptions(pager); + + _results.clear(); + val toAdd = pager.getResults(); + _results.addAll(toAdd); + _adapterResults?.notifyDataSetChanged(); + _recyclerResults?.scrollToPosition(0); + }*/ + + fun setPager(pager: IPager, cache: FeedView.ItemCache? = null) { + if (_pager_parent != null && _pager_parent is IRefreshPager<*>) { + (_pager_parent as IRefreshPager<*>).onPagerError?.remove(this); + (_pager_parent as IRefreshPager<*>).onPagerChanged?.remove(this); + _pager_parent = null; + } + if(_pager is IReplacerPager<*>) + (_pager as IReplacerPager<*>).onReplaced.remove(this); + + var pagerToSet: IPager? = null; + if(pager is IRefreshPager<*>) { + _pager_parent = pager; + pagerToSet = pager.getCurrentPager() as IPager; + pager.onPagerChanged.subscribe(this) { + + lifecycleScope.launch(Dispatchers.Main) { + try { + loadPagerInternal(it as IPager); + } catch (e: Throwable) { + Logger.e(TAG, "loadPagerInternal failed.", e) + } + } + }; + pager.onPagerError.subscribe(this) { + Logger.e(TAG, "Search pager failed: ${it.message}", it); + if(it is PluginException) + UIDialogs.toast("Plugin [${it.config.name}] failed due to:\n${it.message}"); + else + UIDialogs.toast("Plugin failed due to:\n${it.message}"); + }; + } + else pagerToSet = pager; + + loadPagerInternal(pagerToSet, cache); + } + private fun loadPagerInternal(pager: IPager, cache: FeedView.ItemCache? = null) { + _cache = cache; + + if(_pager is IReplacerPager<*>) + (_pager as IReplacerPager<*>).onReplaced.remove(this); + + if(pager is IReplacerPager<*>) { + pager.onReplaced.subscribe(this) { oldItem, newItem -> + if(_pager != pager) + return@subscribe; + + if(_pager !is IPager) + return@subscribe; + + val toReplaceIndex = _results.indexOfFirst { it == newItem }; + if(toReplaceIndex >= 0) { + _results[toReplaceIndex] = newItem as IPlatformContent; + _adapterResults?.let { + it.notifyItemChanged(it.childToParentPosition(toReplaceIndex)); + } + } + } + } + + _pager = pager; + + processPagerExceptions(pager); + + _results.clear(); + val toAdd = pager.getResults(); + _results.addAll(toAdd); + //insertPagerResults(toAdd, true); + _adapterResults?.notifyDataSetChanged(); + _recyclerResults?.scrollToPosition(0); + } + + private fun loadInitial() { + val channel: IPlatformChannel = _channel ?: return; + setLoading(true); + _taskLoadVideos.run(channel); + } + + private fun loadNextPage() { + val pager: IPager = _pager ?: return; + if(_pager?.hasMorePages() ?: false) { + setLoading(true); + _nextPageHandler.run(pager); + } + } + + private fun setLoading(loading: Boolean) { + _loading = loading; + _adapterResults?.setLoading(loading); + } + + fun setPolycentricProfile(polycentricProfile: PolycentricProfile?, animate: Boolean) { + val p = _lastPolycentricProfile; + if (p != null && polycentricProfile != null && p.system == polycentricProfile.system) { + Logger.i(TAG, "setPolycentricProfile skipped because previous was same"); + return; + } + + _lastPolycentricProfile = polycentricProfile; + + if (polycentricProfile != null) { + _taskLoadVideos.cancel(); + val itemsRemoved = _results.size; + _results.clear(); + _adapterResults?.notifyItemRangeRemoved(0, itemsRemoved); + loadInitial(); + } + } + + private fun processPagerExceptions(pager: IPager<*>) { + if(pager is MultiPager<*> && pager.allowFailure) { + val ex = pager.getResultExceptions(); + for(kv in ex) { + val jsVideoPager: JSPager<*>? = if(kv.key is MultiPager<*>) + (kv.key as MultiPager<*>).findPager { it is JSPager<*> } as JSPager<*>?; + else if(kv.key is JSPager<*>) + kv.key as JSPager<*>; + else null; + + context?.let { + lifecycleScope.launch(Dispatchers.Main) { + try { + if(jsVideoPager != null) + UIDialogs.toast(it, "Plugin ${jsVideoPager.getPluginConfig().name} failed:\n${kv.value.message}", false); + else + UIDialogs.toast(it, kv.value.message ?: "", false); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to show toast.", e) + } + } + } + } + } + } + + companion object { + val TAG = "VideoListFragment"; + fun newInstance() = ChannelContentsFragment().apply { } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelListFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelListFragment.kt new file mode 100644 index 00000000..e3cf3061 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelListFragment.kt @@ -0,0 +1,149 @@ +package com.futo.platformplayer.fragment.channel.tab + +import android.os.Bundle +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.* +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment +import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader +import com.futo.platformplayer.views.adapters.viewholders.CreatorViewHolder +import com.futo.polycentric.core.toUrl +import kotlinx.coroutines.runBlocking + +class ChannelListFragment : Fragment, IChannelTabFragment { + private var _channels: ArrayList = arrayListOf(); + private var _authorLinks: ArrayList = arrayListOf(); + private var _currentLoadIndex = 0; + private var _adapterCreator: InsertedViewAdapterWithLoader? = null; + private var _recyclerCreator: RecyclerView? = null; + private var _lm: GridLayoutManager? = null; + private var _lastPolycentricProfile: PolycentricProfile? = null; + private var _lastChannel : IPlatformChannel? = null; + + val onClickChannel = Event1(); + + private var _taskLoadChannel = TaskHandler({lifecycleScope}, { link -> + if (!StatePlatform.instance.hasEnabledChannelClient(link)) { + return@TaskHandler null; + } + + return@TaskHandler StatePlatform.instance.getChannel(link).await(); + }).success { + val adapter = _adapterCreator; + if (it == null || adapter == null || _channels.any { c -> c.url == it.url }) { + loadNext(); + return@success; + } + + _channels.add(it); + _authorLinks.add(PlatformAuthorLink(it.id, it.name, it.url, it.thumbnail)); + adapter.notifyItemInserted(adapter.childToParentPosition(_authorLinks.size - 1)); + loadNext(); + }.exceptionWithParameter { ex, para -> + Logger.w(ChannelFragment.TAG, "Failed to load results.", ex); + UIDialogs.toast(requireContext(), "Failed to fetch\n${para}", false) + loadNext(); + }; + + constructor() : super() { + + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = inflater.inflate(R.layout.fragment_channel_list, container, false); + + val recyclerCreator: RecyclerView = view.findViewById(R.id.recycler_creators); + _adapterCreator = InsertedViewAdapterWithLoader(view.context, arrayListOf(), arrayListOf(), + childCountGetter = { _authorLinks.size }, + childViewHolderBinder = { viewHolder, position -> viewHolder.bind(_authorLinks[position]); }, + childViewHolderFactory = { viewGroup, _ -> + val holder = CreatorViewHolder(viewGroup, true); + holder.onClick.subscribe { c -> onClickChannel.emit(c) }; + return@InsertedViewAdapterWithLoader holder; + } + ); + + recyclerCreator.adapter = _adapterCreator; + + _lm = GridLayoutManager(view.context, 2); + recyclerCreator.layoutManager = _lm; + _recyclerCreator = recyclerCreator; + _lastChannel?.also { setChannel(it); }; + _lastPolycentricProfile?.also { setPolycentricProfile(it, animate = false); } + + return view; + } + + override fun onDestroyView() { + super.onDestroyView(); + } + + override fun setChannel(channel: IPlatformChannel) { + _lastChannel = channel; + } + + private fun load() { + val profile = _lastPolycentricProfile ?: return; + setLoading(true); + + val url = profile.ownedClaims[_currentLoadIndex].claim.resolveChannelUrl(); + if (url == null) { + loadNext(); + return; + } + + _taskLoadChannel.run(url); + } + + private fun loadNext() { + val profile = _lastPolycentricProfile; + if (profile == null) { + setLoading(false); + return; + } + + _currentLoadIndex++; + if (_currentLoadIndex < profile.ownedClaims.size) { + load(); + } else { + setLoading(false); + } + } + + fun setPolycentricProfile(polycentricProfile: PolycentricProfile?, animate: Boolean) { + _taskLoadChannel.cancel(); + _lastPolycentricProfile = polycentricProfile; + + val adapter = _adapterCreator ?: return; + _channels.clear(); + _authorLinks.clear(); + adapter.notifyDataSetChanged(); + + if (polycentricProfile != null) { + _currentLoadIndex = 0; + load(); + } + } + + private fun setLoading(isLoading: Boolean) { + _adapterCreator?.setLoading(isLoading); + } + + companion object { + val TAG = "ChannelListFragment"; + fun newInstance() = ChannelListFragment().apply { } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelMonetizationFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelMonetizationFragment.kt new file mode 100644 index 00000000..83a37e9a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelMonetizationFragment.kt @@ -0,0 +1,79 @@ +package com.futo.platformplayer.fragment.channel.tab + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.views.buttons.BigButton + + +class ChannelMonetizationFragment : Fragment, IChannelTabFragment { + private var _buttonStore: BigButton? = null; + + private var _lastChannel: IPlatformChannel? = null; + private var _lastPolycentricProfile: PolycentricProfile? = null; + + constructor() : super() { } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = inflater.inflate(R.layout.fragment_channel_monetization, container, false); + _buttonStore = view.findViewById(R.id.button_store); + + _buttonStore?.onClick?.subscribe { + _lastPolycentricProfile?.systemState?.store?.let { + try { + val uri = Uri.parse(it); + val intent = Intent(Intent.ACTION_VIEW) + intent.data = uri + startActivity(intent) + } catch (e: Throwable) { + Logger.e(TAG, "Failed to open URI: '${it}'.", e); + } + } + }; + + _lastChannel?.also { + setChannel(it); + }; + + _lastPolycentricProfile?.also { + setPolycentricProfile(it, animate = false); + } + + return view; + } + + override fun onDestroyView() { + super.onDestroyView(); + _buttonStore = null; + } + + override fun setChannel(channel: IPlatformChannel) { + _lastChannel = channel; + _buttonStore?.visibility = View.GONE; + } + + fun setPolycentricProfile(polycentricProfile: PolycentricProfile?, animate: Boolean) { + _lastPolycentricProfile = polycentricProfile; + + if (polycentricProfile == null) { + return; + } + + if (polycentricProfile.systemState.store.isNotEmpty()) { + _buttonStore?.visibility = View.VISIBLE; + } + } + + companion object { + val TAG = "ChannelMonetizationFragment"; + fun newInstance() = ChannelMonetizationFragment().apply { } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelStoreFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelStoreFragment.kt new file mode 100644 index 00000000..4651c806 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelStoreFragment.kt @@ -0,0 +1,33 @@ +package com.futo.platformplayer.fragment.channel.tab + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel + +class ChannelStoreFragment : Fragment, IChannelTabFragment { + constructor() : super() { + + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = inflater.inflate(R.layout.fragment_channel_store, container, false); + return view; + } + + override fun onDestroyView() { + super.onDestroyView(); + } + + override fun setChannel(channel: IPlatformChannel) { + + } + + companion object { + val TAG = "StoreListFragment"; + fun newInstance() = ChannelStoreFragment().apply { } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/IChannelTabFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/IChannelTabFragment.kt new file mode 100644 index 00000000..e1da77c5 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/IChannelTabFragment.kt @@ -0,0 +1,7 @@ +package com.futo.platformplayer.fragment.channel.tab + +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel + +interface IChannelTabFragment { + fun setChannel(channel: IPlatformChannel); +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/MainActivityFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/MainActivityFragment.kt new file mode 100644 index 00000000..6244004b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/MainActivityFragment.kt @@ -0,0 +1,52 @@ +package com.futo.platformplayer.fragment.mainactivity + +import android.util.Log +import androidx.fragment.app.Fragment +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.fragment.mainactivity.main.MainFragment + +open class MainActivityFragment : Fragment() { + protected val currentMain : MainFragment + get() { + isValidMainActivity(); + return (activity as MainActivity).fragCurrent; + } + + fun closeSegment() { + val a = activity + if (a is MainActivity) + return a.closeSegment() + else + Log.e(TAG, "Failed to close segment due to activity not being a main activity.") + } + + fun navigate(frag: MainFragment, parameter: Any? = null, withHistory: Boolean = true) { + val a = activity + if (a is MainActivity) + (activity as MainActivity).navigate(frag, parameter, withHistory) + else + Log.e(TAG, "Failed to navigate due to activity not being a main activity.") + } + + inline fun navigate(parameter: Any? = null, withHistory: Boolean = true): T { + val target = requireFragment(); + navigate(target, parameter, withHistory); + return target; + } + + inline fun requireFragment() : T { + isValidMainActivity(); + return (activity as MainActivity).getFragment(); + } + + fun isValidMainActivity(){ + if(activity == null) + throw java.lang.IllegalStateException("Attempted to use fragment without an activity"); + if(!(activity is MainActivity)) + throw java.lang.IllegalStateException("Attempted to use fragment without a MainActivty"); + } + + companion object { + private const val TAG = "MainActivityFragment" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/BotFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/BotFragment.kt new file mode 100644 index 00000000..199758d5 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/BotFragment.kt @@ -0,0 +1,7 @@ +package com.futo.platformplayer.fragment.mainactivity.bottombar + +import com.futo.platformplayer.fragment.mainactivity.MainActivityFragment + +class BotFragment : MainActivityFragment() { + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt new file mode 100644 index 00000000..42b9569a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt @@ -0,0 +1,367 @@ +package com.futo.platformplayer.fragment.mainactivity.bottombar + +import android.animation.Animator +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import android.view.Gravity +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.core.animation.doOnEnd +import androidx.lifecycle.lifecycleScope +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.activities.SettingsActivity +import com.futo.platformplayer.dp +import com.futo.platformplayer.fragment.mainactivity.MainActivityFragment +import com.futo.platformplayer.fragment.mainactivity.main.* +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePayment +import com.futo.platformplayer.states.StateSubscriptions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.Collections +import kotlin.math.floor +import kotlin.math.roundToInt + +class MenuBottomBarFragment : MainActivityFragment() { + private var _view: MenuBottomBarView? = null; + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = MenuBottomBarView(this, inflater); + _view = view; + return view; + } + + override fun onResume() { + super.onResume() + _view?.updateAllButtonVisibility() + } + + override fun onDestroyView() { + super.onDestroyView(); + + _view?.cleanup(); + _view = null; + } + + fun onBackPressed() : Boolean { + return _view?.onBackPressed() ?: false; + } + + @SuppressLint("ViewConstructor") + class MenuBottomBarView : LinearLayout { + private val _fragment: MenuBottomBarFragment; + private val _inflater: LayoutInflater; + private val _subscribedActivity: MainActivity?; + + private var _overlayMore: FrameLayout; + private var _overlayMoreBackground: FrameLayout; + private var _layoutMoreButtons: LinearLayout; + private var _layoutBottomBarButtons: LinearLayout; + + private var _moreVisible = false; + private var _moreVisibleAnimating = false; + + private var _bottomButtons = arrayListOf(); + private var _moreButtons = arrayListOf(); + + private var _buttonsVisible = 0; + private var _subscriptionsVisible = false; + + var currentButtonDefinitions: List? = null; + + constructor(fragment: MenuBottomBarFragment, inflater: LayoutInflater) : super(inflater.context) { + _fragment = fragment; + _inflater = inflater; + inflater.inflate(R.layout.fragment_overview_bottom_bar, this); + + _overlayMore = findViewById(R.id.more_overlay); + _overlayMoreBackground = findViewById(R.id.more_overlay_background); + _layoutMoreButtons = findViewById(R.id.more_menu_buttons); + _layoutBottomBarButtons = findViewById(R.id.bottom_bar_buttons) + + _overlayMoreBackground.setOnClickListener { setMoreVisible(false); }; + + _subscribedActivity = fragment.activity as MainActivity? + _subscribedActivity?.onNavigated?.subscribe(this) { + updateMenuIcons(); + } + + registerUpdateButtonEvents(); + updateButtonDefinitions(); + } + + fun cleanup() { + _subscribedActivity?.onNavigated?.remove(this) + unregisterUpdateButtonEvents(); + } + + fun onBackPressed() : Boolean { + if(_moreVisible) { + setMoreVisible(false); + return true; + } + return false; + } + + private fun setMoreVisible(visible: Boolean) { + if (_moreVisibleAnimating) { + return + } + + if (_moreVisible == visible) { + return + } + + val height = _moreButtons.firstOrNull()?.let { + it.height.toFloat() + (it.layoutParams as MarginLayoutParams).bottomMargin + } ?: return + + _moreVisibleAnimating = true + val moreOverlayBackground = _overlayMoreBackground + val moreOverlay = _overlayMore + val duration: Long = 300 + val staggerFactor = 3.0f + + if (visible) { + moreOverlay.visibility = LinearLayout.VISIBLE + val animations = arrayListOf() + animations.add(ObjectAnimator.ofFloat(moreOverlayBackground, "alpha", 0.0f, 1.0f).setDuration(duration)) + + for ((index, button) in _moreButtons.withIndex()) { + val i = _moreButtons.size - index + animations.add(ObjectAnimator.ofFloat(button, "translationY", height * staggerFactor * (i + 1), 0.0f).setDuration(duration)) + } + + val animatorSet = AnimatorSet() + animatorSet.doOnEnd { + _moreVisibleAnimating = false + _moreVisible = true + } + animatorSet.playTogether(animations) + animatorSet.start() + } else { + val animations = arrayListOf() + animations.add(ObjectAnimator.ofFloat(moreOverlayBackground, "alpha", 1.0f, 0.0f).setDuration(duration)) + + for ((index, button) in _moreButtons.withIndex()) { + val i = _moreButtons.size - index + animations.add(ObjectAnimator.ofFloat(button, "translationY", 0.0f, height * staggerFactor * (i + 1)).setDuration(duration)) + } + + val animatorSet = AnimatorSet() + animatorSet.doOnEnd { + _moreVisibleAnimating = false + _moreVisible = false + moreOverlay.visibility = LinearLayout.INVISIBLE + } + animatorSet.playTogether(animations) + animatorSet.start() + } + } + + private fun updateBottomMenuButtons(buttons: MutableList, hasMore: Boolean) { + if (hasMore) { + buttons.add(ButtonDefinition(99, R.drawable.ic_more, R.drawable.ic_more, R.string.more, canToggle = false, { false }, { setMoreVisible(true) })) + } + + _bottomButtons.clear(); + //_bottomButtonImages.clear(); + _layoutBottomBarButtons.removeAllViews(); + + _layoutBottomBarButtons.addView(Space(context).apply { + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + }) + + for ((index, button) in buttons.withIndex()) { + val menuButton = MenuButton(context, button, _fragment, false); + menuButton.setOnClickListener { + updateMenuIcons() + button.action(_fragment) + setMoreVisible(false); + } + + _layoutBottomBarButtons.addView(menuButton) + if (index < buttonDefinitions.size - 1) { + _layoutBottomBarButtons.addView(Space(context).apply { + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + }) + } + + _bottomButtons.add(menuButton) + } + + _layoutBottomBarButtons.addView(Space(context).apply { + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + }) + } + + private fun updateMoreButtons(buttons: MutableList) { + //_moreButtonImages.clear(); + _moreButtons.clear(); + _layoutMoreButtons.removeAllViews(); + + //Force buy to be on top for more buttons + val buyIndex = buttons.indexOfFirst { b -> b.id == 98 }; + if (buyIndex != -1) { + val button = buttons[buyIndex] + buttons.removeAt(buyIndex) + buttons.add(0, button) + } + + for (data in buttons) { + val button = MenuButton(context, data, _fragment, true); + button.setOnClickListener { + updateMenuIcons() + data.action(_fragment) + setMoreVisible(false); + }; + + _moreButtons.add(button); + _layoutMoreButtons.addView(button); + } + } + + private fun updateMenuIcons() { + for(button in _bottomButtons.toList()) + button.updateActive(_fragment); + for(button in _moreButtons.toList()) + button.updateActive(_fragment); + } + + fun updateAllButtonVisibility() { + val defs = currentButtonDefinitions?.toMutableList() ?: return + val metrics = StateApp.instance.displayMetrics ?: resources.displayMetrics; + _buttonsVisible = floor(metrics.widthPixels.toDouble() / 65.dp(resources).toDouble()).roundToInt(); + if (_buttonsVisible - 2 >= defs.size) { + updateBottomMenuButtons(defs.slice(IntRange(0, defs.size - 1)).toMutableList(), false); + } else { + updateBottomMenuButtons(defs.slice(IntRange(0, _buttonsVisible - 2)).toMutableList(), true); + updateMoreButtons(defs.slice(IntRange(_buttonsVisible - 1, defs.size - 1)).toMutableList()); + } + } + + private fun registerUpdateButtonEvents() { + _subscriptionsVisible = StateSubscriptions.instance.getSubscriptionCount() > 0; + StateSubscriptions.instance.onSubscriptionsChanged.subscribe(this) { subs, _ -> + _subscriptionsVisible = subs.isNotEmpty(); + updateButtonDefinitions() + } + + StatePayment.instance.hasPaidChanged.subscribe(this) { + _fragment.lifecycleScope.launch(Dispatchers.Main) { + updateButtonDefinitions() + } + }; + + Settings.instance.onTabsChanged.subscribe(this) { + updateButtonDefinitions() + } + } + + private fun unregisterUpdateButtonEvents() { + StateSubscriptions.instance.onSubscriptionsChanged.remove(this); + Settings.instance.onTabsChanged.remove(this) + StatePayment.instance.hasPaidChanged.remove(this) + } + + private fun updateButtonDefinitions() { + val newCurrentButtonDefinitions = Settings.instance.tabs.filter { it.enabled }.mapNotNull { + if (it.id == 1 && !_subscriptionsVisible) { + return@mapNotNull null + } + + buttonDefinitions.find { d -> d.id == it.id } + }.toMutableList() + + if (!StatePayment.instance.hasPaid) { + newCurrentButtonDefinitions.add(ButtonDefinition(98, R.drawable.ic_paid, R.drawable.ic_paid, R.string.buy, canToggle = false, { it.currentMain is BuyFragment }, { it.navigate() })) + } + + //Add conditional buttons here, when you add a conditional button, be sure to add the register and unregister events for when the button needs to be updated + + currentButtonDefinitions = newCurrentButtonDefinitions + updateAllButtonVisibility() + } + + + class MenuButton: LinearLayout { + val definition: ButtonDefinition; + + private val _buttonImage: ImageView; + private val _textButton: TextView; + + constructor(context: Context, def: ButtonDefinition, fragment: MenuBottomBarFragment, isMore: Boolean): super(context) { + inflate(context, if(isMore) R.layout.view_bottom_more_menu_button else R.layout.view_bottom_menu_button, this); + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + + this.definition = def; + + _buttonImage = findViewById(R.id.image_button); + _buttonImage.setImageResource(if (def.isActive(fragment)) def.iconActive else def.icon); + + _textButton = findViewById(R.id.text_button); + _textButton.text = resources.getString(def.string); + + val root = findViewById(R.id.root); + root.setOnClickListener { + this.performClick(); + } + } + + fun updateActive(fragment: MenuBottomBarFragment) { + _buttonImage.setImageResource(if (definition.isActive(fragment)) definition.iconActive else definition.icon); + } + } + } + + companion object { + private const val TAG = "MenuBottomBarFragment"; + + fun newInstance() = MenuBottomBarFragment().apply { } + + //Add configurable buttons here + var buttonDefinitions = listOf( + ButtonDefinition(0, R.drawable.ic_home, R.drawable.ic_home_filled, R.string.home, canToggle = true, { it.currentMain is HomeFragment }, { it.navigate() }), + ButtonDefinition(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate() }), + ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate() }), + ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate() }), + ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate() }), + ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate() }), + ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate() }), + ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings, R.string.settings, canToggle = false, { false }, { + val c = it.context ?: return@ButtonDefinition; + Logger.i(TAG, "settings preventPictureInPicture()"); + it.requireFragment().preventPictureInPicture(); + val intent = Intent(c, SettingsActivity::class.java); + c.startActivity(intent); + if (c is Activity) { + c.overridePendingTransition(R.anim.slide_in_up, R.anim.slide_darken); + } + }) + //98 is reversed for buy button + //99 is reserved for more button + ); + } + + data class ButtonDefinition( + val id: Int, + val icon: Int, + val iconActive: Int, + val string: Int, + val canToggle: Boolean, + val isActive: (fragment: MenuBottomBarFragment) -> Boolean, + val action: (fragment: MenuBottomBarFragment) -> Unit); +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/BrowserFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/BrowserFragment.kt new file mode 100644 index 00000000..805cabd4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/BrowserFragment.kt @@ -0,0 +1,62 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.webkit.CookieManager +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.Spinner +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.R +import com.futo.platformplayer.views.adapters.SubscriptionAdapter + +class BrowserFragment : MainFragment() { + override val isMainView : Boolean = true; + override val isTab: Boolean = false; + override val hasBottomBar: Boolean get() = true; + + private var _webview: WebView? = null; + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = inflater.inflate(R.layout.fragment_browser, container, false); + _webview = view.findViewById(R.id.webview).apply { + this.webViewClient = object: WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { + return false; + } + }; + this.settings.javaScriptEnabled = true; + CookieManager.getInstance().setAcceptCookie(true); + this.settings.domStorageEnabled = true; + }; + return view; + } + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack) + + if(parameter is String) + _webview?.loadUrl(parameter); + else + _webview?.loadUrl("about:blank"); + } + + override fun onHide() { + super.onHide() + _webview?.loadUrl("about:blank"); + } + + override fun onBackPressed(): Boolean { + return false; + } + + companion object { + fun newInstance() = BrowserFragment().apply {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/BuyFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/BuyFragment.kt new file mode 100644 index 00000000..5eecc4a5 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/BuyFragment.kt @@ -0,0 +1,153 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import androidx.lifecycle.lifecycleScope +import com.futo.futopay.PaymentConfigurations +import com.futo.futopay.PaymentManager +import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StatePayment +import com.futo.platformplayer.views.overlays.LoaderOverlay +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class BuyFragment : MainFragment() { + override val isMainView : Boolean = true; + override val isTab: Boolean = false; + override val hasBottomBar: Boolean get() = true; + + private var _view: BuyView? = null; + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = BuyView(this, inflater); + _view = view; + return view; + } + + class BuyView: LinearLayout { + private val _fragment: BuyFragment; + + private val _buttonBuy: LinearLayout; + private val _buttonBuyText: TextView; + private val _buttonPaid: LinearLayout; + + private val _overlayPaying: FrameLayout; + + private val _paymentManager: PaymentManager; + + private val _overlayLoading: LoaderOverlay; + + constructor(fragment: BuyFragment, inflater: LayoutInflater) : super(inflater.context) { + _fragment = fragment; + inflater.inflate(R.layout.fragment_buy, this); + + _buttonBuy = findViewById(R.id.button_buy); + _buttonBuyText = findViewById(R.id.button_buy_text); + _buttonPaid = findViewById(R.id.button_paid); + _overlayLoading = findViewById(R.id.overlay_loading); + _overlayPaying = findViewById(R.id.overlay_paying); + + _paymentManager = PaymentManager(StatePayment.instance, fragment, _overlayPaying) { success, purchaseId, exception -> + if(success) { + UIDialogs.showDialog(context, R.drawable.ic_check, "Payment succeeded", "Thanks for your purchase, a key will be sent to your email after your payment has been received!", null, 0, + UIDialogs.Action("Ok", {}, UIDialogs.ActionStyle.PRIMARY)); + _fragment.close(true); + } + else { + UIDialogs.showGeneralErrorDialog(context, "Payment failed", exception); + } + } + + _buttonBuy.setOnClickListener { + buy(); + } + _buttonPaid.setOnClickListener { + paid(); + } + + fragment.lifecycleScope.launch(Dispatchers.IO) { + //Calling this function will cache first call + try { + val currencies = StatePayment.instance.getAvailableCurrencies("grayjay"); + val prices = StatePayment.instance.getAvailableCurrencyPrices("grayjay"); + val country = StatePayment.instance.getPaymentCountryFromIP()?.let { c -> PaymentConfigurations.COUNTRIES.find { it.id.equals(c, ignoreCase = true) } }; + val currency = country?.let { c -> PaymentConfigurations.CURRENCIES.find { it.id == c.defaultCurrencyId && (currencies.contains(it.id) ?: true) } }; + + if(currency != null && prices.containsKey(currency.id)) { + val price = prices[currency.id]!!; + val priceDecimal = (price.toDouble() / 100); + withContext(Dispatchers.Main) { + _buttonBuyText.text = currency.symbol + String.format("%.2f", priceDecimal); + } + } + } + catch(ex: Throwable) { + Logger.e("BuyFragment", "Failed to prefetch payment info", ex); + } + } + } + + + private fun buy() { + _paymentManager.startPayment(StatePayment.instance, _fragment.lifecycleScope, "grayjay"); + } + + private fun paid() { + val licenseInput = SlideUpMenuTextInput(context, "License"); + val productLicenseDialog = SlideUpMenuOverlay(context, findViewById(R.id.overlay_paid), "Enter license key", "Ok", true, licenseInput); + productLicenseDialog.onOK.subscribe { + val licenseText = licenseInput.text; + if (licenseText.isNullOrEmpty()) { + UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Invalid license key"); + return@subscribe; + } + + _fragment.lifecycleScope.launch(Dispatchers.IO) { + + try{ + val activationResult = StatePayment.instance.setPaymentLicense(licenseText); + + withContext(Dispatchers.Main) { + if(activationResult) { + licenseInput.deactivate(); + licenseInput.clear(); + productLicenseDialog.hide(true); + + UIDialogs.showDialogOk(context, R.drawable.ic_check, "Your license key has been set!\nAn app restart might be required."); + _fragment.close(true); + } + else + { + UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Invalid license key"); + } + } + } + catch(ex: Throwable) { + Logger.e("BuyFragment", "Failed to activate key", ex); + withContext(Dispatchers.Main) { + UIDialogs.showGeneralErrorDialog(context, "Failed to activate key", ex); + } + } + } + }; + productLicenseDialog.show(); + } + + } + + + + companion object { + fun newInstance() = BuyFragment().apply {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt new file mode 100644 index 00000000..c38eb798 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt @@ -0,0 +1,425 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.annotation.SuppressLint +import android.graphics.drawable.Animatable +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.widget.AppCompatImageView +import androidx.lifecycle.lifecycleScope +import androidx.viewpager2.widget.ViewPager2 +import com.bumptech.glide.Glide +import com.futo.platformplayer.* +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.api.media.models.channels.SerializedChannel +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist +import com.futo.platformplayer.api.media.models.post.IPlatformPost +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.fragment.channel.tab.ChannelAboutFragment +import com.futo.platformplayer.fragment.channel.tab.ChannelListFragment +import com.futo.platformplayer.fragment.channel.tab.ChannelMonetizationFragment +import com.futo.platformplayer.fragment.channel.tab.ChannelContentsFragment +import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment +import com.futo.platformplayer.images.GlideHelper.Companion.crossfade +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.SearchType +import com.futo.platformplayer.models.Subscription +import com.futo.platformplayer.polycentric.PolycentricCache +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.subscriptions.SubscribeButton +import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay +import com.futo.polycentric.core.* +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable + +@Serializable +data class PolycentricProfile(val system: PublicKey, val systemState: SystemState, val ownedClaims: List); + +class ChannelFragment : MainFragment() { + override val isMainView : Boolean = true; + override val hasBottomBar: Boolean = true; + private var _view: ChannelView? = null; + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + _view?.onShown(parameter, isBack); + } + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = ChannelView(this, inflater); + _view = view; + return view; + } + + override fun onBackPressed(): Boolean { + return _view?.onBackPressed() ?: false; + } + + override fun onDestroyMainView() { + super.onDestroyMainView(); + + _view?.cleanup(); + _view = null; + } + + fun selectTab(selectedTabIndex: Int) { + _view?.selectTab(selectedTabIndex); + } + + @SuppressLint("ViewConstructor") + class ChannelView : LinearLayout { + private val _fragment: ChannelFragment; + + private var _textChannel: TextView; + private var _textChannelSub: TextView; + private var _creatorThumbnail: CreatorThumbnail; + private var _imageBanner: AppCompatImageView; + + private var _tabs: TabLayout; + private var _viewPager: ViewPager2; + private var _tabLayoutMediator: TabLayoutMediator; + private var _buttonSubscribe: SubscribeButton; + + private var _overlayContainer: FrameLayout; + private var _overlay_loading: LinearLayout; + private var _overlay_loading_spinner: ImageView; + + private var _slideUpOverlay: SlideUpMenuOverlay? = null; + + private var _isLoading: Boolean = false; + private var _selectedTabIndex: Int = -1; + var channel: IPlatformChannel? = null + private set; + private var _url: String? = null; + + private val _onPageChangeCallback = object : ViewPager2.OnPageChangeCallback() { + override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) { + super.onPageScrolled(position, positionOffset, positionOffsetPixels); + //recalculate(position, positionOffset); + } + } + + private val _taskLoadPolycentricProfile: TaskHandler; + private val _taskGetChannel: TaskHandler; + + constructor(fragment: ChannelFragment, inflater: LayoutInflater) : super(inflater.context) { + _fragment = fragment; + inflater.inflate(R.layout.fragment_channel, this); + + _taskLoadPolycentricProfile = TaskHandler({fragment.lifecycleScope}, { id -> + return@TaskHandler PolycentricCache.instance.getProfileAsync(id); + }) + .success { it -> setPolycentricProfile(it, animate = true) } + .exception { + Logger.w(TAG, "Failed to load polycentric profile.", it); + }; + + _taskGetChannel = TaskHandler({fragment.lifecycleScope}, { url -> StatePlatform.instance.getChannelLive(url) }) + .success { showChannel(it); } + .exception { + + UIDialogs.showDialog(context, + R.drawable.ic_sources, + "No source enabled to support this channel\n(${_url})", null, null, + 0, + UIDialogs.Action("Back", { + fragment.close(true); + }, UIDialogs.ActionStyle.PRIMARY) + ); + } + .exception { + Logger.e(TAG, "Failed to load channel.", it); + UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadChannel() }); + } + + val tabs: TabLayout = findViewById(R.id.tabs); + val viewPager: ViewPager2 = findViewById(R.id.view_pager); + _textChannel = findViewById(R.id.text_channel_name); + _textChannelSub = findViewById(R.id.text_metadata); + _creatorThumbnail = findViewById(R.id.creator_thumbnail); + _imageBanner = findViewById(R.id.image_channel_banner); + _buttonSubscribe = findViewById(R.id.button_subscribe); + _overlay_loading = findViewById(R.id.channel_loading_overlay); + _overlay_loading_spinner = findViewById(R.id.channel_loader); + _overlayContainer = findViewById(R.id.overlay_container); + + //TODO: Determine if this is really the only solution (isSaveEnabled=false) + viewPager.isSaveEnabled = false; + viewPager.registerOnPageChangeCallback(_onPageChangeCallback); + val adapter = ChannelViewPagerAdapter(fragment.childFragmentManager, fragment.lifecycle); + adapter.onChannelClicked.subscribe { c -> fragment.navigate(c) } + adapter.onContentClicked.subscribe { v, _ -> + if(v is IPlatformVideo) { + fragment.navigate(v).maximizeVideoDetail(); + } else if (v is IPlatformPlaylist) { + fragment.navigate(v); + } else if (v is IPlatformPost) { + fragment.navigate(v); + } + } + adapter.onAddToClicked.subscribe {content -> + _overlayContainer.let { + if(content is IPlatformVideo) + _slideUpOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it); + } + } + adapter.onContentUrlClicked.subscribe { url, contentType -> + when(contentType) { + ContentType.MEDIA -> fragment.navigate(url).maximizeVideoDetail(); + ContentType.URL -> fragment.navigate(url); + else -> {}; + } + } + viewPager.adapter = adapter; + + val tabLayoutMediator = TabLayoutMediator(tabs, viewPager) { tab, position -> + tab.text = when (position) { + 0 -> "VIDEOS" + 1 -> "CHANNELS" + //2 -> "STORE" + 2 -> "SUPPORT" + 3 -> "ABOUT" + else -> "Unknown $position" + }; + }; + tabLayoutMediator.attach(); + + _tabLayoutMediator = tabLayoutMediator; + _tabs = tabs; + _viewPager = viewPager; + + if (_selectedTabIndex != -1) { + selectTab(_selectedTabIndex); + } + + setLoading(true); + } + + fun cleanup() { + _taskLoadPolycentricProfile.cancel(); + _taskGetChannel.cancel(); + _tabLayoutMediator.detach(); + _viewPager.unregisterOnPageChangeCallback(_onPageChangeCallback); + hideSlideUpOverlay(); + (_overlay_loading_spinner.drawable as Animatable?)?.stop(); + } + + fun onShown(parameter: Any?, isBack: Boolean) { + hideSlideUpOverlay(); + _taskLoadPolycentricProfile.cancel(); + _selectedTabIndex = -1; + + if (!isBack) { + _imageBanner.setImageDrawable(null); + + if (parameter is String) { + _buttonSubscribe.setSubscribeChannel(parameter); + _textChannel.text = ""; + _textChannelSub.text = ""; + + _url = parameter; + loadChannel(); + } else if (parameter is SerializedChannel) { + showChannel(parameter); + _url = parameter.url; + _creatorThumbnail.setThumbnail(parameter.url, false); + loadChannel(); + } else if (parameter is IPlatformChannel) + showChannel(parameter); + else if (parameter is PlatformAuthorLink) { + _textChannel.text = parameter.name; + _textChannelSub.text = ""; + _creatorThumbnail.setThumbnail(parameter.url, false); + _url = parameter.url; + loadChannel(); + } else if (parameter is Subscription) { + _textChannel.text = parameter.channel.name; + _textChannelSub.text = ""; + _creatorThumbnail.setThumbnail(parameter.channel.thumbnail, false); + + _url = parameter.channel.url; + loadChannel(); + } + } else { + loadChannel(); + } + } + + fun selectTab(selectedTabIndex: Int) { + _selectedTabIndex = selectedTabIndex; + _tabs.selectTab(_tabs.getTabAt(selectedTabIndex)); + } + + private fun setLoading(isLoading: Boolean) { + if (_isLoading == isLoading) { + return; + } + + _isLoading = isLoading; + if(isLoading){ + _overlay_loading.visibility = View.VISIBLE; + (_overlay_loading_spinner.drawable as Animatable?)?.start(); + } + else { + (_overlay_loading_spinner.drawable as Animatable?)?.stop(); + _overlay_loading.visibility = View.GONE; + } + } + + fun onBackPressed(): Boolean { + if (_slideUpOverlay != null) { + hideSlideUpOverlay(); + return true; + } + + return false; + } + + private fun hideSlideUpOverlay() { + _slideUpOverlay?.hide(false); + _slideUpOverlay = null; + } + + + private fun loadChannel() { + val url = _url; + if (url != null) { + setLoading(true); + _taskGetChannel.run(url); + } + } + + private fun showChannel(channel: IPlatformChannel) { + setLoading(false); + + _fragment.topBar?.onShown(channel); + + val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) { + UIDialogs.showConfirmationDialog(context, "Do you want to convert channel ${channel.name} to a playlist?", { + UIDialogs.showDialogProgress(context) { + _fragment.lifecycleScope.launch(Dispatchers.IO) { + try { + StatePlaylists.instance.createPlaylistFromChannel(channel) { page -> + _fragment.lifecycleScope.launch(Dispatchers.Main) { + it.setText("${channel.name}\nPage ${page}"); + } + }; + } + catch(ex: Exception) { + Logger.e(TAG, "Error", ex); + UIDialogs.showGeneralErrorDialog(context, "Failed to convert channel", ex); + } + + withContext(Dispatchers.Main) { + it.hide(); + } + }; + }; + }); + }); + + val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url); + if (plugin != null && plugin.capabilities.hasSearchChannelContents) { + buttons.add(Pair(R.drawable.ic_search) { + _fragment.navigate(SuggestionsFragmentData("", SearchType.VIDEO, channel.url)); + }); + } + + _fragment.topBar?.assume()?.setMenuItems(buttons); + + _buttonSubscribe.setSubscribeChannel(channel); + _textChannel.text = channel.name; + _textChannelSub.text = "${channel.subscribers.toHumanNumber()} subscribers"; + + _creatorThumbnail.setThumbnail(channel.thumbnail, true); + Glide.with(_imageBanner) + .load(channel.banner) + .crossfade() + .into(_imageBanner) + + //TODO: Find a better way to access the adapter fragments.. + + (_viewPager.adapter as ChannelViewPagerAdapter?)?.let { + it.getFragment().setChannel(channel); + it.getFragment().setChannel(channel); + it.getFragment().setChannel(channel); + it.getFragment().setChannel(channel); + //TODO: Call on other tabs as needed + } + + this.channel = channel; + + val cachedProfile = PolycentricCache.instance.getCachedProfile(channel.url); + if (cachedProfile != null) { + setPolycentricProfile(cachedProfile, animate = false); + } else { + setPolycentricProfile(null, animate = false); + _taskLoadPolycentricProfile.run(channel.id); + } + } + + private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) { + Log.i(TAG, "setPolycentricProfile(cachedPolycentricProfile = $cachedPolycentricProfile, animate = $animate)") + + val polycentricProfile = cachedPolycentricProfile?.profile; + if (polycentricProfile != null) { + _fragment.topBar?.onShown(polycentricProfile); + + if (polycentricProfile.systemState.username.isNotBlank()) + _textChannel.text = polycentricProfile.systemState.username; + + val dp_35 = 35.dp(resources) + val avatar = polycentricProfile.systemState.avatar?.selectBestImage(dp_35 * dp_35) + ?.let { it.toURLInfoSystemLinkUrl(polycentricProfile.system.toProto(), it.process, polycentricProfile.systemState.servers.toList()) }; + + if (avatar != null) { + _creatorThumbnail.setThumbnail(avatar, true); + } else { + _creatorThumbnail.setHarborAvailable(true, true); + } + + val banner = polycentricProfile.systemState.banner?.selectHighestResolutionImage() + ?.let { it.toURLInfoSystemLinkUrl(polycentricProfile.system.toProto(), it.process, polycentricProfile.systemState.servers.toList()) }; + + if (banner != null) { + Glide.with(_imageBanner) + .load(banner) + .crossfade() + .into(_imageBanner); + } + } + + (_viewPager.adapter as ChannelViewPagerAdapter?)?.let { + it.getFragment().setPolycentricProfile(polycentricProfile, animate); + it.getFragment().setPolycentricProfile(polycentricProfile, animate); + it.getFragment().setPolycentricProfile(polycentricProfile, animate); + it.getFragment().setPolycentricProfile(polycentricProfile, animate); + //TODO: Call on other tabs as needed + } + } + } + + companion object { + val TAG = "ChannelFragment"; + fun newInstance() = ChannelFragment().apply { } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt new file mode 100644 index 00000000..a7382097 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt @@ -0,0 +1,202 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.annotation.SuppressLint +import android.content.Context +import android.util.Log +import android.view.LayoutInflater +import android.widget.LinearLayout +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.* +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist +import com.futo.platformplayer.api.media.models.post.IPlatformPost +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.structures.* +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.video.PlayerManager +import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.adapters.PreviewContentListAdapter +import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder +import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader +import com.futo.platformplayer.views.adapters.InsertedViewHolder +import com.futo.platformplayer.views.adapters.PreviewNestedVideoViewHolder +import com.futo.platformplayer.views.adapters.PreviewVideoViewHolder +import kotlin.math.floor + +abstract class ContentFeedView : FeedView, ContentPreviewViewHolder> where TFragment : MainFragment { + private var _exoPlayer: PlayerManager? = null; + + override val feedStyle: FeedStyle = FeedStyle.PREVIEW; + + private var _previewsEnabled: Boolean = true; + override val visibleThreshold: Int get() = if (feedStyle == FeedStyle.PREVIEW) { 5 } else { 10 }; + protected lateinit var headerView: LinearLayout; + + constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData, LinearLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>? = null) : super(fragment, inflater, cachedRecyclerData) { + + } + + override fun filterResults(results: List): List { + return results; + } + + override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList): InsertedViewAdapterWithLoader { + val player = StatePlayer.instance.getThumbnailPlayerOrCreate(context); + player.modifyState("ThumbnailPlayer", { state -> state.muted = true }); + _exoPlayer = player; + + val v = LinearLayout(context).apply { + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); + orientation = LinearLayout.VERTICAL; + }; + headerView = v; + + return PreviewContentListAdapter(context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(v)).apply { + attachAdapterEvents(this); + } + } + + private fun attachAdapterEvents(adapter: PreviewContentListAdapter) { + adapter.onContentUrlClicked.subscribe(this, this@ContentFeedView::onContentUrlClicked); + adapter.onContentClicked.subscribe(this) { content, time -> + this@ContentFeedView.onContentClicked(content, time); + }; + adapter.onChannelClicked.subscribe(this) { fragment.navigate(it) }; + adapter.onAddToClicked.subscribe(this) { content -> + //TODO: Reconstruct search video from detail if search is null + _overlayContainer.let { + if(content is IPlatformVideo) + UISlideOverlays.showVideoOptionsOverlay(content, it) { + if (fragment is HomeFragment) { + val removeIndex = recyclerData.results.indexOf(content); + if (removeIndex >= 0) { + recyclerData.results.removeAt(removeIndex); + recyclerData.adapter.notifyItemRemoved(recyclerData.adapter.childToParentPosition(removeIndex)); + } + } + }; + } + }; + adapter.onAddToQueueClicked.subscribe(this) { + if(it is IPlatformVideo) { + StatePlayer.instance.addToQueue(it); + val name = if (it.name.length > 20) (it.name.subSequence(0, 20).toString() + "...") else it.name; + UIDialogs.toast(context, "Queued [$name]", false); + } + }; + } + + private fun detachAdapterEvents() { + val adapter = recyclerData.adapter as PreviewContentListAdapter? ?: return; + adapter.onContentUrlClicked.remove(this); + adapter.onContentClicked.remove(this); + adapter.onChannelClicked.remove(this); + adapter.onAddToClicked.remove(this); + adapter.onAddToQueueClicked.remove(this); + } + + override fun onRestoreCachedData(cachedData: RecyclerData, LinearLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>) { + super.onRestoreCachedData(cachedData) + val v = LinearLayout(context).apply { + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); + orientation = LinearLayout.VERTICAL; + }; + headerView = v; + cachedData.adapter.viewsToPrepend.add(v); + (cachedData.adapter as PreviewContentListAdapter?)?.let { attachAdapterEvents(it) }; + } + + override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): LinearLayoutManager { + val llmResults = LinearLayoutManager(context); + llmResults.orientation = LinearLayoutManager.VERTICAL; + return llmResults; + } + + override fun onScrollStateChanged(newState: Int) { + if (!_previewsEnabled) + return; + + if (newState == RecyclerView.SCROLL_STATE_IDLE) + playPreview(); + } + + protected open fun onContentClicked(content: IPlatformContent, time: Long) { + if(content is IPlatformVideo) { + if (Settings.instance.playback.shouldResumePreview(time)) + fragment.navigate(content.withTimestamp(time)).maximizeVideoDetail(); + else + fragment.navigate(content).maximizeVideoDetail(); + } else if (content is IPlatformPlaylist) { + fragment.navigate(content); + } else if (content is IPlatformPost) { + fragment.navigate(content); + } + } + protected open fun onContentUrlClicked(url: String, contentType: ContentType) { + when(contentType) { + ContentType.MEDIA -> fragment.navigate(url).maximizeVideoDetail(); + ContentType.PLAYLIST -> fragment.navigate(url); + ContentType.URL -> fragment.navigate(url); + else -> {}; + } + } + + private fun playPreview() { + if(feedStyle == FeedStyle.THUMBNAIL) + return; + + val firstVisible = recyclerData.layoutManager.findFirstVisibleItemPosition(); + val lastVisible = recyclerData.layoutManager.findLastVisibleItemPosition(); + val itemsVisible = lastVisible - firstVisible + 1; + val autoPlayIndex = (firstVisible + floor(itemsVisible / 2.0 + 0.49).toInt()).coerceAtLeast(0).coerceAtMost((recyclerData.results.size - 1)); + + Log.v(TAG, "auto play index=$autoPlayIndex"); + val viewHolder = _recyclerResults.findViewHolderForAdapterPosition(autoPlayIndex) ?: return; + Logger.i(TAG, "viewHolder=$viewHolder") + if (viewHolder !is InsertedViewHolder<*>) { + return; + } + + if (viewHolder.childViewHolder !is PreviewVideoViewHolder && viewHolder.childViewHolder !is PreviewNestedVideoViewHolder) { + return; + } + + //TODO: Is this still necessary? + if(viewHolder.childViewHolder is ContentPreviewViewHolder) + (recyclerData.adapter as PreviewContentListAdapter?)?.preview(viewHolder.childViewHolder) + } + + fun stopVideo() { + //TODO: Is this still necessary? + (recyclerData.adapter as PreviewContentListAdapter?)?.stopPreview(); + } + + fun onPause() { + stopVideo(); + } + + override fun cleanup() { + super.cleanup(); + val viewCount = recyclerData.adapter.viewsToPrepend.size; + detachAdapterEvents(); + recyclerData.adapter.viewsToPrepend.clear(); + recyclerData.adapter.notifyItemRangeRemoved(0, viewCount); + (recyclerData.adapter as PreviewContentListAdapter?)?.release(); + } + + fun setPreviewsEnabled(previewsEnabled: Boolean) { + if (!previewsEnabled) + stopVideo(); + else + playPreview(); + + _previewsEnabled = previewsEnabled; + } + + companion object { + private val TAG = "ContentFeedView"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt new file mode 100644 index 00000000..035ff8ed --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt @@ -0,0 +1,289 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.lifecycleScope +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UISlideOverlays +import com.futo.platformplayer.api.media.models.ResultCapabilities +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment +import com.futo.platformplayer.views.FeedStyle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ContentSearchResultsFragment : MainFragment() { + override val isMainView : Boolean = true; + override val isTab: Boolean = false; + override val hasBottomBar: Boolean get() = true; + + private var _view: ContentSearchResultsView? = null; + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + _view?.onShown(parameter, isBack); + } + + override fun onHide() { + super.onHide() + _view?.onHide(); + } + + override fun onResume() { + super.onResume() + _view?.onResume(); + } + + override fun onPause() { + super.onPause() + _view?.onPause(); + } + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = ContentSearchResultsView(this, inflater); + _view = view; + return view; + } + + override fun onDestroyMainView() { + super.onDestroyMainView(); + _view?.cleanup(); + _view = null; + } + + fun setPreviewsEnabled(previewsEnabled: Boolean) { + _view?.setPreviewsEnabled(previewsEnabled); + } + + @SuppressLint("ViewConstructor") + class ContentSearchResultsView : ContentFeedView { + override val feedStyle: FeedStyle get() = Settings.instance.search.getSearchFeedStyle(); + + private var _query: String? = null; + private var _sortBy: String? = null; + private var _filterValues: HashMap> = hashMapOf(); + private var _enabledClientIds: List? = null; + private var _channelUrl: String? = null; + + private val _taskSearch: TaskHandler>; + + constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) { + _taskSearch = TaskHandler>({fragment.lifecycleScope}, { query -> + Logger.i(TAG, "Searching for: $query") + val channelUrl = _channelUrl; + if (channelUrl != null) { + StatePlatform.instance.searchChannel(channelUrl, query, null, _sortBy, _filterValues, _enabledClientIds) + } else { + StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds) + } + }) + .success { loadedResult(it); } + .exception { + Logger.w(ChannelFragment.TAG, "Failed to load results.", it); + UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() }); + } + } + + override fun cleanup() { + super.cleanup(); + _taskSearch.cancel(); + } + + fun onShown(parameter: Any?, isBack: Boolean) { + if(parameter is SuggestionsFragmentData) { + if(!isBack) { + setQuery(parameter.query, false); + setChannelUrl(parameter.channelUrl, false); + + fragment.topBar?.apply { + if (this is SearchTopBarFragment) { + this.setText(parameter.query); + } + } + } + } + + fragment.topBar?.apply { + if (this is SearchTopBarFragment) { + setFilterButtonVisible(true); + + onFilterClick.subscribe(this) { + _overlayContainer?.let { + val filterValuesCopy = HashMap(_filterValues); + val filtersOverlay = UISlideOverlays.showFiltersOverlay(lifecycleScope, it, _enabledClientIds!!, filterValuesCopy); + filtersOverlay.onOK.subscribe { enabledClientIds, changed -> + if (changed) { + setFilterValues(filtersOverlay.commonCapabilities, filterValuesCopy); + } + + _enabledClientIds = enabledClientIds; + + val sorts = filtersOverlay.commonCapabilities?.sorts ?: listOf(); + if (sorts.isNotEmpty()) { + setSortByOptions(sorts); + if (!sorts.contains(_sortBy)) { + _sortBy = null; + } + } else { + setSortByOptions(null); + _sortBy = null; + } + + loadResults(); + }; + }; + }; + + onSearch.subscribe(this) { + setQuery(it, true); + }; + } + } + + fragment.lifecycleScope.launch(Dispatchers.IO) { + try { + val commonCapabilities = StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id }); + val sorts = commonCapabilities?.sorts ?: listOf(); + if (sorts.size > 1) { + withContext(Dispatchers.Main) { + try { + setSortByOptions(sorts); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to set sort options.", e); + } + } + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to gtet common search capabilities.", e) + } + } + + onSortBySelect.subscribe(this) { + if (_sortBy == it) { + return@subscribe; + } + + Logger.i(TAG, "Sort by changed: $it") + setSortBy(it); + }; + + _enabledClientIds = StatePlatform.instance.getEnabledClients().map { it -> it.id }; + _sortBy = null; + _filterValues.clear(); + + clearResults(); + loadResults(); + } + + fun onHide() { + onSortBySelect.remove(this); + + fragment.topBar?.apply { + if (this is SearchTopBarFragment) { + setFilterButtonVisible(false); + onFilterClick.remove(this); + onSearch.remove(this); + } + }; + + setActiveTags(null); + setSortByOptions(null); + } + + override fun reload() { + loadResults(); + } + + private fun setQuery(query: String, updateResults: Boolean = true) { + _query = query; + + if (updateResults) { + clearResults(); + loadResults(); + } + } + + private fun setChannelUrl(channelUrl: String?, updateResults: Boolean = true) { + _channelUrl = channelUrl; + + if (updateResults) { + clearResults(); + loadResults(); + } + } + + private fun setSortBy(sortBy: String?, updateResults: Boolean = true) { + _sortBy = sortBy; + + if (updateResults) { + clearResults(); + loadResults(); + } + } + + private fun setFilterValues(resultCapabilities: ResultCapabilities?, filterValues: HashMap>) { + clearResults(); + + if (resultCapabilities != null) { + val tags = arrayListOf(); + for (filter in resultCapabilities.filters) { + val values = filterValues[filter.idOrName] ?: continue; + if (values.isNotEmpty()) { + val titles = arrayListOf(); + for (value in values) { + val title = filter.filters.firstOrNull { it.idOrName == value } ?: continue; + titles.add(title.idOrName); + } + + tags.add("${filter.name}: ${titles.joinToString(", ")}"); + } + } + + setActiveTags(tags); + } else { + setActiveTags(null); + } + + _filterValues = filterValues; + loadResults(); + } + + override fun onContentClicked(content: IPlatformContent, time: Long) { + super.onContentClicked(content, time) + + (fragment.topBar as SearchTopBarFragment?)?.apply { + clearFocus(); + } + } + + private fun loadResults() { + val query = _query; + if (query.isNullOrBlank()) { + return; + } + + setLoading(true); + _taskSearch.run(query); + } + private fun loadedResult(pager : IPager) { + finishRefreshLayoutLoader(); + setLoading(false); + setPager(pager); + } + } + + companion object { + private const val TAG = "VideoSearchResultsFragment"; + + fun newInstance() = ContentSearchResultsFragment().apply {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CreatorFeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CreatorFeedView.kt new file mode 100644 index 00000000..78f90fb1 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CreatorFeedView.kt @@ -0,0 +1,51 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.content.Context +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.ViewGroup.MarginLayoutParams +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.* +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.structures.* +import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.adapters.* +import com.futo.platformplayer.views.adapters.viewholders.CreatorViewHolder + +abstract class CreatorFeedView : FeedView, CreatorViewHolder> where TFragment : MainFragment { + override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator; + + constructor(fragment: TFragment, inflater: LayoutInflater) : super(fragment, inflater) { + + } + + override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList): InsertedViewAdapterWithLoader { + return InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(), + childCountGetter = { dataset.size }, + childViewHolderBinder = { viewHolder, position -> viewHolder.bind(dataset[position]); }, + childViewHolderFactory = { viewGroup, _ -> + val holder = CreatorViewHolder(viewGroup, false); + holder.onClick.subscribe { c -> fragment.navigate(c) }; + return@InsertedViewAdapterWithLoader holder; + } + ); + } + + override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): LinearLayoutManager { + val glmResults = GridLayoutManager(context, 2); + glmResults.orientation = LinearLayoutManager.VERTICAL; + + _swipeRefresh.layoutParams = (_swipeRefresh.layoutParams as MarginLayoutParams?)?.apply { + rightMargin = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8.0f, context.resources.displayMetrics).toInt(); + }; + + return glmResults; + } + + companion object { + private val TAG = "CreatorFeedView"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CreatorSearchResultsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CreatorSearchResultsFragment.kt new file mode 100644 index 00000000..ab58f63f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CreatorSearchResultsFragment.kt @@ -0,0 +1,116 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.lifecycleScope +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.Settings +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment +import com.futo.platformplayer.views.FeedStyle + +class CreatorSearchResultsFragment : MainFragment() { + override val isMainView : Boolean = true; + override val isTab: Boolean = false; + override val hasBottomBar: Boolean get() = true; + + private var _view: CreatorSearchResultsView? = null; + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + _view?.onShown(parameter, isBack); + } + + override fun onResume() { + super.onResume() + _view?.onResume(); + } + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = CreatorSearchResultsView(this, inflater); + _view = view; + return view; + } + + override fun onDestroyMainView() { + super.onDestroyMainView(); + _view?.cleanup(); + _view = null; + } + + @SuppressLint("ViewConstructor") + class CreatorSearchResultsView : CreatorFeedView { + override val feedStyle: FeedStyle get() = Settings.instance.search.getSearchFeedStyle(); + + private var _query: String? = null; + + private val _taskSearch: TaskHandler>; + + constructor(fragment: CreatorSearchResultsFragment, inflater: LayoutInflater): super(fragment, inflater) { + _taskSearch = TaskHandler>({fragment.lifecycleScope}, { query -> StatePlatform.instance.searchChannels(query) }) + .success { loadedResult(it); } + .exception { + Logger.w(ChannelFragment.TAG, "Failed to load results.", it); + UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() }); + } + } + + override fun cleanup() { + super.cleanup(); + _taskSearch.cancel(); + } + + fun onShown(parameter: Any?, isBack: Boolean) { + if(parameter is String) { + if(!isBack) { + setQuery(parameter); + + fragment.topBar?.apply { + if (this is SearchTopBarFragment) { + setText(parameter); + onSearch.subscribe(this) { + setQuery(it); + }; + } + } + } + } + } + + override fun reload() { + loadResults(); + } + + private fun setQuery(query: String) { + clearResults(); + _query = query; + loadResults(); + } + + private fun loadResults() { + val query = _query; + if (query.isNullOrBlank()) { + return; + } + + setLoading(true); + _taskSearch.run(query); + } + private fun loadedResult(pager: IPager) { + finishRefreshLayoutLoader(); + setLoading(false); + setPager(pager); + } + } + + companion object { + fun newInstance() = CreatorSearchResultsFragment().apply {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CreatorsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CreatorsFragment.kt new file mode 100644 index 00000000..421ae22a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CreatorsFragment.kt @@ -0,0 +1,56 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.Spinner +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.R +import com.futo.platformplayer.views.adapters.SubscriptionAdapter + +class CreatorsFragment : MainFragment() { + override val isMainView : Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var _spinnerSortBy: Spinner? = null; + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = inflater.inflate(R.layout.fragment_creators, container, false); + + val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription)); + adapter.onClick.subscribe { platformUser -> navigate(platformUser) }; + + val spinnerSortBy: Spinner = view.findViewById(R.id.spinner_sortby); + spinnerSortBy.adapter = ArrayAdapter(view.context, R.layout.spinner_item_simple, resources.getStringArray(R.array.subscriptions_sortby_array)).also { + it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); + }; + spinnerSortBy.setSelection(adapter.sortBy); + spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { + adapter.sortBy = pos; + } + override fun onNothingSelected(parent: AdapterView<*>?) = Unit + }; + + _spinnerSortBy = spinnerSortBy; + + val recyclerView = view.findViewById(R.id.recycler_subscriptions); + recyclerView.adapter = adapter; + recyclerView.layoutManager = LinearLayoutManager(view.context); + return view; + } + + override fun onDestroyMainView() { + super.onDestroyMainView(); + _spinnerSortBy = null; + } + + companion object { + fun newInstance() = CreatorsFragment().apply {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt new file mode 100644 index 00000000..9d1f5680 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt @@ -0,0 +1,183 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.TextView +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.* +import com.futo.platformplayer.downloads.VideoDownload +import com.futo.platformplayer.downloads.VideoLocal +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateDownloads +import com.futo.platformplayer.views.AnyInsertedAdapterView +import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop +import com.futo.platformplayer.views.others.ProgressBar +import com.futo.platformplayer.views.adapters.viewholders.VideoDownloadViewHolder +import com.futo.platformplayer.views.items.ActiveDownloadItem +import com.futo.platformplayer.views.items.PlaylistDownloadItem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class DownloadsFragment : MainFragment() { + private val TAG = "DownloadsFragment"; + + override val isMainView : Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var _view: DownloadsView? = null; + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = DownloadsView(this, inflater); + _view = view; + return view; + } + + override fun onResume() { + super.onResume() + _view?.reloadUI(); + + StateDownloads.instance.onDownloadsChanged.subscribe(this) { + lifecycleScope.launch(Dispatchers.Main) { + try { + Logger.i(TAG, "Reloading UI for downloads"); + _view?.reloadUI() + } catch (e: Throwable) { + Logger.e(TAG, "Failed to reload UI for downloads", e) + } + } + }; + StateDownloads.instance.onDownloadedChanged.subscribe(this) { + lifecycleScope.launch(Dispatchers.Main) { + try { + Logger.i(TAG, "Reloading UI for downloaded"); + _view?.reloadUI(); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to reload UI for downloaded", e) + } + } + }; + StateDownloads.instance.onExportsChanged.subscribe(this) { + lifecycleScope.launch(Dispatchers.Main) { + try { + Logger.i(TAG, "Reloading UI for exports"); + _view?.reloadUI() + } catch (e: Throwable) { + Logger.e(TAG, "Failed to reload UI for exports", e) + } + } + }; + } + + override fun onPause() { + super.onPause(); + + StateDownloads.instance.onDownloadsChanged.remove(this); + StateDownloads.instance.onDownloadedChanged.remove(this); + StateDownloads.instance.onExportsChanged.remove(this); + } + + private class DownloadsView : LinearLayout { + private val TAG = "DownloadsView"; + private val _frag: DownloadsFragment; + + private val _usageUsed: TextView; + private val _usageAvailable: TextView; + private val _usageProgress: ProgressBar; + + private val _listActiveDownloadsContainer: LinearLayout; + private val _listActiveDownloadsMeta: TextView; + private val _listActiveDownloads: LinearLayout; + + private val _listPlaylistsContainer: LinearLayout; + private val _listPlaylistsMeta: TextView; + private val _listPlaylists: LinearLayout; + + private val _listDownloadedHeader: LinearLayout; + private val _listDownloadedMeta: TextView; + private val _listDownloaded: AnyInsertedAdapterView; + + constructor(frag: DownloadsFragment, inflater: LayoutInflater): super(frag.requireContext()) { + inflater.inflate(R.layout.fragment_downloads, this); + _frag = frag; + + _usageUsed = findViewById(R.id.downloads_usage_used); + _usageAvailable = findViewById(R.id.downloads_usage_available); + _usageProgress = findViewById(R.id.downloads_usage_progress); + + _listActiveDownloadsContainer = findViewById(R.id.downloads_active_downloads_container); + _listActiveDownloadsMeta = findViewById(R.id.downloads_active_downloads_meta); + _listActiveDownloads = findViewById(R.id.downloads_active_downloads_list); + + _listPlaylistsContainer = findViewById(R.id.downloads_playlist_container); + _listPlaylistsMeta = findViewById(R.id.downloads_playlist_meta); + _listPlaylists = findViewById(R.id.downloads_playlist_list); + + _listDownloadedHeader = findViewById(R.id.downloads_videos_header); + _listDownloadedMeta = findViewById(R.id.downloads_videos_meta); + + _listDownloaded = findViewById(R.id.list_downloaded) + .asAnyWithTop(findViewById(R.id.downloads_top)) { + it.onClick.subscribe { + _frag.navigate(it).maximizeVideoDetail(); + } + }; + + + reloadUI(); + } + + + fun reloadUI() { + val usage = StateDownloads.instance.getTotalUsage(true); + _usageUsed.text = "${usage.usage.toHumanBytesSize()} Used"; + _usageAvailable.text = "${usage.available.toHumanBytesSize()} Available"; + _usageProgress.progress = usage.percentage.toFloat(); + + + val activeDownloads = StateDownloads.instance.getDownloading(); + val playlists = StateDownloads.instance.getCachedPlaylists(); + val downloaded = StateDownloads.instance.getDownloadedVideos() + .filter { it.groupType != VideoDownload.GROUP_PLAYLIST || it.groupID == null || !StateDownloads.instance.hasCachedPlaylist(it.groupID!!) }; + + if(activeDownloads.isEmpty()) + _listActiveDownloadsContainer.visibility = GONE; + else { + _listActiveDownloadsContainer.visibility = VISIBLE; + _listActiveDownloadsMeta.text = "(${activeDownloads.size})"; + + _listActiveDownloads.removeAllViews(); + for(view in activeDownloads.map { ActiveDownloadItem(context, it, _frag.lifecycleScope) }) + _listActiveDownloads.addView(view); + } + + if(playlists.isEmpty()) + _listPlaylistsContainer.visibility = GONE; + else { + _listPlaylistsContainer.visibility = VISIBLE; + _listPlaylistsMeta.text = "(${playlists.size} playlists, ${playlists.sumOf { it.playlist.videos.size }} videos)"; + + _listPlaylists.removeAllViews(); + for(view in playlists.map { PlaylistDownloadItem(context, it) }) { + view.setOnClickListener { + _frag.navigate(view.playlist.playlist); + }; + _listPlaylists.addView(view); + } + } + + if(downloaded.isEmpty()) { + _listDownloadedHeader.visibility = GONE; + } else { + _listDownloadedHeader.visibility = VISIBLE; + _listDownloadedMeta.text = "(${downloaded.size} videos)"; + } + + _listDownloaded.setData(downloaded); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt new file mode 100644 index 00000000..d6c2c1bf --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt @@ -0,0 +1,425 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.content.Context +import android.graphics.Color +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.LayoutManager +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.futo.platformplayer.* +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.platforms.js.models.JSPager +import com.futo.platformplayer.api.media.structures.* +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.engine.exceptions.PluginException +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder +import com.futo.platformplayer.views.others.ProgressBar +import com.futo.platformplayer.views.others.TagsView +import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader +import com.futo.platformplayer.views.adapters.InsertedViewHolder +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.time.OffsetDateTime + +abstract class FeedView : LinearLayout where TPager : IPager, TViewHolder : RecyclerView.ViewHolder, TFragment : MainFragment { + protected val _recyclerResults: RecyclerView; + protected val _overlayContainer: FrameLayout; + protected val _swipeRefresh: SwipeRefreshLayout; + private val _progress_bar: ProgressBar; + private val _spinnerSortBy: Spinner; + private val _containerSortBy: LinearLayout; + private val _tagsView: TagsView; + + protected val _toolbarContentView: LinearLayout; + + private var _loading: Boolean = true; + + private val _pager_lock = Object(); + private var _cache: ItemCache? = null; + + open val visibleThreshold = 15; + + protected abstract val feedStyle: FeedStyle; + + val onTagClick = Event1(); + val onSortBySelect = Event1(); + + private var _sortByOptions: List? = null; + private var _activeTags: List? = null; + + private var _nextPageHandler: TaskHandler>; + val recyclerData: RecyclerData, LinearLayoutManager, TPager, TResult, TConverted, InsertedViewHolder>; + + val fragment: TFragment; + + private val _scrollListener: RecyclerView.OnScrollListener; + + constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData, LinearLayoutManager, TPager, TResult, TConverted, InsertedViewHolder>? = null) : super(inflater.context) { + this.fragment = fragment; + inflater.inflate(R.layout.fragment_feed, this); + + _progress_bar = findViewById(R.id.progress_bar); + _progress_bar.inactiveColor = Color.TRANSPARENT; + + _swipeRefresh = findViewById(R.id.swipe_refresh); + val recyclerResults: RecyclerView = findViewById(R.id.list_results); + + if (cachedRecyclerData != null) { + recyclerData = cachedRecyclerData; + onRestoreCachedData(cachedRecyclerData); + attachParentPagerEvents(); + attachPagerEvents(); + setLoading(false); + } else { + val lmResults = createLayoutManager(recyclerResults, context); + val dataset = arrayListOf(); + val adapterResults = createAdapter(recyclerResults, context, dataset); + recyclerData = RecyclerData(adapterResults, lmResults, dataset); + } + + _swipeRefresh.setOnRefreshListener { + reload(); + }; + + recyclerResults.layoutManager = recyclerData.layoutManager; + recyclerResults.adapter = recyclerData.adapter; + + _overlayContainer = findViewById(R.id.overlay_container); + _recyclerResults = recyclerResults; + + _containerSortBy = findViewById(R.id.container_sort_by); + _spinnerSortBy = findViewById(R.id.spinner_sortby); + _spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { + val sortByOptions = _sortByOptions ?: return; + if (pos == 0) { + onSortBySelect.emit(null); + return; + } + + onSortBySelect.emit(sortByOptions[pos - 1]); + } + override fun onNothingSelected(parent: AdapterView<*>?) { + onSortBySelect.emit(null); + } + }; + setSortByOptions(null); + _tagsView = findViewById(R.id.tags_view); + _tagsView.onClick.subscribe { onTagClick.emit(it.first) }; + setActiveTags(null); + + _toolbarContentView = findViewById(R.id.container_toolbar_content); + + _nextPageHandler = TaskHandler>({fragment.lifecycleScope}, { + if (it is IAsyncPager<*>) + it.nextPageAsync(); + else + it.nextPage(); + + processPagerExceptions(it); + return@TaskHandler it.getResults(); + }).success { + setLoading(false); + + if (it.isEmpty()) { + return@success; + } + + val posBefore = recyclerData.results.size; + val filteredResults = filterResults(it); + recyclerData.results.addAll(filteredResults); + recyclerData.resultsUnfiltered.addAll(it); + recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size); + }.exception { + Logger.w(TAG, "Failed to load next page.", it); + UIDialogs.showGeneralRetryErrorDialog(context, "Failed to load next page", it, { + loadNextPage(); + }); + //UIDialogs.showDataRetryDialog(layoutInflater, it.message, { loadNextPage() }); + }; + + _scrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + super.onScrollStateChanged(recyclerView, newState); + onScrollStateChanged(newState); + } + + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy); + + val visibleItemCount = _recyclerResults.childCount; + val firstVisibleItem = recyclerData.layoutManager.findFirstVisibleItemPosition(); + if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= recyclerData.results.size && firstVisibleItem > 0) { + //Logger.i(TAG, "loadNextPage(): firstVisibleItem=$firstVisibleItem visibleItemCount=$visibleItemCount visibleThreshold=$visibleThreshold _results.size=${_results.size}") + loadNextPage(); + } + } + }; + + _recyclerResults.addOnScrollListener(_scrollListener); + } + + fun onResume() { + //Reload the pager if the plugin was killed + val pager = recyclerData.pager; + if((pager is MultiPager<*> && pager.findPager { it is JSPager<*> && !it.isAvailable } != null) || + (pager is JSPager<*> && !pager.isAvailable)) { + Logger.w(TAG, "Detected pager of a dead plugin instance, reloading"); + reload(); + } + } + + open fun cleanup() { + detachParentPagerEvents(); + detachPagerEvents(); + + _recyclerResults.removeOnScrollListener(_scrollListener); + _nextPageHandler.cancel(); + + _recyclerResults.adapter = null; + _recyclerResults.layoutManager = null; + } + + protected open fun onScrollStateChanged(newState: Int) {} + + protected open fun setActiveTags(activeTags: List?) { + _activeTags = activeTags; + + if (activeTags != null && activeTags.isNotEmpty()) { + _tagsView.setTags(activeTags); + _tagsView.visibility = View.VISIBLE; + } else { + _tagsView.visibility = View.GONE; + } + } + protected open fun setSortByOptions(options: List?) { + _sortByOptions = options; + + if (options != null && options.isNotEmpty()) { + val allOptions = arrayListOf(); + allOptions.add("Default"); + allOptions.addAll(options); + + _spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, allOptions).also { + it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); + }; + + _containerSortBy.visibility = View.VISIBLE; + } else { + _containerSortBy.visibility = View.GONE; + } + } + protected abstract fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList): InsertedViewAdapterWithLoader; + protected abstract fun createLayoutManager(recyclerResults: RecyclerView, context: Context): LinearLayoutManager; + protected open fun onRestoreCachedData(cachedData: RecyclerData, LinearLayoutManager, TPager, TResult, TConverted, InsertedViewHolder>) {} + + protected fun setProgress(fin: Int, total: Int) { + val progress = (fin.toFloat() / total); + _progress_bar.progress = progress; + if(progress > 0 && progress < 1) + { + if(_progress_bar.height == 0) + _progress_bar.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 5); + } + else if(_progress_bar.height > 0) { + _progress_bar.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0); + } + } + + private fun processPagerExceptions(pager: IPager<*>) { + if(pager is MultiPager<*> && pager.allowFailure) { + val ex = pager.getResultExceptions(); + for(kv in ex) { + val jsVideoPager: JSPager<*>? = if(kv.key is MultiPager<*>) + (kv.key as MultiPager<*>).findPager { it is JSPager<*> } as JSPager<*>?; + else if(kv.key is JSPager<*>) + kv.key as JSPager<*>; + else null; + + context?.let { + fragment.lifecycleScope.launch(Dispatchers.Main) { + try { + if(jsVideoPager != null) + UIDialogs.toast(it, "Plugin ${jsVideoPager.getPluginConfig().name} failed:\n${kv.value.message}", false); + else + UIDialogs.toast(it, kv.value.message ?: "", false); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to show toast.", e) + } + } + } + } + } + } + + open fun filterResults(results: List): List { + return results as List; + } + + open fun reload() { + + } + + protected fun finishRefreshLayoutLoader() { + _swipeRefresh.isRefreshing = false; + } + + fun clearResults(){ + setPager(EmptyPager() as TPager); + } + + fun preloadCache(cache: ItemCache) { + _cache = cache + recyclerData.results.clear(); + val results = cache.cachePager.getResults(); + val resultsFiltered = filterResults(results); + recyclerData.results.addAll(resultsFiltered); + recyclerData.adapter.notifyDataSetChanged(); + //insertPagerResults(_cache!!.cachePager.getResults(), false); + } + fun setPager(pager: TPager, cache: ItemCache? = null) { + synchronized(_pager_lock) { + detachParentPagerEvents(); + detachPagerEvents(); + + val pagerToSet: TPager?; + if(pager is IRefreshPager<*>) { + recyclerData.parentPager = pager; + attachParentPagerEvents(); + pagerToSet = pager.getCurrentPager() as TPager; + } + else pagerToSet = pager; + + loadPagerInternal(pagerToSet, cache); + } + } + + private fun detachParentPagerEvents() { + val parentPager = recyclerData.parentPager; + if (parentPager != null && parentPager is IRefreshPager<*>) { + parentPager.onPagerError.remove(this); + parentPager.onPagerChanged.remove(this); + recyclerData.parentPager = null; + } + } + + private fun attachParentPagerEvents() { + val parentPager = recyclerData.parentPager as IRefreshPager<*>? ?: return; + parentPager.onPagerChanged.subscribe(this) { + fragment.lifecycleScope.launch(Dispatchers.Main) { + try { + loadPagerInternal(it as TPager); + } catch (e: Throwable) { + Logger.e(TAG, "Failed loadPagerInternal", e) + } + } + }; + parentPager.onPagerError.subscribe(this) { + Logger.e(TAG, "Search pager failed: ${it.message}", it); + when (it) { + is PluginException -> UIDialogs.toast("Plugin [${it.config.name}] failed due to:\n${it.message}") + is CancellationException -> { + //Hide cancelled toast + } + else -> UIDialogs.toast("Plugin failed due to:\n${it.message}") + }; + }; + } + + private fun loadPagerInternal(pager: TPager, cache: ItemCache? = null) { + _cache = cache; + + detachPagerEvents(); + recyclerData.pager = pager; + attachPagerEvents(); + + processPagerExceptions(pager); + + recyclerData.results.clear(); + recyclerData.resultsUnfiltered.clear(); + val toAdd = pager.getResults(); + val filteredResults = filterResults(toAdd); + recyclerData.results.addAll(filteredResults); + //insertPagerResults(toAdd, true); + recyclerData.resultsUnfiltered.addAll(toAdd); + recyclerData.adapter.notifyDataSetChanged(); + recyclerData.loadedFeedStyle = feedStyle; + } + + private fun detachPagerEvents() { + val p = recyclerData.pager; + if(p is IReplacerPager<*>) + p.onReplaced.remove(this); + } + + private fun attachPagerEvents() { + val p = recyclerData.pager; + if(p is IReplacerPager<*>) { + p.onReplaced.subscribe(this) { _, newItem -> + synchronized(_pager_lock) { + val filtered = filterResults(listOf(newItem as TResult)); + if(filtered.isEmpty()) + return@subscribe; + val newItemConverted = filtered[0]; + + val toReplaceIndex = recyclerData.results.indexOfFirst { it == newItemConverted }; + if(toReplaceIndex >= 0) { + recyclerData.results[toReplaceIndex] = newItemConverted; + recyclerData.adapter.notifyItemChanged(recyclerData.adapter.childToParentPosition(toReplaceIndex)); + } + } + } + } + } + + private fun loadNextPage() { + synchronized(_pager_lock) { + val pager: TPager = recyclerData.pager ?: return; + val hasMorePages = pager.hasMorePages(); + Logger.i(TAG, "loadNextPage() hasMorePages=$hasMorePages"); + + //loadCachedPage(); + if (pager.hasMorePages()) { + setLoading(true); + _nextPageHandler.run(pager); + } + } + } + + protected fun setLoading(loading: Boolean) { + Logger.v(TAG, "setLoading loading=${loading}"); + _loading = loading; + recyclerData.adapter.setLoading(loading); + } + + companion object { + private val TAG = "FeedView"; + } + + abstract class ItemCache(val cachePager: IPager) { + abstract fun isSame(item: TResult, toCompare: TResult): Boolean; + abstract fun compareOrder(item: TResult, toCompare: TResult): Int; + } + + data class RecyclerData ( + val adapter: TAdapter, + val layoutManager: TLayoutManager, + val results: ArrayList, + val resultsUnfiltered: ArrayList = ArrayList(), + var pager: TPager? = null, + var parentPager: TPager? = null, + var loadedFeedStyle: FeedStyle = FeedStyle.UNKNOWN, + var lastLoad: OffsetDateTime = OffsetDateTime.MIN, + var lastClients: List? = null + ) where TViewHolder : RecyclerView.ViewHolder, TPager : IPager, TAdapter : RecyclerView.Adapter, TLayoutManager : LayoutManager +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HistoryFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HistoryFragment.kt new file mode 100644 index 00000000..ac1eb702 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HistoryFragment.kt @@ -0,0 +1,92 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.widget.EditText +import android.widget.ImageButton +import androidx.core.widget.addTextChangedListener +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.* +import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.views.others.TagsView +import com.futo.platformplayer.views.adapters.HistoryListAdapter + +class HistoryFragment : MainFragment() { + override val isMainView : Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var _adapter: HistoryListAdapter? = null; + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = inflater.inflate(R.layout.fragment_history, container, false); + + val inputMethodManager = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager; + + val recyclerHistory = view.findViewById(R.id.recycler_history); + val clearSearch = view.findViewById(R.id.button_clear_search); + val editSearch = view.findViewById(R.id.edit_search); + var tagsView = view.findViewById(R.id.tags_text); + tagsView.setPairs(listOf( + Pair(getString(R.string.last_hour), 60L), + Pair(getString(R.string.last_24_hours), 24L * 60L), + Pair(getString(R.string.last_week), 7L * 24L * 60L), + Pair(getString(R.string.last_30_days), 30L * 24L * 60L), + Pair(getString(R.string.last_year), 365L * 30L * 24L * 60L), + Pair(getString(R.string.all_time), -1L))); + + val adapter = HistoryListAdapter(); + adapter.onClick.subscribe { v -> + val diff = v.video.duration - v.position; + val vid: Any = if (diff > 5) { v.video.withTimestamp(v.position) } else { v.video }; + navigate(vid).maximizeVideoDetail(); + editSearch.clearFocus(); + inputMethodManager.hideSoftInputFromWindow(editSearch.windowToken, 0); + }; + _adapter = adapter; + + recyclerHistory.adapter = adapter; + recyclerHistory.isSaveEnabled = false; + recyclerHistory.layoutManager = LinearLayoutManager(context); + + tagsView.onClick.subscribe { timeMinutesToErase -> + UIDialogs.showConfirmationDialog(requireContext(), getString(R.string.are_you_sure_delete_historical), { + StatePlaylists.instance.removeHistoryRange(timeMinutesToErase.second as Long); + UIDialogs.toast(view.context, timeMinutesToErase.first + " " + getString(R.string.removed)); + adapter.updateFilteredVideos(); + adapter.notifyDataSetChanged(); + }); + }; + + clearSearch.setOnClickListener { + editSearch.text.clear(); + clearSearch.visibility = View.GONE; + adapter.setQuery(""); + editSearch.clearFocus(); + inputMethodManager.hideSoftInputFromWindow(editSearch.windowToken, 0); + }; + + editSearch.addTextChangedListener { _ -> + val text = editSearch.text; + clearSearch.visibility = if (text.isEmpty()) { View.GONE } else { View.VISIBLE }; + adapter.setQuery(text.toString()); + }; + + return view; + } + + override fun onDestroyMainView() { + super.onDestroyMainView(); + _adapter?.cleanup(); + _adapter = null; + } + + companion object { + fun newInstance() = HistoryFragment().apply {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt new file mode 100644 index 00000000..25600b4e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt @@ -0,0 +1,165 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import com.futo.platformplayer.* +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.api.media.structures.EmptyPager +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.engine.exceptions.ScriptExecutionException +import com.futo.platformplayer.engine.exceptions.ScriptImplementationException +import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.AnnouncementType +import com.futo.platformplayer.states.StateAnnouncement +import com.futo.platformplayer.states.StateMeta +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.views.announcements.AnnouncementView +import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder +import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader +import com.futo.platformplayer.views.adapters.InsertedViewHolder +import java.time.OffsetDateTime +import java.util.UUID + +class HomeFragment : MainFragment() { + override val isMainView : Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var _view: HomeView? = null; + private var _cachedRecyclerData: FeedView.RecyclerData, LinearLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>? = null; + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + _view?.onShown(); + } + + override fun onResume() { + super.onResume() + _view?.onResume(); + } + + override fun onPause() { + super.onPause() + _view?.onPause(); + } + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = HomeView(this, inflater, _cachedRecyclerData); + _view = view; + return view; + } + + override fun onDestroyMainView() { + super.onDestroyMainView(); + + val view = _view; + if (view != null) { + _cachedRecyclerData = view.recyclerData; + view.cleanup(); + _view = null; + } + } + + fun setPreviewsEnabled(previewsEnabled: Boolean) { + _view?.setPreviewsEnabled(previewsEnabled); + } + + @SuppressLint("ViewConstructor") + class HomeView : ContentFeedView { + override val feedStyle: FeedStyle get() = Settings.instance.home.getHomeFeedStyle(); + + private var _announcementsView: AnnouncementView; + + private val _taskGetPager: TaskHandler>; + + constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData, LinearLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>? = null) : super(fragment, inflater, cachedRecyclerData) { + _announcementsView = AnnouncementView(context).apply { + headerView.addView(AnnouncementView(context)) + }; + + _taskGetPager = TaskHandler>({ fragment.lifecycleScope }, { + StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope) + }) + .success { loadedResult(it); } + .exception { + Logger.w(ChannelFragment.TAG, "Plugin failure.", it); + UIDialogs.showDialog(context, R.drawable.ic_error_pred, "Failed to get Home\nPlugin [${it.config.name}]", it.message, null, 0, + UIDialogs.Action("Ignore", {}), + UIDialogs.Action("Sources", { fragment.navigate() }, UIDialogs.ActionStyle.PRIMARY) + ); + } + .exception { + Logger.w(ChannelFragment.TAG, "Plugin failure.", it); + UIDialogs.showDialog(context, R.drawable.ic_error_pred, "Failed to get Home\nPlugin [${it.config.name}]", it.message, null, 0, + UIDialogs.Action("Ignore", {}), + UIDialogs.Action("Sources", { fragment.navigate() }, UIDialogs.ActionStyle.PRIMARY) + ); + } + .exception { + Logger.w(ChannelFragment.TAG, "Failed to load channel.", it); + UIDialogs.showGeneralRetryErrorDialog(context, "Failed to get Home", it, { + loadResults() + }); + }; + } + + fun onShown() { + val lastClients = recyclerData.lastClients; + val clients = StatePlatform.instance.getSortedEnabledClient().filter { if (it is JSClient) it.enableInHome else true }; + + val feedstyleChanged = recyclerData.loadedFeedStyle != feedStyle; + val clientsChanged = lastClients == null || lastClients.size != clients.size || !lastClients.containsAll(clients); + val outdated = recyclerData.lastLoad.getNowDiffSeconds() > 60; + Logger.i(TAG, "onShown (recyclerData.loadedFeedStyle=${recyclerData.loadedFeedStyle}, recyclerData.lastLoad=${recyclerData.lastLoad}, feedstyleChanged=$feedstyleChanged, clientsChanged=$clientsChanged, outdated=$outdated)") + + if(feedstyleChanged || outdated || clientsChanged) { + recyclerData.lastLoad = OffsetDateTime.now(); + recyclerData.loadedFeedStyle = feedStyle; + recyclerData.lastClients = clients; + loadResults(); + } else { + setLoading(false); + } + } + + override fun reload() { + loadResults(); + } + + override fun filterResults(contents: List): List { + return contents.filter { it !is IPlatformVideo || !StateMeta.instance.isVideoHidden(it.url) }; + } + + private fun loadResults() { + setLoading(true); + _taskGetPager.run(true); + } + private fun loadedResult(pager : IPager) { + if (pager is EmptyPager) { + StateAnnouncement.instance.registerAnnouncement(UUID.randomUUID().toString(), "No home available", "No home page is available, please check if you are connected to the internet and refresh.", AnnouncementType.SESSION); + } + + Logger.i(TAG, "Got new home pager ${pager}"); + finishRefreshLayoutLoader(); + setLoading(false); + setPager(pager); + } + } + + companion object { + val TAG = "HomeFragment"; + + fun newInstance() = HomeFragment().apply {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ImportPlaylistsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ImportPlaylistsFragment.kt new file mode 100644 index 00000000..8c503318 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ImportPlaylistsFragment.kt @@ -0,0 +1,203 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.annotation.SuppressLint +import android.graphics.drawable.Animatable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.* +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment +import com.futo.platformplayer.views.AnyAdapterView +import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.Playlist +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.views.adapters.viewholders.ImportPlaylistsViewHolder +import com.futo.platformplayer.views.adapters.viewholders.SelectablePlaylist + +class ImportPlaylistsFragment : MainFragment() { + override val isMainView : Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var _view: ImportPlaylistsView? = null; + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + _view?.onShown(parameter, isBack); + } + + override fun onHide() { + super.onHide(); + + val tb = this.topBar as ImportTopBarFragment?; + tb?.onImport?.remove(this); + } + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = ImportPlaylistsView(this, inflater); + _view = view; + return view; + } + + override fun onDestroyMainView() { + super.onDestroyMainView(); + _view?.cleanup(); + _view = null; + } + + @SuppressLint("ViewConstructor") + class ImportPlaylistsView : LinearLayout { + private val _fragment: ImportPlaylistsFragment; + + private var _spinner: ImageView; + private var _textSelectDeselectAll: TextView; + private var _textNothingToImport: TextView; + private var _textCounter: TextView; + private var _adapterView: AnyAdapterView; + private var _links: List = listOf(); + private val _items: ArrayList = arrayListOf(); + private var _currentLoadIndex = 0; + + private var _taskLoadPlaylist: TaskHandler; + + constructor(fragment: ImportPlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) { + _fragment = fragment; + inflater.inflate(R.layout.fragment_import, this); + + _textNothingToImport = findViewById(R.id.nothing_to_import); + _textSelectDeselectAll = findViewById(R.id.text_select_deselect_all); + _textCounter = findViewById(R.id.text_select_counter); + _spinner = findViewById(R.id.channel_loader); + + _adapterView = findViewById(R.id.recycler_import).asAny( _items) { + it.onSelectedChange.subscribe { c -> + updateSelected(); + }; + }; + + _textSelectDeselectAll.setOnClickListener { + val itemsSelected = _items.count { i -> i.selected }; + if (itemsSelected > 0) { + for (i in _items) { + i.selected = false; + } + } else { + for (i in _items) { + i.selected = true; + } + } + + _adapterView.adapter.notifyContentChanged(); + updateSelected(); + }; + + setLoading(false); + + _taskLoadPlaylist = TaskHandler({fragment.lifecycleScope}, { link -> StatePlatform.instance.getPlaylist(link).toPlaylist(); }) + .success { + if (it != null) { + _items.add(SelectablePlaylist(it)); + _adapterView.adapter.notifyItemInserted(_items.size - 1); + } + + loadNext(); + }.exceptionWithParameter { ex, para -> + //setLoading(false); + Logger.w(ChannelFragment.TAG, "Failed to load results.", ex); + UIDialogs.toast(context, "Failed to fetch\n${para}", false) + //UIDialogs.showDataRetryDialog(layoutInflater, { load(); }); + loadNext(); + }; + } + + fun cleanup() { + _taskLoadPlaylist.cancel(); + } + + fun onShown(parameter: Any ?, isBack: Boolean) { + updateSelected(); + + val itemsRemoved = _items.size; + if (itemsRemoved > 0) { + _items.clear(); + _adapterView.adapter.notifyItemRangeRemoved(0, itemsRemoved); + } + + _links = (parameter as Array).toList(); + _currentLoadIndex = 0; + if (_links.isNotEmpty()) { + load(); + _textNothingToImport.visibility = View.GONE; + } else { + setLoading(false); + _textNothingToImport.visibility = View.VISIBLE; + } + + val tb = _fragment.topBar as ImportTopBarFragment?; + tb?.let { + it.title = "Import Playlists"; + it.onImport.subscribe(this) { + val playlistsToImport = _items.filter { i -> i.selected }.toList(); + for (playlistToImport in playlistsToImport) { + StatePlaylists.instance.createOrUpdatePlaylist(playlistToImport.playlist); + } + + UIDialogs.toast("${playlistsToImport.size} playlists imported."); + _fragment.closeSegment(); + }; + } + } + + private fun load() { + setLoading(true); + _taskLoadPlaylist.run(_links[_currentLoadIndex]); + } + + private fun loadNext() { + _currentLoadIndex++; + if (_currentLoadIndex < _links.size) { + load(); + } else { + setLoading(false); + } + } + + private fun updateSelected() { + val itemsSelected = _items.count { i -> i.selected }; + if (itemsSelected > 0) { + _textSelectDeselectAll.text = context.getString(R.string.deselect_all); + _textCounter.text = "$itemsSelected out of ${_items.size} selected"; + (_fragment.topBar as ImportTopBarFragment?)?.setImportEnabled(true); + } else { + _textSelectDeselectAll.text = context.getString(R.string.select_all); + _textCounter.text = ""; + (_fragment.topBar as ImportTopBarFragment?)?.setImportEnabled(false); + } + } + + private fun setLoading(isLoading: Boolean) { + if(isLoading){ + (_spinner.drawable as Animatable?)?.start(); + _spinner.visibility = View.VISIBLE; + } + else { + _spinner.visibility = View.GONE; + (_spinner.drawable as Animatable?)?.stop(); + } + } + } + + companion object { + val TAG = "ImportSubscriptionsFragment"; + fun newInstance() = ImportPlaylistsFragment().apply {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ImportSubscriptionsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ImportSubscriptionsFragment.kt new file mode 100644 index 00000000..d813a0ac --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ImportSubscriptionsFragment.kt @@ -0,0 +1,201 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.annotation.SuppressLint +import android.graphics.drawable.Animatable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.* +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment +import com.futo.platformplayer.views.AnyAdapterView +import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny +import com.futo.platformplayer.views.adapters.viewholders.ImportSubscriptionViewHolder +import com.futo.platformplayer.views.adapters.viewholders.SelectableIPlatformChannel +import com.futo.platformplayer.states.StateSubscriptions +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StatePlatform + +class ImportSubscriptionsFragment : MainFragment() { + override val isMainView : Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var _view: ImportSubscriptionsView? = null; + + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShown(parameter, isBack); + _view?.onShown(parameter, isBack); + } + + override fun onHide() { + super.onHide(); + + val tb = this.topBar as ImportTopBarFragment?; + tb?.onImport?.remove(this); + } + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = ImportSubscriptionsView(this, inflater); + _view = view; + return view; + } + + override fun onDestroyMainView() { + super.onDestroyMainView(); + _view?.cleanup(); + _view = null; + } + + @SuppressLint("ViewConstructor") + class ImportSubscriptionsView : LinearLayout { + private val _fragment: ImportSubscriptionsFragment; + + private var _spinner: ImageView; + private var _textSelectDeselectAll: TextView; + private var _textNothingToImport: TextView; + private var _textCounter: TextView; + private var _adapterView: AnyAdapterView; + private var _links: List = listOf(); + private val _items: ArrayList = arrayListOf(); + private var _currentLoadIndex = 0; + + private var _taskLoadChannel: TaskHandler; + + constructor(fragment: ImportSubscriptionsFragment, inflater: LayoutInflater) : super(inflater.context) { + _fragment = fragment; + inflater.inflate(R.layout.fragment_import, this); + + _textNothingToImport = findViewById(R.id.nothing_to_import); + _textSelectDeselectAll = findViewById(R.id.text_select_deselect_all); + _textCounter = findViewById(R.id.text_select_counter); + _spinner = findViewById(R.id.channel_loader); + + _adapterView = findViewById(R.id.recycler_import).asAny( _items) { + it.onSelectedChange.subscribe { c -> + updateSelected(); + }; + }; + + _textSelectDeselectAll.setOnClickListener { + val itemsSelected = _items.count { i -> i.selected }; + if (itemsSelected > 0) { + for (i in _items) { + i.selected = false; + } + } else { + for (i in _items) { + i.selected = true; + } + } + + _adapterView.adapter.notifyContentChanged(); + updateSelected(); + }; + + setLoading(false); + + _taskLoadChannel = TaskHandler({_fragment.lifecycleScope}, { link -> + val channel: IPlatformChannel = StatePlatform.instance.getChannelLive(link, false); + return@TaskHandler channel; + }).success { + _items.add(SelectableIPlatformChannel(it)); + _adapterView.adapter.notifyItemInserted(_items.size - 1); + loadNext(); + }.exceptionWithParameter { ex, para -> + //setLoading(false); + Logger.w(ChannelFragment.TAG, "Failed to load results.", ex); + UIDialogs.toast(context, "Failed to fetch\n${para}", false) + //UIDialogs.showDataRetryDialog(layoutInflater, { load(); }); + loadNext(); + }; + } + + fun cleanup() { + _taskLoadChannel.cancel(); + } + + fun onShown(parameter: Any ?, isBack: Boolean) { + updateSelected(); + + val itemsRemoved = _items.size; + _items.clear(); + _adapterView?.adapter?.notifyItemRangeRemoved(0, itemsRemoved); + + _links = (parameter as List).filter { i -> !StateSubscriptions.instance.isSubscribed(i) }.toList(); + _currentLoadIndex = 0; + if (_links.isNotEmpty()) { + load(); + _textNothingToImport.visibility = View.GONE; + } else { + setLoading(false); + _textNothingToImport.visibility = View.VISIBLE; + } + + val tb = _fragment.topBar as ImportTopBarFragment?; + tb?.let { + it.title = "Import Subscriptions"; + it.onImport.subscribe(this) { + val subscriptionsToImport = _items.filter { i -> i.selected }.toList(); + for (subscriptionToImport in subscriptionsToImport) { + StateSubscriptions.instance.addSubscription(subscriptionToImport.channel); + } + + UIDialogs.toast("${subscriptionsToImport.size} subscriptions imported."); + _fragment.closeSegment(); + }; + } + } + + private fun load() { + setLoading(true); + _taskLoadChannel.run(_links[_currentLoadIndex]); + } + + private fun loadNext() { + _currentLoadIndex++; + if (_currentLoadIndex < _links.size) { + load(); + } else { + setLoading(false); + } + } + + private fun updateSelected() { + val itemsSelected = _items.count { i -> i.selected }; + if (itemsSelected > 0) { + _textSelectDeselectAll.text = context.getString(R.string.deselect_all); + _textCounter.text = "$itemsSelected out of ${_items.size} selected"; + (_fragment.topBar as ImportTopBarFragment?)?.setImportEnabled(true); + } else { + _textSelectDeselectAll.text = context.getString(R.string.select_all); + _textCounter.text = ""; + (_fragment.topBar as ImportTopBarFragment?)?.setImportEnabled(false); + } + } + + private fun setLoading(isLoading: Boolean) { + if(isLoading){ + (_spinner.drawable as Animatable?)?.start(); + _spinner.visibility = View.VISIBLE; + } + else { + _spinner.visibility = View.GONE; + (_spinner.drawable as Animatable?)?.stop(); + } + } + } + + companion object { + val TAG = "ImportSubscriptionsFragment"; + fun newInstance() = ImportSubscriptionsFragment().apply {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/MainFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/MainFragment.kt new file mode 100644 index 00000000..e9e1b0b6 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/MainFragment.kt @@ -0,0 +1,99 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.fragment.mainactivity.MainActivityFragment +import com.futo.platformplayer.fragment.mainactivity.topbar.TopFragment +import com.futo.platformplayer.listeners.OrientationManager + +abstract class MainFragment : MainActivityFragment() { + open val isMainView: Boolean = false; + open val isTab: Boolean = false; + open val isOverlay: Boolean = false; + open val isHistory: Boolean = true; + open val hasBottomBar: Boolean = true; + var topBar: TopFragment? = null; + + val onShownEvent = Event1(); + val onHideEvent = Event1(); + val onCloseEvent = Event1(); + + private val _fragmentLock = Object(); + private var _mainView: View? = null; + private var _lastOnShownParameters: Pair? = null; + + open fun onShown(parameter: Any?, isBack: Boolean) { + onShownEvent.emit(this); + + if (_mainView == null) { + synchronized(_fragmentLock) { + _lastOnShownParameters = Pair(parameter, isBack); + } + } else { + synchronized(_fragmentLock) { + _lastOnShownParameters = null; + } + + onShownWithView(parameter, isBack); + } + } + + open fun onShownWithView(parameter: Any?, isBack: Boolean) { + + } + + open fun onOrientationChanged(orientation: OrientationManager.Orientation) { + + } + + open fun onBackPressed(): Boolean { + return false; + } + + open fun onHide() { + onHideEvent.emit(this); + } + + final override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = onCreateMainView(inflater, container, savedInstanceState); + _mainView = view; + + val lastOnShownParameters = synchronized(_fragmentLock) { + val value = _lastOnShownParameters; + _lastOnShownParameters = null; + return@synchronized value; + }; + + if (lastOnShownParameters != null) + onShownWithView(lastOnShownParameters.first, lastOnShownParameters.second); + + return view; + } + + final override fun onDestroyView() { + super.onDestroyView(); + onDestroyMainView(); + _mainView = null; + } + + abstract fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View; + open fun onDestroyMainView() {} + + override fun onDestroy() { + super.onDestroy(); + onShownEvent.clear(); + onHideEvent.clear(); + onCloseEvent.clear(); + } + + fun close(withNavigate: Boolean = false) { + isValidMainActivity(); + onCloseEvent.emit(this); + if (withNavigate) + (activity as MainActivity).closeSegment(this); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt new file mode 100644 index 00000000..5d623461 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt @@ -0,0 +1,365 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.annotation.SuppressLint +import android.graphics.drawable.Animatable +import android.os.Bundle +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.app.ShareCompat +import androidx.core.view.setPadding +import androidx.lifecycle.lifecycleScope +import com.futo.platformplayer.* +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.downloads.VideoDownload +import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.Playlist +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateDownloads +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class PlaylistFragment : MainFragment() { + override val isMainView : Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var _view: PlaylistView? = null; + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + _view?.onShown(parameter, isBack); + } + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = PlaylistView(this, inflater); + _view = view; + return view; + } + + override fun onDestroyMainView() { + super.onDestroyMainView(); + _view = null; + } + + override fun onResume() { + super.onResume() + _view?.onResume(); + } + + override fun onPause() { + super.onPause() + _view?.onPause(); + } + + @SuppressLint("ViewConstructor") + class PlaylistView : VideoListEditorView { + private val _fragment: PlaylistFragment; + + private var _playlist: Playlist? = null; + private var _remotePlaylist: IPlatformPlaylistDetails? = null; + private var _editPlaylistNameInput: SlideUpMenuTextInput? = null; + private var _editPlaylistOverlay: SlideUpMenuOverlay? = null; + private var _url: String? = null; + + private val _taskLoadPlaylist: TaskHandler; + + constructor(fragment: PlaylistFragment, inflater: LayoutInflater) : super(inflater) { + _fragment = fragment; + + val nameInput = SlideUpMenuTextInput(context, "Name"); + val editPlaylistOverlay = SlideUpMenuOverlay(context, overlayContainer, "Edit playlist", "Ok", false, nameInput); + + _buttonDownload.visibility = View.VISIBLE; + editPlaylistOverlay.onOK.subscribe { + val text = nameInput.text; + if (text.isBlank()) { + return@subscribe; + } + + setName(text); + _playlist?.let { + it.name = text; + StatePlaylists.instance.createOrUpdatePlaylist(it); + } + + editPlaylistOverlay.hide(); + nameInput.deactivate(); + nameInput.clear(); + } + + editPlaylistOverlay.onCancel.subscribe { + nameInput.deactivate(); + nameInput.clear(); + }; + + _editPlaylistOverlay = editPlaylistOverlay; + _editPlaylistNameInput = nameInput; + + setOnShare { + val playlist = _playlist ?: return@setOnShare; + val reconstruction = StatePlaylists.instance.playlistStore.getReconstructionString(playlist); + + UISlideOverlays.showOverlay(overlayContainer, "Playlist [${playlist.name}]", null, {}, + SlideUpMenuItem(context, R.drawable.ic_list, "Share as Text", "Share as a list of video urls", 1, { + _fragment.startActivity(ShareCompat.IntentBuilder(context) + .setType("text/plain") + .setText(reconstruction) + .intent); + }), + SlideUpMenuItem(context, R.drawable.ic_move_up, "Share as Import", "Share as a import file for Grayjay", 2, { + val shareUri = StatePlaylists.instance.createPlaylistShareJsonUri(context, playlist); + _fragment.startActivity(ShareCompat.IntentBuilder(context) + .setType("application/json") + .setStream(shareUri) + .intent); + }) + ); + }; + + _taskLoadPlaylist = TaskHandler( + StateApp.instance.scopeGetter, + { + return@TaskHandler StatePlatform.instance.getPlaylist(it); + }) + .success { + setLoading(false); + _remotePlaylist = it; + setName(it.name); + setVideos(it.contents.getResults(), false); + setVideoCount(it.videoCount); + //TODO: Implement support for pagination + } + .exception { + Logger.w(TAG, "Failed to load playlist.", it); + val c = context ?: return@exception; + UIDialogs.showGeneralRetryErrorDialog(c, "Failed to load playlist", it, ::fetchPlaylist); + }; + } + + fun onShown(parameter: Any ?, isBack: Boolean) { + _taskLoadPlaylist.cancel(); + + if (parameter is Playlist?) { + _playlist = parameter; + _remotePlaylist = null; + _url = null; + + if(parameter != null) { + setName(parameter.name); + setVideos(parameter.videos, true); + setVideoCount(parameter.videos.size); + setButtonDownloadVisible(true); + setButtonEditVisible(true); + } else { + setName(null); + setVideos(null, false); + setVideoCount(-1); + setButtonDownloadVisible(false); + setButtonEditVisible(false); + } + + //TODO: Do I have to remove the showConvertPlaylistButton(); button here? + } else if (parameter is IPlatformPlaylist) { + _playlist = null; + _remotePlaylist = null; + _url = parameter.url; + + setVideoCount(parameter.videoCount); + setName(parameter.name); + setVideos(null, false); + setButtonDownloadVisible(false); + setButtonEditVisible(false); + + fetchPlaylist(); + showConvertPlaylistButton(); + } else if (parameter is String) { + _playlist = null; + _remotePlaylist = null; + _url = parameter; + + setName(null); + setVideos(null, false); + setVideoCount(-1); + setButtonDownloadVisible(false); + setButtonEditVisible(false); + + fetchPlaylist(); + showConvertPlaylistButton(); + } + + updateDownloadState(); + } + + fun onResume() { + StateDownloads.instance.onDownloadsChanged.subscribe(this) { + _fragment.lifecycleScope.launch(Dispatchers.Main) { + try { + updateDownloadState(); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to update download state onDownloadedChanged.") + } + } + }; + StateDownloads.instance.onDownloadedChanged.subscribe(this) { + _fragment.lifecycleScope.launch(Dispatchers.Main) { + try { + updateDownloadState(); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to update download state onDownloadedChanged.") + } + } + }; + } + + fun onPause() { + StateDownloads.instance.onDownloadsChanged.remove(this); + StateDownloads.instance.onDownloadedChanged.remove(this); + } + + private fun showConvertPlaylistButton() { + _fragment.topBar?.assume()?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) { + val remotePlaylist = _remotePlaylist; + if (remotePlaylist == null) { + UIDialogs.toast("Please wait for playlist to finish loading"); + return@Pair; + } + + setLoading(true); + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + try { + StatePlaylists.instance.playlistStore.save(remotePlaylist.toPlaylist()); + + withContext(Dispatchers.Main) { + setLoading(false); + UIDialogs.toast("Playlist copied as local playlist"); + } + } catch (e: Throwable) { + withContext(Dispatchers.Main) { + setLoading(false); + } + + throw e; + } + } + })); + } + + private fun fetchPlaylist() { + Logger.i(TAG, "fetchPlaylist") + + val url = _url; + if (!url.isNullOrBlank()) { + setLoading(true); + _taskLoadPlaylist.run(url); + } + } + + private fun updateDownloadState() { + val playlist = _playlist ?: return; + val isDownloading = StateDownloads.instance.getDownloading().any { it.groupType == VideoDownload.GROUP_PLAYLIST && it.groupID == playlist.id }; + val isDownloaded = StateDownloads.instance.isPlaylistCached(playlist.id); + + val dp10 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10f, resources.displayMetrics); + + if(isDownloaded && !isDownloading) + _buttonDownload.setBackgroundResource(R.drawable.background_button_round_green); + else + _buttonDownload.setBackgroundResource(R.drawable.background_button_round); + + if(isDownloading) { + _buttonDownload.setImageResource(R.drawable.ic_loader_animated); + _buttonDownload.drawable.assume { it.start() }; + _buttonDownload.setOnClickListener { + StateDownloads.instance.deleteCachedPlaylist(playlist.id); + } + } + else if(isDownloaded) { + _buttonDownload.setImageResource(R.drawable.ic_download_off); + _buttonDownload.setOnClickListener { + StateDownloads.instance.deleteCachedPlaylist(playlist.id); + } + } + else { + _buttonDownload.setImageResource(R.drawable.ic_download); + _buttonDownload.setOnClickListener { + UISlideOverlays.showDownloadPlaylistOverlay(playlist, overlayContainer); + } + } + _buttonDownload.setPadding(dp10.toInt()); + } + + override fun canEdit(): Boolean { return _playlist != null; } + + override fun onEditClick() { + _editPlaylistNameInput?.activate(); + _editPlaylistOverlay?.show(); + } + + override fun onPlayAllClick() { + val playlist = _playlist; + val remotePlaylist = _remotePlaylist; + if (playlist != null) { + StatePlayer.instance.setPlaylist(playlist, focus = true); + } else if (remotePlaylist != null) { + StatePlayer.instance.setPlaylist(remotePlaylist, focus = true, shuffle = false); + } + } + + override fun onShuffleClick() { + val playlist = _playlist; + val remotePlaylist = _remotePlaylist; + if (playlist != null) { + StatePlayer.instance.setPlaylist(playlist, focus = true, shuffle = true); + } else if (remotePlaylist != null) { + StatePlayer.instance.setPlaylist(remotePlaylist, focus = true, shuffle = true); + } + } + + override fun onVideoOrderChanged(videos: List) { + val playlist = _playlist ?: return; + playlist.videos = ArrayList(videos.map { it as SerializedPlatformVideo }); + StatePlaylists.instance.createOrUpdatePlaylist(playlist); + } + override fun onVideoRemoved(video: IPlatformVideo) { + val playlist = _playlist ?: return; + playlist.videos = ArrayList(playlist.videos.filter { it != video }); + StatePlaylists.instance.createOrUpdatePlaylist(playlist); + } + override fun onVideoClicked(video: IPlatformVideo) { + val playlist = _playlist; + val remotePlaylist = _remotePlaylist; + if (playlist != null) { + val index = playlist.videos.indexOf(video); + if (index == -1) + return; + + StatePlayer.instance.setPlaylist(playlist, index, true); + } else if (remotePlaylist != null) { + val index = remotePlaylist.contents.getResults().indexOf(video); + if (index == -1) + return; + + StatePlayer.instance.setPlaylist(remotePlaylist, index, true); + } + } + } + + companion object { + private const val TAG = "PlaylistFragment"; + fun newInstance() = PlaylistFragment().apply {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistSearchResultsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistSearchResultsFragment.kt new file mode 100644 index 00000000..58020c07 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistSearchResultsFragment.kt @@ -0,0 +1,120 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.lifecycleScope +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.Settings +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment +import com.futo.platformplayer.views.FeedStyle + +class PlaylistSearchResultsFragment : MainFragment() { + override val isMainView : Boolean = true; + override val isTab: Boolean = false; + override val hasBottomBar: Boolean get() = true; + + private var _view: PlaylistSearchResultsView? = null; + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + _view?.onShown(parameter, isBack); + } + + override fun onResume() { + super.onResume() + _view?.onResume(); + } + + override fun onPause() { + super.onPause() + _view?.onPause(); + } + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = PlaylistSearchResultsView(this, inflater); + _view = view; + return view; + } + + override fun onDestroyMainView() { + super.onDestroyMainView(); + _view?.cleanup(); + _view = null; + } + + @SuppressLint("ViewConstructor") + class PlaylistSearchResultsView : ContentFeedView { + override val feedStyle: FeedStyle get() = Settings.instance.search.getSearchFeedStyle(); + + private var _query: String? = null; + + private val _taskSearch: TaskHandler>; + constructor(fragment: PlaylistSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) { + _taskSearch = TaskHandler>({fragment.lifecycleScope}, { query -> StatePlatform.instance.searchPlaylist(query) }) + .success { loadedResult(it); } + .exception { + Logger.w(ChannelFragment.TAG, "Failed to load results.", it); + UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() }); + } + } + + override fun cleanup() { + super.cleanup(); + _taskSearch.cancel(); + } + + fun onShown(parameter: Any?, isBack: Boolean) { + if(parameter is String) { + if(!isBack) { + setQuery(parameter); + + fragment.topBar?.apply { + if (this is SearchTopBarFragment) { + setText(parameter); + onSearch.subscribe(this) { + setQuery(it); + }; + } + } + } + } + } + + override fun reload() { + loadResults(); + } + + private fun setQuery(query: String) { + clearResults(); + _query = query; + loadResults(); + } + + private fun loadResults() { + val query = _query; + if (query.isNullOrBlank()) { + return; + } + + setLoading(true); + _taskSearch.run(query); + } + private fun loadedResult(pager: IPager) { + finishRefreshLayoutLoader(); + setLoading(false); + setPager(pager); + } + } + + companion object { + fun newInstance() = PlaylistSearchResultsFragment().apply {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt new file mode 100644 index 00000000..622e98fe --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt @@ -0,0 +1,180 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.annotation.SuppressLint +import android.os.Bundle +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.assume +import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment +import com.futo.platformplayer.models.Playlist +import com.futo.platformplayer.models.SearchType +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.views.adapters.* +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput +import com.google.android.material.appbar.AppBarLayout +import kotlin.collections.ArrayList + + +class PlaylistsFragment : MainFragment() { + override val isMainView : Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var _view: PlaylistsView? = null; + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = PlaylistsView(this, inflater); + _view = view; + return view; + } + + override fun onDestroyMainView() { + super.onDestroyMainView(); + _view?.cleanup(); + _view = null; + } + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + _view?.onShown(parameter, isBack); + } + + @SuppressLint("ViewConstructor") + class PlaylistsView : LinearLayout { + private val _fragment: PlaylistsFragment; + + var watchLater: ArrayList = arrayListOf(); + var playlists: ArrayList = arrayListOf(); + private var _appBar: AppBarLayout; + private var _adapterWatchLater: VideoListHorizontalAdapter; + private var _adapterPlaylist: PlaylistsAdapter; + private var _layoutWatchlist: ConstraintLayout; + + constructor(fragment: PlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) { + _fragment = fragment; + inflater.inflate(R.layout.fragment_playlists, this); + + watchLater = ArrayList(); + playlists = ArrayList(); + + val recyclerWatchLater = findViewById(R.id.recycler_watch_later); + + _adapterWatchLater = VideoListHorizontalAdapter(watchLater); + recyclerWatchLater.adapter = _adapterWatchLater; + recyclerWatchLater.layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false); + + _adapterWatchLater.onClick.subscribe { v -> + val index = watchLater.indexOf(v); + if (index == -1) { + return@subscribe; + } + + StatePlayer.instance.setQueueWithPosition(watchLater, StatePlayer.TYPE_WATCHLATER, index, true); + }; + + val recyclerPlaylists = findViewById(R.id.recycler_playlists); + _adapterPlaylist = PlaylistsAdapter(playlists, inflater, context.getString(R.string.confirm_delete_playlist)); + recyclerPlaylists.adapter = _adapterPlaylist; + recyclerPlaylists.layoutManager = LinearLayoutManager(context); + + val nameInput = SlideUpMenuTextInput(context, "Name"); + val addPlaylistOverlay = SlideUpMenuOverlay(context, findViewById(R.id.overlay_create_playlist), "Create new playlist", "Ok", false, nameInput); + + _adapterPlaylist.onClick.subscribe { p -> _fragment.navigate(p); }; + _adapterPlaylist.onPlay.subscribe { p -> + StatePlayer.instance.setPlaylist(p, 0, true); + }; + + addPlaylistOverlay.onOK.subscribe { + val text = nameInput.text; + if (text.isBlank()) { + return@subscribe; + } + + val playlist = Playlist(text, arrayListOf()); + playlists.add(0, playlist); + StatePlaylists.instance.createOrUpdatePlaylist(playlist); + + _adapterPlaylist.notifyItemInserted(0); + addPlaylistOverlay.hide(); + nameInput.deactivate(); + nameInput.clear(); + }; + + addPlaylistOverlay.onCancel.subscribe { + nameInput.deactivate(); + nameInput.clear(); + }; + + val buttonCreatePlaylist = findViewById(R.id.button_create_playlist); + buttonCreatePlaylist.setOnClickListener { + addPlaylistOverlay.show(); + nameInput.activate(); + }; + + _appBar = findViewById(R.id.app_bar); + _layoutWatchlist = findViewById(R.id.layout_watchlist); + + findViewById(R.id.text_view_all).setOnClickListener { _fragment.navigate("Watch Later"); }; + StatePlaylists.instance.onWatchLaterChanged.subscribe(this) { + updateWatchLater(); + }; + } + + fun cleanup() { + StatePlaylists.instance.onWatchLaterChanged.remove(this); + } + + fun onShown(parameter: Any?, isBack: Boolean) { + playlists.clear() + playlists.addAll(StatePlaylists.instance.getPlaylists().sortedByDescending { maxOf(it.datePlayed, it.dateUpdate, it.dateCreation) }); + _adapterPlaylist.notifyDataSetChanged(); + + updateWatchLater(); + } + + private fun updateWatchLater() { + val watchList = StatePlaylists.instance.getWatchLater(); + if (watchList.isNotEmpty()) { + _layoutWatchlist.visibility = View.VISIBLE; + + _appBar.let { appBar -> + val layoutParams: CoordinatorLayout.LayoutParams = appBar.layoutParams as CoordinatorLayout.LayoutParams; + layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 230.0f, resources.displayMetrics).toInt(); + appBar.layoutParams = layoutParams; + } + } else { + _layoutWatchlist.visibility = View.GONE; + + _appBar.let { appBar -> + val layoutParams: CoordinatorLayout.LayoutParams = appBar.layoutParams as CoordinatorLayout.LayoutParams; + layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 25.0f, resources.displayMetrics).toInt(); + appBar.layoutParams = layoutParams; + }; + } + + watchLater.clear(); + watchLater.addAll(StatePlaylists.instance.getWatchLater()); + _adapterWatchLater.notifyDataSetChanged(); + } + } + + companion object { + fun newInstance() = PlaylistsFragment().apply {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt new file mode 100644 index 00000000..6441c5fa --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt @@ -0,0 +1,710 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Animatable +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewPropertyAnimator +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.children +import androidx.lifecycle.lifecycleScope +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.models.post.IPlatformPost +import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.dp +import com.futo.platformplayer.fixHtmlWhitespace +import com.futo.platformplayer.images.GlideHelper.Companion.crossfade +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.polycentric.PolycentricCache +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.toHumanNumber +import com.futo.platformplayer.views.comments.AddCommentView +import com.futo.platformplayer.views.segments.CommentsList +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.platform.PlatformIndicator +import com.futo.platformplayer.views.subscriptions.SubscribeButton +import com.futo.platformplayer.views.others.Toggle +import com.futo.platformplayer.views.adapters.PreviewPostView +import com.futo.platformplayer.views.overlays.RepliesOverlay +import com.futo.platformplayer.views.pills.PillRatingLikesDislikes +import com.futo.polycentric.core.ApiMethods +import com.futo.polycentric.core.ContentType +import com.futo.polycentric.core.Models +import com.futo.polycentric.core.Opinion +import com.google.android.flexbox.FlexboxLayout +import com.google.android.material.imageview.ShapeableImageView +import com.google.android.material.shape.CornerFamily +import com.google.android.material.shape.ShapeAppearanceModel +import com.google.protobuf.ByteString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import userpackage.Protocol +import java.lang.Integer.min + +class PostDetailFragment : MainFragment { + override val isMainView: Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var _viewDetail: PostDetailView? = null; + + constructor() : super() { } + + override fun onBackPressed(): Boolean { + return false; + } + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = PostDetailView(inflater.context).applyFragment(this); + _viewDetail = view; + return view; + } + + override fun onDestroyMainView() { + super.onDestroyMainView(); + _viewDetail?.onDestroy(); + _viewDetail = null; + } + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + + if (parameter is IPlatformPostDetails) { + _viewDetail?.clear(); + _viewDetail?.setPostDetails(parameter); + } else if (parameter is IPlatformPost) { + _viewDetail?.setPostOverview(parameter); + } else if(parameter is String) { + _viewDetail?.setPostUrl(parameter); + } + } + + private class PostDetailView : ConstraintLayout { + private lateinit var _fragment: PostDetailFragment; + private var _url: String? = null; + private var _isLoading = false; + private var _post: IPlatformPostDetails? = null; + private var _postOverview: IPlatformPost? = null; + private var _polycentricProfile: PolycentricCache.CachedPolycentricProfile? = null; + private var _version = 0; + private var _isRepliesVisible: Boolean = false; + private var _repliesAnimator: ViewPropertyAnimator? = null; + + private val _creatorThumbnail: CreatorThumbnail; + private val _buttonSubscribe: SubscribeButton; + private val _channelName: TextView; + private val _channelMeta: TextView; + private val _textTitle: TextView; + private val _textMeta: TextView; + private val _textContent: TextView; + private val _platformIndicator: PlatformIndicator; + private val _buttonShare: ImageButton; + + private val _buttonSupport: LinearLayout; + private val _buttonStore: LinearLayout; + private val _layoutMonetization: LinearLayout; + + private val _layoutRating: LinearLayout; + private val _imageLikeIcon: ImageView; + private val _textLikes: TextView; + private val _imageDislikeIcon: ImageView; + private val _textDislikes: TextView; + + private val _textComments: TextView; + private val _textCommentType: TextView; + private val _addCommentView: AddCommentView; + private val _toggleCommentType: Toggle; + + private val _rating: PillRatingLikesDislikes; + + private val _layoutLoadingOverlay: FrameLayout; + private val _imageLoader: ImageView; + + private val _imageActive: ImageView; + private val _layoutThumbnails: FlexboxLayout; + + private val _repliesOverlay: RepliesOverlay; + + private val _commentsList: CommentsList; + + private val _taskLoadPost = if(!isInEditMode) TaskHandler( + StateApp.instance.scopeGetter, + { + val result = StatePlatform.instance.getContentDetails(it).await(); + if(result !is IPlatformPostDetails) + throw IllegalStateException("Expected media content, found ${result.contentType}"); + return@TaskHandler result; + }) + .success { setPostDetails(it) } + .exception { + Logger.w(ChannelFragment.TAG, "Failed to load post.", it); + UIDialogs.showGeneralRetryErrorDialog(context, "Failed to load post", it, ::fetchPost); + } else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope }; + + private val _taskLoadPolycentricProfile = TaskHandler(StateApp.instance.scopeGetter, { PolycentricCache.instance.getProfileAsync(it) }) + .success { it -> setPolycentricProfile(it, animate = true) } + .exception { + Logger.w(TAG, "Failed to load claims.", it); + }; + + constructor(context: Context) : super(context) { + inflate(context, R.layout.fragview_post_detail, this); + + val root = findViewById(R.id.root); + + _creatorThumbnail = findViewById(R.id.creator_thumbnail); + _buttonSubscribe = findViewById(R.id.button_subscribe); + _channelName = findViewById(R.id.text_channel_name); + _channelMeta = findViewById(R.id.text_channel_meta); + _textTitle = findViewById(R.id.text_title); + _textMeta = findViewById(R.id.text_meta); + _textContent = findViewById(R.id.text_content); + _platformIndicator = findViewById(R.id.platform_indicator); + _buttonShare = findViewById(R.id.button_share); + + _buttonSupport = findViewById(R.id.button_support); + _buttonStore = findViewById(R.id.button_store); + _layoutMonetization = findViewById(R.id.layout_monetization); + + _layoutRating = findViewById(R.id.layout_rating); + _imageLikeIcon = findViewById(R.id.image_like_icon); + _textLikes = findViewById(R.id.text_likes); + _imageDislikeIcon = findViewById(R.id.image_dislike_icon); + _textDislikes = findViewById(R.id.text_dislikes); + + _commentsList = findViewById(R.id.comments_list); + _textCommentType = findViewById(R.id.text_comment_type); + _toggleCommentType = findViewById(R.id.toggle_comment_type); + _textComments = findViewById(R.id.text_comments); + _addCommentView = findViewById(R.id.add_comment_view); + + _rating = findViewById(R.id.rating); + + _layoutLoadingOverlay = findViewById(R.id.layout_loading_overlay); + _imageLoader = findViewById(R.id.image_loader); + + _imageActive = findViewById(R.id.image_active); + _layoutThumbnails = findViewById(R.id.layout_thumbnails); + + _repliesOverlay = findViewById(R.id.replies_overlay); + + val layoutTop: LinearLayout = findViewById(R.id.layout_top); + root.removeView(layoutTop); + _commentsList.setPrependedView(layoutTop); + + _commentsList.onCommentsLoaded.subscribe { count -> + updateCommentType(false); + }; + + _commentsList.onClick.subscribe { c -> + val replyCount = c.replyCount ?: 0; + var metadata = ""; + if (replyCount > 0) { + metadata += "$replyCount replies"; + } + + if (c is PolycentricPlatformComment) { + var parentComment: PolycentricPlatformComment = c; + _repliesOverlay.load(_toggleCommentType.value, metadata, c.contextUrl, c.reference, + { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) }, + { + val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1); + _commentsList.replaceComment(parentComment, newComment); + parentComment = newComment; + }); + } else { + _repliesOverlay.load(_toggleCommentType.value, metadata, null, null, { StatePlatform.instance.getSubComments(c) }); + } + + setRepliesOverlayVisible(isVisible = true, animate = true); + }; + + + _toggleCommentType.onValueChanged.subscribe { + updateCommentType(true); + }; + + _textCommentType.setOnClickListener { + _toggleCommentType.setValue(!_toggleCommentType.value, true); + updateCommentType(true); + }; + + _layoutMonetization.visibility = View.GONE; + + _buttonSupport.setOnClickListener { + val author = _post?.author ?: _postOverview?.author; + author?.let { _fragment.navigate(it).selectTab(2); }; + }; + + _buttonStore.setOnClickListener { + _polycentricProfile?.profile?.systemState?.store?.let { + try { + val uri = Uri.parse(it); + val intent = Intent(Intent.ACTION_VIEW); + intent.data = uri; + context.startActivity(intent); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to open URI: '${it}'.", e); + } + } + }; + + _addCommentView.onCommentAdded.subscribe { + _commentsList.addComment(it); + }; + + _repliesOverlay.onClose.subscribe { setRepliesOverlayVisible(isVisible = false, animate = true); }; + + _buttonShare.setOnClickListener { share() }; + + _creatorThumbnail.onClick.subscribe { openChannel() }; + _channelName.setOnClickListener { openChannel() }; + _channelMeta.setOnClickListener { openChannel() }; + } + + private fun openChannel() { + val author = _post?.author ?: _postOverview?.author ?: return; + _fragment.navigate(author); + } + + private fun share() { + try { + Logger.i(PreviewPostView.TAG, "sharePost") + + val url = _post?.shareUrl ?: _postOverview?.shareUrl ?: _url; + _fragment.startActivity(Intent.createChooser(Intent().apply { + action = Intent.ACTION_SEND; + putExtra(Intent.EXTRA_TEXT, url); + type = "text/plain"; //TODO: Determine alt types? + }, null)); + } catch (e: Throwable) { + //Ignored + Logger.e(PreviewPostView.TAG, "Failed to share.", e); + } + } + + private fun updatePolycentricRating() { + _rating.visibility = View.GONE; + + val value = _post?.id?.value ?: _postOverview?.id?.value ?: return; + val ref = Models.referenceFromBuffer(value.toByteArray()); + val version = _version; + + _rating.onLikeDislikeUpdated.remove(this); + _fragment.lifecycleScope.launch(Dispatchers.IO) { + if (version != _version) { + return@launch; + } + + try { + val queryReferencesResponse = ApiMethods.getQueryReferences(PolycentricCache.SERVER, ref, null,null, + arrayListOf( + Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType( + ContentType.OPINION.value).setValue( + ByteString.copyFrom(Opinion.like.data)).build(), + Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType( + ContentType.OPINION.value).setValue( + ByteString.copyFrom(Opinion.dislike.data)).build() + ) + ); + + if (version != _version) { + return@launch; + } + + val likes = queryReferencesResponse.countsList[0]; + val dislikes = queryReferencesResponse.countsList[1]; + val hasLiked = StatePolycentric.instance.hasLiked(ref); + val hasDisliked = StatePolycentric.instance.hasDisliked(ref); + + withContext(Dispatchers.Main) { + if (version != _version) { + return@withContext; + } + + _rating.visibility = VISIBLE; + _rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked); + _rating.onLikeDislikeUpdated.subscribe(this) { processHandle, newHasLiked, newHasDisliked -> + if (newHasLiked) { + processHandle.opinion(ref, Opinion.like); + } else if (newHasDisliked) { + processHandle.opinion(ref, Opinion.dislike); + } else { + processHandle.opinion(ref, Opinion.neutral); + } + + StateApp.instance.scopeGetter().launch(Dispatchers.IO) { + try { + processHandle.fullyBackfillServers(); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to backfill servers", e) + } + } + + StatePolycentric.instance.updateLikeMap(ref, newHasLiked, newHasDisliked) + }; + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e); + _rating.visibility = View.GONE; + } + } + } + + private fun setPlatformRating(rating: IRating?) { + if (rating == null) { + _layoutRating.visibility = View.GONE; + return; + } + + _layoutRating.visibility = View.VISIBLE; + + when (rating) { + is RatingLikeDislikes -> { + _textLikes.visibility = View.VISIBLE; + _imageLikeIcon.visibility = View.VISIBLE; + _textLikes.text = rating.likes.toHumanNumber(); + + _imageDislikeIcon.visibility = View.VISIBLE; + _textDislikes.visibility = View.VISIBLE; + _textDislikes.text = rating.dislikes.toHumanNumber(); + } + is RatingLikes -> { + _textLikes.visibility = View.VISIBLE; + _imageLikeIcon.visibility = View.VISIBLE; + _textLikes.text = rating.likes.toHumanNumber(); + + _imageDislikeIcon.visibility = View.GONE; + _textDislikes.visibility = View.GONE; + } + else -> { + _textLikes.visibility = View.GONE; + _imageLikeIcon.visibility = View.GONE; + _imageDislikeIcon.visibility = View.GONE; + _textDislikes.visibility = View.GONE; + } + } + } + + fun applyFragment(frag: PostDetailFragment): PostDetailView { + _fragment = frag; + return this; + } + + fun clear() { + _commentsList.cancel(); + _taskLoadPost.cancel(); + _taskLoadPolycentricProfile.cancel(); + _version++; + + _toggleCommentType.setValue(false, false); + _url = null; + _post = null; + _postOverview = null; + _creatorThumbnail.clear(); + //_buttonSubscribe.setSubscribeChannel(null); TODO: clear button + _channelName.text = ""; + setChannelMeta(null); + _textTitle.text = ""; + _textMeta.text = ""; + _textContent.text = ""; + setPlatformRating(null); + _polycentricProfile = null; + _rating.visibility = View.GONE; + updatePolycentricRating(); + setRepliesOverlayVisible(isVisible = false, animate = false); + setImages(null, null); + + _addCommentView.setContext(null, null); + _platformIndicator.clearPlatform(); + } + + fun setPostDetails(value: IPlatformPostDetails) { + _url = value.url; + _post = value; + + _creatorThumbnail.setThumbnail(value.author.thumbnail, false); + _buttonSubscribe.setSubscribeChannel(value.author.url); + _channelName.text = value.author.name; + setChannelMeta(value); + _textTitle.text = value.name; + _textMeta.text = value.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: "" //TODO: Include view count? + _textContent.text = value.content.fixHtmlWhitespace(); + _platformIndicator.setPlatformFromClientID(value.id.pluginId); + setPlatformRating(value.rating); + setImages(value.thumbnails.filterNotNull(), value.images); + + //Fetch only when not already called in setPostOverview + if (_postOverview == null) { + fetchPolycentricProfile(); + updatePolycentricRating(); + + val ref = value.id.value?.let { Models.referenceFromBuffer(it.toByteArray()); }; + _addCommentView.setContext(value.url, ref); + } + + updateCommentType(true); + } + + fun setPostOverview(value: IPlatformPost) { + clear(); + _url = value.url; + _postOverview = value; + + _creatorThumbnail.setThumbnail(value.author.thumbnail, false); + _buttonSubscribe.setSubscribeChannel(value.author.url); + _channelName.text = value.author.name; + setChannelMeta(value); + _textTitle.text = value.name; + _textMeta.text = value.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: "" //TODO: Include view count? + _textContent.text = value.description.fixHtmlWhitespace(); + _platformIndicator.setPlatformFromClientID(value.id.pluginId); + + val ref = value.id.value?.let { Models.referenceFromBuffer(it.toByteArray()); }; + _addCommentView.setContext(value.url, ref); + + updatePolycentricRating(); + fetchPolycentricProfile(); + fetchPost(); + } + + private fun setImages(images: List?, fullImages: List?) { + for (child in _layoutThumbnails.children) { + if (child is ImageView) { + Glide.with(child).clear(child); + } + } + + _layoutThumbnails.removeAllViews(); + + if (images.isNullOrEmpty() || fullImages.isNullOrEmpty()) { + _imageActive.visibility = View.GONE; + _layoutThumbnails.visibility = View.GONE; + return; + } + + _imageActive.visibility = View.VISIBLE; + + Glide.with(_imageActive) + .load(fullImages[0]) + .crossfade() + .into(_imageActive); + + if (images.size > 1) { + val dp_6f = 6.dp(resources).toFloat() + val dp_5 = 5.dp(resources) + val dp_12 = 12.dp(resources) + val dp_90 = 90.dp(resources) + + for (i in 0 until min(images.size, fullImages.size)) { + val image = images[i]; + val fullImage = fullImages[i]; + + _layoutThumbnails.addView(ShapeableImageView(context).apply { + scaleType = ImageView.ScaleType.CENTER_CROP + layoutParams = FlexboxLayout.LayoutParams(dp_90, dp_90).apply { setContentPadding(dp_5, dp_12, dp_5, 0) } + shapeAppearanceModel = ShapeAppearanceModel.builder().setAllCorners(CornerFamily.ROUNDED, dp_6f).build() + }.apply { + Glide.with(this) + .load(image.getLQThumbnail()) + .crossfade() + .into(this); + + setOnClickListener { + Glide.with(_imageActive) + .load(fullImage) + .crossfade() + .into(_imageActive); + } + }); + } + + _layoutThumbnails.visibility = View.VISIBLE; + } else { + _layoutThumbnails.visibility = View.GONE; + } + } + + private fun setRepliesOverlayVisible(isVisible: Boolean, animate: Boolean) { + if (_isRepliesVisible == isVisible) { + return; + } + + _isRepliesVisible = isVisible; + _repliesAnimator?.cancel(); + + if (isVisible) { + _repliesOverlay.visibility = View.VISIBLE; + + if (animate) { + _repliesOverlay.translationY = _repliesOverlay.height.toFloat(); + + _repliesAnimator = _repliesOverlay.animate() + .setDuration(300) + .translationY(0f) + .withEndAction { + _repliesAnimator = null; + }.apply { start() }; + } + } else { + if (animate) { + _repliesOverlay.translationY = 0f; + + _repliesAnimator = _repliesOverlay.animate() + .setDuration(300) + .translationY(_repliesOverlay.height.toFloat()) + .withEndAction { + _repliesOverlay.visibility = GONE; + _repliesAnimator = null; + }.apply { start(); } + } else { + _repliesOverlay.visibility = View.GONE; + _repliesOverlay.translationY = _repliesOverlay.height.toFloat(); + } + } + } + + private fun fetchPolycentricProfile() { + val author = _post?.author ?: _postOverview?.author ?: return; + val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(author.url); + if (cachedPolycentricProfile != null) { + setPolycentricProfile(cachedPolycentricProfile, animate = false); + } else { + setPolycentricProfile(null, animate = false); + _taskLoadPolycentricProfile.run(author.id); + } + } + + private fun setChannelMeta(value: IPlatformPost?) { + val subscribers = value?.author?.subscribers; + if(subscribers != null && subscribers > 0) { + _channelMeta.visibility = View.VISIBLE; + _channelMeta.text = value.author.subscribers!!.toHumanNumber() + " subscribers"; + } else { + _channelMeta.visibility = View.GONE; + _channelMeta.text = ""; + } + } + + fun setPostUrl(url: String) { + clear(); + _url = url; + fetchPost(); + } + + fun onDestroy() { + _commentsList.cancel(); + _taskLoadPost.cancel(); + _repliesOverlay.cleanup(); + } + + private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) { + _polycentricProfile = cachedPolycentricProfile; + + if (cachedPolycentricProfile?.profile == null) { + _layoutMonetization.visibility = View.GONE; + _creatorThumbnail.setHarborAvailable(false, animate); + return; + } + + _layoutMonetization.visibility = View.VISIBLE; + _creatorThumbnail.setHarborAvailable(true, animate); + } + + private fun fetchPost() { + Logger.i(TAG, "fetchVideo") + _post = null; + + val url = _url; + if (!url.isNullOrBlank()) { + setLoading(true); + _taskLoadPost.run(url); + } + } + + private fun fetchComments() { + Logger.i(TAG, "fetchComments") + _post?.let { + _commentsList.load(true) { StatePlatform.instance.getComments(it); }; + } + } + + private fun fetchPolycentricComments() { + Logger.i(TAG, "fetchPolycentricComments") + val post = _post; + val idValue = post?.id?.value + if (idValue == null) { + Logger.w(TAG, "Failed to fetch polycentric comments because id was null") + _commentsList.clear(); + return + } + + _commentsList.load(false) { StatePolycentric.instance.getCommentPager(post.url, Models.referenceFromBuffer(idValue.toByteArray())); }; + } + + private fun updateCommentType(reloadComments: Boolean) { + if (_toggleCommentType.value) { + _textCommentType.text = "Platform"; + _addCommentView.visibility = View.GONE; + + if (reloadComments) { + fetchComments(); + } + } else { + _textCommentType.text = "Polycentric"; + _addCommentView.visibility = View.VISIBLE; + + if (reloadComments) { + fetchPolycentricComments() + } + } + } + + private fun setLoading(isLoading : Boolean) { + if (_isLoading == isLoading) { + return; + } + + _isLoading = isLoading; + + if(isLoading) { + (_imageLoader.drawable as Animatable?)?.start() + _layoutLoadingOverlay.visibility = View.VISIBLE; + } + else { + _layoutLoadingOverlay.visibility = View.GONE; + (_imageLoader.drawable as Animatable?)?.stop() + } + } + + companion object { + const val TAG = "PostDetailFragment" + } + } + + companion object { + fun newInstance() = PostDetailFragment().apply {} + } +} diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt new file mode 100644 index 00000000..d7ec2197 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt @@ -0,0 +1,450 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.content.Intent +import android.graphics.drawable.Animatable +import android.net.Uri +import android.os.Bundle +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.webkit.CookieManager +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import androidx.lifecycle.lifecycleScope +import com.futo.platformplayer.* +import com.futo.platformplayer.activities.AddSourceActivity +import com.futo.platformplayer.activities.LoginActivity +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePlugins +import com.futo.platformplayer.views.buttons.BigButton +import com.futo.platformplayer.views.buttons.BigButtonGroup +import com.futo.platformplayer.views.sources.SourceHeaderView +import com.futo.platformplayer.views.fields.FieldForm +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class SourceDetailFragment : MainFragment() { + override val isMainView : Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var _view: SourceDetailView? = null; + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + _view?.onShown(parameter, isBack); + } + + override fun onHide() { + super.onHide(); + _view?.onHide(); + } + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = SourceDetailView(this, inflater); + _view = view; + return view; + } + override fun onDestroyMainView() { + super.onDestroyMainView(); + _view = null; + } + + class SourceDetailView: LinearLayout { + private val fragment: SourceDetailFragment; + + private val _sourceHeader: SourceHeaderView; + private val _sourceButtons: LinearLayout; + private val _layoutLoader: FrameLayout; + private val _imageSpinner: ImageView; + + private val _settingsAppForm: FieldForm; + private var _settingsAppChanged = false; + + private val _settingsForm: FieldForm; + private var _settings: HashMap? = null; + private var _settingsChanged = false; + + private var _config: SourcePluginConfig? = null; + + private var _loading = false; + + constructor(fragment: SourceDetailFragment, inflater: LayoutInflater) : super(inflater.context) { + inflater.inflate(R.layout.fragment_source_detail, this); + this.fragment = fragment; + _sourceHeader = findViewById(R.id.source_header); + _sourceButtons = findViewById(R.id.source_buttons); + _settingsAppForm = findViewById(R.id.source_app_setings); + _settingsForm = findViewById(R.id.source_settings); + _layoutLoader = findViewById(R.id.layout_loader); + _imageSpinner = findViewById(R.id.image_spinner); + + updateSourceViews(); + } + + fun onShown(parameter: Any?, isBack: Boolean) { + if (parameter is SourcePluginConfig) { + loadConfig(parameter); + updateSourceViews(); + } + + setLoading(false); + } + + fun onHide() { + val id = _config?.id ?: return; + + if(_settingsChanged && _settings != null) { + _settingsChanged = false; + StatePlugins.instance.setPluginSettings(id, _settings!!); + reloadSource(id); + + UIDialogs.toast("Plugin settings saved", false); + } + if(_settingsAppChanged) { + _settingsAppForm.setObjectValues(); + StatePlugins.instance.savePlugin(id); + } + } + + + private fun loadConfig(config: SourcePluginConfig?) { + _config = config; + if(config != null) { + try { + val settings = config.settings; + val source = StatePlatform.instance.getClient(config.id) as JSClient; + val settingValues = source.settings; + + fragment.lifecycleScope.launch(Dispatchers.Main) { + + //Set any defaults + source.descriptor.appSettings.loadDefaults(source.descriptor.config); + + //App settings + try { + _settingsAppForm.fromObject(source.descriptor.appSettings); + _settingsAppForm.onChanged.clear(); + _settingsAppForm.onChanged.subscribe { field, value -> + _settingsAppChanged = true; + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to load app settings form from plugin settings", e) + } + + //Plugin settings + try { + _settings = settingValues; + _settingsForm.fromPluginSettings( + settings, settingValues, "Plugin settings", + "These settings are defined by the plugin" + ); + _settingsForm.onChanged.clear(); + _settingsForm.onChanged.subscribe { field, value -> + _settingsChanged = true; + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to load settings form from plugin settings", e) + } + } + } + catch(ex: Throwable) { + Logger.e(TAG, "Failed to load source", ex); + UIDialogs.toast("Failed to loast source"); + } + } + } + + private fun setLoading(isLoading: Boolean) { + fragment.lifecycleScope.launch(Dispatchers.Main) { + try { + if (isLoading) { + _layoutLoader.visibility = View.VISIBLE; + (_imageSpinner.drawable as Animatable?)?.start(); + } else { + _layoutLoader.visibility = View.GONE; + (_imageSpinner.drawable as Animatable?)?.stop(); + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to setLoading.", e) + } + } + + _loading = isLoading; + } + + private fun updateSourceViews() { + val config = _config; + + if (config != null) { + _sourceHeader.loadConfig(config); + } else { + _sourceHeader.clear(); + } + + //updateAllToggles(); + updateButtons(); + } + + private fun updateButtons() { + val groups = mutableListOf(); + _sourceButtons.removeAllViews(); + + val c = context ?: return; + val config = _config ?: return; + val source = StatePlatform.instance.getClient(config.id) as JSClient; + val isEnabled = StatePlatform.instance.isClientEnabled(source); + + groups.add( + BigButtonGroup(c, "Update", + BigButton(c, "Check for updates", "Checks for new versions of the source", R.drawable.ic_update) { + checkForUpdatesSource(); + } + ) + ); + + if (source.isLoggedIn) { + groups.add( + BigButtonGroup(c, "Authentication", + BigButton(c, "Logout", "Sign out of the platform", R.drawable.ic_logout) { + logoutSource(); + } + ) + ); + + val migrationButtons = mutableListOf(); + if (isEnabled && source.capabilities.hasGetUserSubscriptions) { + migrationButtons.add( + BigButton(c, "Import Subscriptions", "Import your subscriptions from this source", R.drawable.ic_subscriptions) { + Logger.i(TAG, "Import subscriptions clicked."); + importSubscriptionsSource(); + } + ); + } + + if (isEnabled && source.capabilities.hasGetUserPlaylists && source.capabilities.hasGetPlaylist) { + val bigButton = BigButton(c, "Import Playlists", "Import your playlists from this source", R.drawable.ic_playlist) { + Logger.i(TAG, "Import playlists clicked."); + importPlaylistsSource(); + }; + + bigButton.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { + setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0); + }; + + migrationButtons.add(bigButton); + } + + if (migrationButtons.size > 0) { + groups.add(BigButtonGroup(c, "Migration", *migrationButtons.toTypedArray())); + } + } else { + if(config.authentication != null) { + groups.add( + BigButtonGroup(c, "Authentication", + BigButton(c, "Login", "Sign into the platform of this source", R.drawable.ic_login) { + loginSource(); + } + ) + ); + } + } + + groups.add( + BigButtonGroup(c, "Management", + BigButton(c, "Uninstall", "Removes the plugin from the app", R.drawable.ic_block) { + uninstallSource(); + }.withBackground(R.drawable.background_big_button_red) + ) + ) + + for (group in groups) { + _sourceButtons.addView(group); + } + } + + + private fun loginSource() { + val config = _config ?: return; + + if(config.authentication == null) + return; + + LoginActivity.showLogin(StateApp.instance.context, config) { + StatePlugins.instance.setPluginAuth(config.id, it); + + reloadSource(config.id); + }; + } + private fun logoutSource() { + val config = _config ?: return; + + StatePlugins.instance.setPluginAuth(config.id, null); + reloadSource(config.id); + + + //TODO: Maybe add a dialog option.. + if(Settings.instance.plugins.clearCookiesOnLogout) { + val cookieManager: CookieManager = CookieManager.getInstance(); + cookieManager.removeAllCookies(null); + } + } + private fun importPlaylistsSource() { + if (_loading) { + return; + } + setLoading(true); + + try { + val config = _config ?: return; + val source = StatePlatform.instance.getClient(config.id); + + fragment.lifecycleScope.launch(Dispatchers.IO) { + try { + val playlists = source.getUserPlaylists(); + withContext(Dispatchers.Main) { + fragment.navigate(playlists); + } + } catch (e: Throwable) { + withContext(Dispatchers.Main) { + context?.let { UIDialogs.showGeneralErrorDialog(it, "Failed to retrieve playlists.", e) } + } + } finally { + setLoading(false); + } + } + } catch (e: Throwable) { + setLoading(false); + } + } + private fun importSubscriptionsSource() { + if (_loading) { + return; + } + + setLoading(true); + + try { + val config = _config ?: return; + val source = StatePlatform.instance.getClient(config.id); + + Logger.i(TAG, "Getting user subscriptions."); + fragment.lifecycleScope.launch(Dispatchers.IO) { + try { + val subscriptions = source.getUserSubscriptions().distinct(); + Logger.i(TAG, "${subscriptions.size} user subscriptions retrieved."); + + withContext(Dispatchers.Main) { + fragment.navigate(subscriptions); + } + } catch(e: Throwable) { + withContext(Dispatchers.Main) { + context?.let { UIDialogs.showGeneralErrorDialog(it, "Failed to retrieve subscriptions.", e) } + } + } finally { + setLoading(false); + } + } + } catch(e: Throwable) { + setLoading(false); + } + } + private fun uninstallSource() { + val config = _config ?: return; + val source = StatePlatform.instance.getClient(config.id); + + UIDialogs.showConfirmationDialog(context, "Are you sure you want to uninstall ${source.name}", { + StatePlugins.instance.deletePlugin(source.id); + + fragment.lifecycleScope.launch(Dispatchers.IO) { + try { + StatePlatform.instance.updateAvailableClients(context); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to update available clients."); + } + + withContext(Dispatchers.Main) { + UIDialogs.toast(context, "Uninstalled ${source.name}"); + fragment.closeSegment(); + } + } + }); + } + private fun checkForUpdatesSource() { + val c = _config ?: return; + val sourceUrl = c.sourceUrl ?: return; + + Logger.i(TAG, "Check for updates tapped."); + fragment.lifecycleScope.launch(Dispatchers.IO) { + try { + val client = ManagedHttpClient(); + val response = client.get(sourceUrl); + Logger.i(TAG, "Downloading source config '$sourceUrl'."); + + if (!response.isOk || response.body == null) { + Logger.w(TAG, "Failed to check for updates (sourceUrl=${sourceUrl}, response.isOk=${response.isOk}, response.body=${response.body})."); + withContext(Dispatchers.Main) { UIDialogs.toast("Failed to check for updates"); }; + return@launch; + } + + val configJson = response.body.string(); + Logger.i(TAG, "Downloaded source config ($sourceUrl):\n${configJson}"); + + val config = SourcePluginConfig.fromJson(configJson); + if (config.version <= c.version) { + Logger.i(TAG, "Plugin is up to date."); + withContext(Dispatchers.Main) { UIDialogs.toast("Plugin is fully up to date"); }; + return@launch; + } + + Logger.i(TAG, "Update is available (config.version=${config.version}, source.config.version=${c.version})."); + + val c = context ?: return@launch; + val intent = Intent(c, AddSourceActivity::class.java).apply { + data = Uri.parse(sourceUrl) + }; + + fragment.startActivity(intent); + Logger.i(TAG, "Started add source activity."); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to check for updates.", e); + withContext(Dispatchers.Main) { UIDialogs.toast("Failed to check for updates"); }; + } + } + } + + private fun reloadSource(id: String) { + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + try { + StatePlatform.instance.reloadClient(context, id); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to reload client.", e) + return@launch; + } + + withContext(Dispatchers.Main) { + try { + updateSourceViews(); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to update source views.", e) + } + } + } + } + } + + + + companion object { + const val TAG = "SourceDetailFragment"; + fun newInstance() = SourceDetailFragment().apply {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourcesFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourcesFragment.kt new file mode 100644 index 00000000..83e94208 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourcesFragment.kt @@ -0,0 +1,239 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.R +import com.futo.platformplayer.activities.AddSourceOptionsActivity +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment +import com.futo.platformplayer.states.StatePlugins +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.SubscriptionStorage +import com.futo.platformplayer.views.sources.SourceUnderConstructionView +import com.futo.platformplayer.views.adapters.DisabledSourceView +import com.futo.platformplayer.views.adapters.EnabledSourceAdapter +import com.futo.platformplayer.views.adapters.EnabledSourceViewHolder +import com.futo.platformplayer.views.adapters.ItemMoveCallback +import kotlinx.coroutines.runBlocking +import java.util.* + +class SourcesFragment : MainFragment() { + override val isMainView : Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var _view: SourcesView? = null; + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack) + + if(topBar is AddTopBarFragment) + (topBar as AddTopBarFragment).onAdd.subscribe { + startActivity(Intent(requireContext(), AddSourceOptionsActivity::class.java)); + }; + + _view?.reloadSources(); + } + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = SourcesView(requireContext(), this); + _view = view; + return view; + } + + companion object { + private const val TAG = "SourcesFragment"; + fun newInstance() = SourcesFragment().apply {} + } + + + private class SourcesView: LinearLayout { + private val _fragment: SourcesFragment; + + private val enabledSources: MutableList = mutableListOf(); + private val disabledSources: MutableList = mutableListOf(); + private val _recyclerSourcesEnabled: RecyclerView; + private val _adapterSourcesEnabled: EnabledSourceAdapter; + private var _didCreateView = false; + + private val _containerEnabled: LinearLayout; + private val _containerDisabled: LinearLayout; + private val _containerDisabledViews: LinearLayout; + private val _containerConstruction: LinearLayout; + + constructor(context: Context, fragment: SourcesFragment): super(context) { + inflate(context, R.layout.fragment_sources, this); + + _fragment = fragment; + + val recyclerSourcesEnabled = findViewById(R.id.recycler_sources_enabled); + _containerEnabled = findViewById(R.id.container_enabled); + _containerDisabled = findViewById(R.id.container_disabled); + _containerDisabledViews = findViewById(R.id.container_disabled_views); + _containerConstruction = findViewById(R.id.container_construction); + + for(inConstructSource in StatePlugins.instance.getSourcesUnderConstruction(context)) + _containerConstruction.addView(SourceUnderConstructionView(context, inConstructSource.key, inConstructSource.value)); + + val callback = ItemMoveCallback(); + val touchHelper = ItemTouchHelper(callback); + val adapterSourcesEnabled = EnabledSourceAdapter(enabledSources, touchHelper); + + recyclerSourcesEnabled.adapter = adapterSourcesEnabled; + recyclerSourcesEnabled.layoutManager = LinearLayoutManager(context); + touchHelper.attachToRecyclerView(recyclerSourcesEnabled); + + //Enabled Sources control + callback.onRowMoved.subscribe { fromPosition, toPosition -> + if (fromPosition < toPosition) { + for (i in fromPosition until toPosition) { + Collections.swap(enabledSources, i, i + 1) + } + } else { + for (i in fromPosition downTo toPosition + 1) { + Collections.swap(enabledSources, i, i - 1) + } + } + + adapterSourcesEnabled.notifyItemMoved(fromPosition, toPosition); + onEnabledChanged(enabledSources); + if(toPosition == 0) + onPrimaryChanged(enabledSources.first()); + + StatePlatform.instance.setPlatformOrder(enabledSources.map { it.name }); + }; + adapterSourcesEnabled.onRemove.subscribe { source -> + val subscriptionStorage = FragmentedStorage.get(); + val enabledSourcesWithSourceRemoved = enabledSources.filter({ s -> s.id != source.id }).toList(); + val unresolvableBefore = subscriptionStorage.subscriptions.count({ s -> !enabledSources.any({ c -> c.isChannelUrl(s.channel.url) }) }); + val unresolvableAfter = subscriptionStorage.subscriptions.count({ s -> !enabledSourcesWithSourceRemoved.any({ c -> c.isChannelUrl(s.channel.url) }) }); + + val removeAction = { + val index = enabledSources.indexOf(source); + if (index >= 0) { + enabledSources.removeAt(index); + disabledSources.add(source); + adapterSourcesEnabled.notifyItemRemoved(index); + updateDisabledSources(); + } + + updateContainerVisibility(); + onEnabledChanged(enabledSources); + if(index == 0) + onPrimaryChanged(enabledSources.first()); + + if(enabledSources.size <= 1) + setCanRemove(false); + }; + + if (unresolvableAfter > unresolvableBefore) { + UIDialogs.showConfirmationDialog(context, fragment.getString(R.string.confirm_remove_source), removeAction); + } else { + removeAction(); + } + }; + adapterSourcesEnabled.onClick.subscribe { source -> + if (source is JSClient) { + fragment.navigate(source.config); + } + }; + + updateContainerVisibility(); + + _recyclerSourcesEnabled = recyclerSourcesEnabled; + _adapterSourcesEnabled = adapterSourcesEnabled; + //_adapterSourcesDisabled = adapterSourcesDisabled; + + setCanRemove(enabledSources.size > 1); + _didCreateView = true; + } + + fun reloadSources() { + enabledSources.clear(); + disabledSources.clear(); + + enabledSources.addAll(StatePlatform.instance.getSortedEnabledClient()); + disabledSources.addAll(StatePlatform.instance.getAvailableClients().filter { !enabledSources.contains(it) }); + _adapterSourcesEnabled?.notifyDataSetChanged(); + setCanRemove(enabledSources.size > 1); + //_adapterSourcesDisabled?.notifyDataSetChanged(); + updateDisabledSources(); + + if(_didCreateView) { + _containerEnabled.visibility = if (enabledSources.isNotEmpty()) { View.VISIBLE } else { View.GONE }; + _containerDisabled.visibility = if (disabledSources.isNotEmpty()) { View.VISIBLE } else { View.GONE }; + } + } + private fun updateDisabledSources() { + _containerDisabledViews.removeAllViews(); + disabledSources.toList().let { + for(source in disabledSources) { + _containerDisabledViews.addView(DisabledSourceView(context, source).apply { + this.onAdd.subscribe { + enableSource(it) + }; + this.onClick.subscribe { + if (source is JSClient) + _fragment.navigate(source.config); + } + }); + } + }; + } + + private fun enableSource(client: IPlatformClient) { + if (disabledSources.remove(client)) { + enabledSources.add(client); + _adapterSourcesEnabled.notifyItemInserted(enabledSources.size - 1); + } + updateDisabledSources(); + + updateContainerVisibility(); + onEnabledChanged(enabledSources); + + if(enabledSources.size > 1) + setCanRemove(true); + } + + private fun setCanRemove(canRemove: Boolean) { + val recyclerSourcesEnabled = _recyclerSourcesEnabled ?: return; + var adapterSourcesEnabled = _adapterSourcesEnabled ?: return; + + for (i in 0 until recyclerSourcesEnabled.childCount) { + val view: View = recyclerSourcesEnabled.getChildAt(i) + val viewHolder = recyclerSourcesEnabled.getChildViewHolder(view) + if (viewHolder is EnabledSourceViewHolder) { + viewHolder.setCanRemove(canRemove); + } + } + + adapterSourcesEnabled.canRemove = canRemove; + } + + private fun onPrimaryChanged(client: IPlatformClient) { + StatePlatform.instance.selectPrimaryClient(client.id); + } + private fun onEnabledChanged(clients: List) { + runBlocking { + StatePlatform.instance.selectClients(*clients.map { it.id }.toTypedArray()); + } + } + + + fun updateContainerVisibility() { + _containerEnabled.visibility = if (enabledSources.isNotEmpty()) { View.VISIBLE } else { View.GONE }; + _containerDisabled.visibility = if (disabledSources.isNotEmpty()) { View.VISIBLE } else { View.GONE }; + }; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt new file mode 100644 index 00000000..5a61b2f5 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt @@ -0,0 +1,302 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import com.futo.platformplayer.* +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.cache.ChannelContentCache +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.engine.exceptions.PluginException +import com.futo.platformplayer.exceptions.ChannelException +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateSubscriptions +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.FragmentedStorageFileJson +import com.futo.platformplayer.views.announcements.AnnouncementView +import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder +import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader +import com.futo.platformplayer.views.adapters.InsertedViewHolder +import com.futo.platformplayer.views.subscriptions.SubscriptionBar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.time.OffsetDateTime + +class SubscriptionsFeedFragment : MainFragment() { + override val isMainView : Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var _view: SubscriptionsFeedView? = null; + private var _cachedRecyclerData: FeedView.RecyclerData, LinearLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>? = null; + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + _view?.onShown(); + } + + override fun onResume() { + super.onResume() + _view?.onResume(); + } + + override fun onPause() { + super.onPause() + _view?.onPause(); + } + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = SubscriptionsFeedView(this, inflater, _cachedRecyclerData); + _view = view; + return view; + } + + override fun onDestroyMainView() { + super.onDestroyMainView(); + val view = _view; + if (view != null) { + _cachedRecyclerData = view.recyclerData; + view.cleanup(); + _view = null; + } + } + + fun setPreviewsEnabled(previewsEnabled: Boolean) { + _view?.setPreviewsEnabled(previewsEnabled); + } + + @SuppressLint("ViewConstructor") + class SubscriptionsFeedView : ContentFeedView { + constructor(fragment: SubscriptionsFeedFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData, LinearLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>? = null) : super(fragment, inflater, cachedRecyclerData) { + StateSubscriptions.instance.onGlobalSubscriptionsUpdateProgress.subscribe(this) { progress, total -> + fragment.lifecycleScope.launch(Dispatchers.Main) { + try { + setProgress(progress, total); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to set progress", e); + } + } + }; + + StateSubscriptions.instance.onSubscriptionsChanged.subscribe(this) { subs, added -> + if(!added) + StateSubscriptions.instance.clearSubscriptionFeed(); + StateApp.instance.scopeOrNull?.let { + StateSubscriptions.instance.updateSubscriptionFeed(it); + } + recyclerData.lastLoad = OffsetDateTime.MIN; + }; + + initializeToolbarContent(); + } + + fun onShown() { + val currentProgress = StateSubscriptions.instance.getGlobalSubscriptionProgress(); + setProgress(currentProgress.first, currentProgress.second); + + if(recyclerData.loadedFeedStyle != feedStyle || + recyclerData.lastLoad.getNowDiffSeconds() > 60 ) { + recyclerData.lastLoad = OffsetDateTime.now(); + loadResults(); + } + + val announcementsView = _announcementsView; + val homeTab = Settings.instance.tabs.find { it.id == 0 }; + val isHomeEnabled = homeTab?.enabled == true; + if (announcementsView != null && isHomeEnabled) { + headerView?.removeView(announcementsView); + _announcementsView = null; + } + + if (announcementsView == null && !isHomeEnabled) { + val c = context; + if (c != null) { + _announcementsView = AnnouncementView(c).apply { + headerView?.addView(AnnouncementView(c)) + }; + } + } + } + + override fun cleanup() { + super.cleanup() + StateSubscriptions.instance.onGlobalSubscriptionsUpdateProgress.remove(this); + StateSubscriptions.instance.onSubscriptionsChanged.remove(this); + } + + override val feedStyle: FeedStyle get() = Settings.instance.subscriptions.getSubscriptionsFeedStyle(); + + private var _subscriptionBar: SubscriptionBar? = null; + + private var _announcementsView: AnnouncementView? = null; + + @Serializable + class FeedFilterSettings: FragmentedStorageFileJson() { + val allowContentTypes: MutableList = mutableListOf(ContentType.MEDIA, ContentType.POST); + var allowLive: Boolean = true; + var allowPlanned: Boolean = false; + override fun encode(): String { + return Json.encodeToString(this); + } + } + private val _filterLock = Object(); + private val _filterSettings = FragmentedStorage.get("subFeedFilter"); + + private val _lastExceptions: List? = null; + private val _taskGetPager = TaskHandler>({StateApp.instance.scope}, { withRefresh -> + val resp = StateSubscriptions.instance.getGlobalSubscriptionFeed(StateApp.instance.scope, withRefresh); + + val currentExs = StateSubscriptions.instance.globalSubscriptionExceptions; + if(currentExs != _lastExceptions && currentExs.any()) + handleExceptions(currentExs); + + return@TaskHandler resp; + }) + .success { loadedResult(it); } + .exception { + Logger.w(ChannelFragment.TAG, "Failed to load channel.", it); + UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() }); + }; + + private fun initializeToolbarContent() { + _subscriptionBar = SubscriptionBar(context).apply { + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); + }; + _subscriptionBar?.onClickChannel?.subscribe { c -> fragment.navigate(c); }; + + synchronized(_filterLock) { + _subscriptionBar?.setToggles( + SubscriptionBar.Toggle("Videos", _filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), it); }, + SubscriptionBar.Toggle("Posts", _filterSettings.allowContentTypes.contains(ContentType.POST)) { toggleFilterContentType(ContentType.POST, it); }, + SubscriptionBar.Toggle("Live", _filterSettings.allowLive) { _filterSettings.allowLive = it; _filterSettings.save(); loadResults(false); }, + SubscriptionBar.Toggle("Planned", _filterSettings.allowPlanned) { _filterSettings.allowPlanned = it; _filterSettings.save(); loadResults(false); } + ); + } + + _toolbarContentView.addView(_subscriptionBar, 0); + } + private fun toggleFilterContentTypes(contentTypes: List, isTrue: Boolean) { + for(contentType in contentTypes) + toggleFilterContentType(contentType, isTrue); + } + private fun toggleFilterContentType(contentType: ContentType, isTrue: Boolean) { + synchronized(_filterLock) { + if(!isTrue) + _filterSettings.allowContentTypes.remove(contentType); + else if(!_filterSettings.allowContentTypes.contains(contentType)) + _filterSettings.allowContentTypes.add(contentType) + else null; + _filterSettings.save(); + }; + loadResults(false) + } + + override fun filterResults(results: List): List { + val nowSoon = OffsetDateTime.now().plusMinutes(5); + return results.filter { + val allowedContentType = _filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO) ContentType.MEDIA else it.contentType); + + if(it.datetime?.isAfter(nowSoon) == true) { + if(!_filterSettings.allowPlanned) + return@filter false; + } + + if(_filterSettings.allowLive) { //If allowLive, always show live + if(it is IPlatformVideo && it.isLive) + return@filter true; + } + else if(it is IPlatformVideo && it.isLive) + return@filter false; + + return@filter allowedContentType; + }; + } + + override fun reload() { + loadResults(true); + } + + private fun loadResults(withRefetch: Boolean = false) { + setLoading(true); + Logger.i(TAG, "Subscriptions load"); + if(recyclerData.results.size == 0) { + val cachePager = ChannelContentCache.instance.getSubscriptionCachePager(); + Logger.i(TAG, "Subscription show cache (${cachePager.getResults().size})"); + setPager(cachePager); + } + _taskGetPager.run(withRefetch); + } + + private fun loadedResult(pager: IPager) { + Logger.i(TAG, "Subscriptions new pager loaded"); + + fragment.lifecycleScope.launch(Dispatchers.Main) { + try { + finishRefreshLayoutLoader(); + setLoading(false); + setPager(pager); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to finish loading", e) + } + } + } + + private fun handleExceptions(exs: List) { + context?.let { + fragment.lifecycleScope.launch(Dispatchers.Main) { + try { + if (exs!!.size <= 8) { + for (ex in exs) { + var toShow = ex; + var channel: String? = null; + if (toShow is ChannelException) { + channel = toShow.channelNameOrUrl; + toShow = toShow.cause!!; + } + Logger.e(TAG, "Channel [${channel}] failed", ex); + if (toShow is PluginException) + UIDialogs.toast( + it, + "Plugin [${toShow.config.name}] (${channel}) failed:\n${toShow.message}" + ); + else + UIDialogs.toast(it, ex.message ?: ""); + } + } + else { + val failedPlugins = exs.filter { it is PluginException || (it is ChannelException && it.cause is PluginException) } + .map { if(it is ChannelException) (it.cause as PluginException) else if(it is PluginException) it else null } + .filter { it != null } + .distinctBy { it?.config?.name } + .map { it!! } + .toList(); + for(distinctPluginFail in failedPlugins) + UIDialogs.toast(it, "Plugin [${distinctPluginFail.config.name}] failed:\n${distinctPluginFail.message}"); + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to handle exceptions", e) + } + } + } + } + } + + companion object { + val TAG = "SubscriptionsFeedFragment"; + + fun newInstance() = SubscriptionsFeedFragment().apply {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt new file mode 100644 index 00000000..27a96094 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt @@ -0,0 +1,195 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.* +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.SearchType +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.SearchHistoryStorage +import com.futo.platformplayer.views.adapters.SearchSuggestionAdapter + +data class SuggestionsFragmentData(val query: String, val searchType: SearchType, val channelUrl: String? = null); + +class SuggestionsFragment : MainFragment { + override val isMainView : Boolean = true; + override val hasBottomBar: Boolean = false; + override val isHistory: Boolean = false; + + private var _recyclerSuggestions: RecyclerView? = null; + private var _llmSuggestions: LinearLayoutManager? = null; + private val _suggestions: ArrayList = ArrayList(); + private var _query: String? = null; + private var _searchType: SearchType = SearchType.VIDEO; + private var _channelUrl: String? = null; + + private val _adapterSuggestions = SearchSuggestionAdapter(_suggestions); + + private val _getSuggestions = TaskHandler>({lifecycleScope}, { + query -> StatePlatform.instance.searchSuggestions(query) + }) + .success { suggestions -> updateSuggestions(suggestions, false) } + .exception { + Logger.w(ChannelFragment.TAG, "Failed to load suggestions.", it); + UIDialogs.showGeneralRetryErrorDialog(requireContext(), it.message ?: "", it, { loadSuggestions() }); + }; + + constructor(): super() { + _adapterSuggestions.onAddToQuery.subscribe { suggestion -> (topBar as SearchTopBarFragment?)?.setText(suggestion); }; + _adapterSuggestions.onClicked.subscribe { suggestion -> + val storage = FragmentedStorage.get(); + storage.add(suggestion); + + if (_searchType == SearchType.CREATOR) { + navigate(suggestion); + } else if (_searchType == SearchType.PLAYLIST) { + navigate(suggestion); + } else { + navigate(SuggestionsFragmentData(suggestion, SearchType.VIDEO, _channelUrl)); + } + } + _adapterSuggestions.onRemove.subscribe { suggestion -> + val index = _suggestions.indexOf(suggestion); + if (index == -1) { + return@subscribe; + } + + val storage = FragmentedStorage.get(); + storage.lastQueries.removeAt(index); + _suggestions.removeAt(index); + _adapterSuggestions.notifyItemRemoved(index); + storage.save(); + }; + } + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = inflater.inflate(R.layout.fragment_suggestion_list, container, false); + + val recyclerSuggestions: RecyclerView = view.findViewById(R.id.list_suggestions); + recyclerSuggestions.layoutManager = _llmSuggestions; + recyclerSuggestions.adapter = _adapterSuggestions; + _recyclerSuggestions = recyclerSuggestions; + + loadSuggestions(); + return view; + } + + override fun onAttach(context: Context) { + super.onAttach(context) + + val llmSuggestions = LinearLayoutManager(context); + llmSuggestions.orientation = LinearLayoutManager.VERTICAL; + _llmSuggestions = llmSuggestions; + } + + override fun onDetach() { + super.onDetach(); + _llmSuggestions = null; + } + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + + loadSuggestions(); + + if (parameter is SuggestionsFragmentData) { + _searchType = parameter.searchType; + _channelUrl = parameter.channelUrl; + } else if (parameter is SearchType) { + _searchType = parameter; + _channelUrl = null; + } + + topBar?.apply { + if (this is SearchTopBarFragment) { + onSearch.subscribe(this) { + if (_searchType == SearchType.CREATOR) { + navigate(it); + } else if (_searchType == SearchType.PLAYLIST) { + navigate(it); + } else { + navigate(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl)); + } + }; + + onTextChange.subscribe(this) { + setQuery(it); + }; + } + } + } + + override fun onHide() { + _query = null; + updateSuggestions(arrayOf(), false); + + topBar?.apply { + if (this is SearchTopBarFragment) { + onSearch.remove(this); + onTextChange.remove(this); + } + } + } + private fun setQuery(query: String) { + _query = query; + loadSuggestions(); + } + + private fun loadSuggestions() { + _getSuggestions.cancel(); + + val query = _query; + Logger.i(TAG, "loadSuggestions query='$query'"); + + if (query.isNullOrBlank()) { + if (!Settings.instance.search.searchHistory) { + updateSuggestions(arrayOf(), false); + return; + } + + val lastQueries = FragmentedStorage.get().lastQueries.toTypedArray(); + updateSuggestions(lastQueries, true); + return; + } + + _getSuggestions.run(query); + } + + private fun updateSuggestions(suggestions: Array, isHistorical: Boolean) { + Logger.i(TAG, "updateSuggestions suggestions='${suggestions.size}' isHistorical=${isHistorical}"); + + _suggestions.clear(); + if (suggestions.isNotEmpty()) { + _suggestions.addAll(suggestions); + } + + _adapterSuggestions.isHistorical = isHistorical; + _adapterSuggestions.notifyDataSetChanged(); + } + + override fun onDestroyMainView() { + super.onDestroyMainView(); + _getSuggestions.onError.clear(); + _recyclerSuggestions = null; + } + + override fun onDestroy() { + super.onDestroy(); + _getSuggestions.cancel(); + } + + companion object { + val TAG = "SuggestionsFragment"; + + fun newInstance() = SuggestionsFragment().apply { } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt new file mode 100644 index 00000000..fea155de --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt @@ -0,0 +1,470 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.content.pm.ActivityInfo +import android.content.res.Configuration +import android.os.Bundle +import android.view.* +import androidx.constraintlayout.motion.widget.MotionLayout +import androidx.core.view.* +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.casting.CastConnectionState +import com.futo.platformplayer.casting.StateCasting +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.listeners.OrientationManager +import com.futo.platformplayer.models.PlatformVideoWithTime +import com.futo.platformplayer.models.UrlVideoWithTime +import com.futo.platformplayer.states.StateSaved +import com.futo.platformplayer.states.VideoToOpen +import com.futo.platformplayer.views.containers.SingleViewTouchableMotionLayout + +class VideoDetailFragment : MainFragment { + override val isMainView : Boolean = false; + override val hasBottomBar: Boolean = true; + override val isOverlay : Boolean = true; + override val isHistory: Boolean = false; + + private var _isActive: Boolean = false; + + private var _viewDetail : VideoDetailView? = null; + private var _view : SingleViewTouchableMotionLayout? = null; + + var isFullscreen : Boolean = false; + var isTransitioning : Boolean = false + private set; + var isInPictureInPicture : Boolean = false + private set; + + var state: State = State.CLOSED; + val currentUrl get() = _viewDetail?.currentUrl; + + val onMinimize = Event0(); + val onTransitioning = Event1(); + val onMaximized = Event0(); + + var lastOrientation : OrientationManager.Orientation = OrientationManager.Orientation.PORTRAIT + private set; + + private var _isInitialMaximize = true; + + private val _maximizeProgress get() = _view?.progress ?: 0.0f; + + private var _loadUrlOnCreate: UrlVideoWithTime? = null; + private var _leavingPiP = false; + +//region Fragment + constructor() : super() { + } + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + Logger.i(TAG, "onShownWithView parameter=$parameter") + + activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + + if(parameter is IPlatformVideoDetails) + _viewDetail?.setVideoDetails(parameter, true); + else if (parameter is IPlatformVideo) + _viewDetail?.setVideoOverview(parameter); + else if(parameter is PlatformVideoWithTime) + _viewDetail?.setVideoOverview(parameter.video, true, parameter.time); + else if (parameter is UrlVideoWithTime) { + if (_viewDetail == null) { + _loadUrlOnCreate = parameter; + } else { + _viewDetail?.setVideo(parameter.url, parameter.timeSeconds, parameter.playWhenReady); + } + } else if(parameter is String) { + if (_viewDetail == null) { + _loadUrlOnCreate = UrlVideoWithTime(parameter, 0, true); + } else { + _viewDetail?.setVideo(parameter, 0, true); + } + } + } + + override fun onOrientationChanged(orientation: OrientationManager.Orientation) { + super.onOrientationChanged(orientation); + + if(!_isActive || state != State.MAXIMIZED) + return; + + var newOrientation = orientation; + val d = StateCasting.instance.activeDevice; + if (d != null && d.connectionState == CastConnectionState.CONNECTED) { + newOrientation = OrientationManager.Orientation.PORTRAIT; + } else if(StatePlayer.instance.rotationLock) { + return; + } + + if(lastOrientation == newOrientation) + return; + + activity?.let { + if (isFullscreen) { + if(newOrientation == OrientationManager.Orientation.REVERSED_LANDSCAPE && it.requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) + changeOrientation(OrientationManager.Orientation.REVERSED_LANDSCAPE); + else if(newOrientation == OrientationManager.Orientation.LANDSCAPE && it.requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE) + changeOrientation(OrientationManager.Orientation.LANDSCAPE); + else if(Settings.instance.playback.isAutoRotate() && (newOrientation == OrientationManager.Orientation.PORTRAIT || newOrientation == OrientationManager.Orientation.REVERSED_PORTRAIT)) { + _viewDetail?.setFullscreen(false); + } + } + else { + if(Settings.instance.playback.isAutoRotate() && (lastOrientation == OrientationManager.Orientation.PORTRAIT || lastOrientation == OrientationManager.Orientation.REVERSED_PORTRAIT)) { + lastOrientation = newOrientation; + _viewDetail?.setFullscreen(true); + } + } + } + lastOrientation = newOrientation; + } + override fun onBackPressed(): Boolean { + Logger.i(TAG, "onBackPressed") + + if (_viewDetail?.onBackPressed() == true) { + return true; + } + + if(state == State.MAXIMIZED) + minimizeVideoDetail(); + else + closeVideoDetails(); + return true; + } + override fun onHide() { + super.onHide(); + activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + + fun preventPictureInPicture() { + Logger.i(TAG, "preventPictureInPicture() preventPictureInPicture = true"); + _viewDetail?.preventPictureInPicture = true; + } + + fun minimizeVideoDetail(){ + _viewDetail?.setFullscreen(false); + if(_view != null) + _view!!.transitionToStart(); + } + fun maximizeVideoDetail(instant: Boolean = false) { + if(_maximizeProgress > 0.9f && state != State.MAXIMIZED) { + state = State.MAXIMIZED; + onMaximized.emit(); + } + _view?.let { + if(!instant) + it.transitionToEnd(); + else { + it.progress = 1f; + onTransitioning.emit(true); + } + }; + } + fun closeVideoDetails() { + Logger.i(TAG, "closeVideoDetails()") + state = State.CLOSED; + _viewDetail?.onStop(); + close(); + + StatePlayer.instance.clearQueue(); + StatePlayer.instance.setPlayerClosed(); + } + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _view = inflater.inflate(R.layout.fragment_video_detail, container, false) as SingleViewTouchableMotionLayout; + _viewDetail = _view!!.findViewById(R.id.fragview_videodetail).also { + it.applyFragment(this); + it.onFullscreenChanged.subscribe(::onFullscreenChanged); + it.onMinimize.subscribe { + _view!!.transitionToStart(); + }; + it.onClose.subscribe { + Logger.i(TAG, "onClose") + closeVideoDetails(); + }; + it.onMaximize.subscribe { maximizeVideoDetail(it) }; + it.onPlayChanged.subscribe { + if(isInPictureInPicture) { + val params = _viewDetail?.getPictureInPictureParams(); + if (params != null) + activity?.setPictureInPictureParams(params); + } + }; + it.onEnterPictureInPicture.subscribe { + Logger.i(TAG, "onEnterPictureInPicture") + isInPictureInPicture = true; + _viewDetail?.handleEnterPictureInPicture(); + _viewDetail?.invalidate(); + }; + it.onTouchCancel.subscribe { + val v = _view ?: return@subscribe; + if (v.progress >= 0.5 && v.progress < 1) { + maximizeVideoDetail(); + } + if (v.progress < 0.5 && v.progress > 0) { + minimizeVideoDetail(); + } + }; + } + _view!!.setTransitionListener(object : MotionLayout.TransitionListener { + override fun onTransitionChange(motionLayout: MotionLayout?, startId: Int, endId: Int, progress: Float) { + + if (state != State.MINIMIZED && progress < 0.1) { + state = State.MINIMIZED; + onMinimize.emit(); + } + else if (state != State.MAXIMIZED && progress > 0.9) { + if (_isInitialMaximize) { + state = State.CLOSED; + _isInitialMaximize = false; + } + else { + state = State.MAXIMIZED; + onMaximized.emit(); + } + } + + if (isTransitioning && (progress > 0.95 || progress < 0.05)) { + isTransitioning = false; + onTransitioning.emit(isTransitioning); + + if(isInPictureInPicture) leavePictureInPictureMode(false); //Workaround to prevent getting stuck in p2p + } + else if (!isTransitioning && (progress < 0.95 && progress > 0.05)) { + isTransitioning = true; + onTransitioning.emit(isTransitioning); + + if(isInPictureInPicture) leavePictureInPictureMode(false); //Workaround to prevent getting stuck in p2p + } + } + override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) { } + override fun onTransitionStarted(p0: MotionLayout?, p1: Int, p2: Int) { } + override fun onTransitionTrigger(p0: MotionLayout?, p1: Int, p2: Boolean, p3: Float) { } + }); + + context + _view?.let { + if (it.progress >= 0.5 && it.progress < 1.0) + maximizeVideoDetail(); + if (it.progress < 0.5 && it.progress > 0.0) + minimizeVideoDetail(); + } + + _loadUrlOnCreate?.let { _viewDetail?.setVideo(it.url, it.timeSeconds, it.playWhenReady) }; + + maximizeVideoDetail(); + return _view!!; + } + + fun onUserLeaveHint() { + val viewDetail = _viewDetail; + Logger.i(TAG, "onUserLeaveHint preventPictureInPicture=${viewDetail?.preventPictureInPicture} isCasting=${StateCasting.instance.isCasting} isBackgroundPictureInPicture=${Settings.instance.playback.isBackgroundPictureInPicture()} allowBackground=${viewDetail?.allowBackground}"); + + if(viewDetail?.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && viewDetail?.allowBackground != true) { + _leavingPiP = false; + + val params = _viewDetail?.getPictureInPictureParams(); + if(params != null) { + Logger.i(TAG, "enterPictureInPictureMode") + activity?.enterPictureInPictureMode(params); + } + } + } + + fun forcePictureInPicture() { + val params = _viewDetail?.getPictureInPictureParams(); + if(params != null) + activity?.enterPictureInPictureMode(params); + } + fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, isStop: Boolean, newConfig: Configuration) { + if (isInPictureInPictureMode) { + _viewDetail?.startPictureInPicture(); + } else if (isInPictureInPicture) { + leavePictureInPictureMode(isStop); + } + } + fun leavePictureInPictureMode(isStop: Boolean) { + isInPictureInPicture = false; + _leavingPiP = true; + + UIDialogs.dismissAllDialogs(); + + _viewDetail?.handleLeavePictureInPicture(); + if (isStop) { + stopIfRequired(); + } + } + + override fun onResume() { + super.onResume(); + Logger.i(TAG, "onResume"); + _isActive = true; + _leavingPiP = false; + + _viewDetail?.let { + Logger.i(TAG, "onResume preventPictureInPicture=false"); + it.preventPictureInPicture = false; + + if (state != State.CLOSED) { + it.onResume(); + } + } + + val realOrientation = if(activity is MainActivity) (activity as MainActivity).orientation else lastOrientation; + Logger.i(TAG, "Real orientation on boot ${realOrientation}, lastOrientation: ${lastOrientation}"); + if(realOrientation != lastOrientation) + onOrientationChanged(realOrientation); + } + override fun onPause() { + super.onPause(); + Logger.i(TAG, "onPause"); + _isActive = false; + + if(!isInPictureInPicture && state != State.CLOSED) + _viewDetail?.onPause(); + } + + override fun onStop() { + Logger.i(TAG, "onStop"); + + stopIfRequired(); + super.onStop(); + } + + private fun stopIfRequired() { + var shouldStop = true; + if (_viewDetail?.allowBackground == true) { + shouldStop = false; + } else if (Settings.instance.playback.isBackgroundPictureInPicture() && !_leavingPiP) { + shouldStop = false; + } else if (Settings.instance.playback.isBackgroundContinue()) { + shouldStop = false; + } else if (StateCasting.instance.isCasting) { + shouldStop = false; + } + + Logger.i(TAG, "shouldStop: $shouldStop"); + if(shouldStop) { + _viewDetail?.let { + val v = it.video ?: return@let; + StateSaved.instance.setVideoToOpenBlocking(VideoToOpen(v.url, (it.lastPositionMilliseconds / 1000.0f).toLong())); + } + + _viewDetail?.onStop(); + StateCasting.instance.onStop(); + Logger.i(TAG, "called onStop() shouldStop: $shouldStop"); + } + } + + override fun onDestroyMainView() { + super.onDestroyMainView(); + Logger.i(TAG, "onDestroyMainView"); + _viewDetail?.let { + _viewDetail = null; + it.onDestroy(); + } + _view = null; + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState); + StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ -> + onOrientationChanged(lastOrientation); + }; + } + + override fun onDestroy() { + super.onDestroy() + + _viewDetail?.let { + _viewDetail = null; + it.onDestroy(); + } + + StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); + + Logger.i(TAG, "onDestroy"); + onMinimize.clear(); + onMaximized.clear(); + } + + private fun onFullscreenChanged(fullscreen : Boolean) { + activity?.let { + if (fullscreen) { + var orient = lastOrientation; + if(orient == OrientationManager.Orientation.PORTRAIT || orient == OrientationManager.Orientation.REVERSED_PORTRAIT) + orient = OrientationManager.Orientation.LANDSCAPE; + changeOrientation(orient); + } + else + changeOrientation(OrientationManager.Orientation.PORTRAIT); + } + isFullscreen = fullscreen; + } + private fun changeOrientation(orientation: OrientationManager.Orientation) { + Logger.i(TAG, "Orientation Change:" + orientation.name); + activity?.let { + when (orientation) { + OrientationManager.Orientation.LANDSCAPE -> { + it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE; + _view?.allowMotion = false; + + WindowCompat.setDecorFitsSystemWindows(requireActivity().window, false) + WindowInsetsControllerCompat(it.window, _viewDetail!!).let { controller -> + controller.hide(WindowInsetsCompat.Type.statusBars()); + controller.hide(WindowInsetsCompat.Type.systemBars()); + controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE; + } + } + OrientationManager.Orientation.REVERSED_LANDSCAPE -> { + it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE; + _view?.allowMotion = false; + + WindowCompat.setDecorFitsSystemWindows(requireActivity().window, false) + WindowInsetsControllerCompat(it.window, _viewDetail!!).let { controller -> + controller.hide(WindowInsetsCompat.Type.statusBars()); + controller.hide(WindowInsetsCompat.Type.systemBars()); + controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE; + } + } + else -> { + it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; + _view?.allowMotion = true; + + WindowCompat.setDecorFitsSystemWindows(it.window, true) + WindowInsetsControllerCompat(it.window, _viewDetail!!).let { controller -> + controller.show(WindowInsetsCompat.Type.statusBars()); + controller.show(WindowInsetsCompat.Type.systemBars()) + } + } + } + } + } + + companion object { + private val TAG = "VideoDetailFragment"; + + fun newInstance() = VideoDetailFragment().apply {} + } + + enum class State { + CLOSED, + MINIMIZED, + MAXIMIZED + } + +//endregion + +//region View + //TODO: Determine if encapsulated would be readable enough +//endregion +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt new file mode 100644 index 00000000..0a72510c --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -0,0 +1,2123 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.app.PictureInPictureParams +import android.app.RemoteAction +import android.content.Context +import android.content.Intent +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.Rect +import android.graphics.drawable.Animatable +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.Icon +import android.net.Uri +import android.provider.Browser +import android.support.v4.media.session.PlaybackStateCompat +import android.text.Spanned +import android.util.AttributeSet +import android.util.Log +import android.util.Rational +import android.util.TypedValue +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.* +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.lifecycle.lifecycleScope +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import com.futo.platformplayer.* + +import com.futo.platformplayer.api.media.IPluginSourced +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.LiveChatManager +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException +import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException +import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor +import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent +import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker +import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.sources.* +import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.casting.CastConnectionState +import com.futo.platformplayer.casting.StateCasting +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.downloads.VideoLocal +import com.futo.platformplayer.engine.exceptions.ScriptAgeException +import com.futo.platformplayer.engine.exceptions.ScriptException +import com.futo.platformplayer.engine.exceptions.ScriptImplementationException +import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException +import com.futo.platformplayer.helpers.VideoHelper +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.polycentric.PolycentricCache +import com.futo.platformplayer.receivers.MediaControlReceiver +import com.futo.platformplayer.states.* +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringArrayStorage +import com.futo.platformplayer.views.behavior.TouchInterceptFrameLayout +import com.futo.platformplayer.views.casting.CastView +import com.futo.platformplayer.views.comments.AddCommentView +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.others.Toggle +import com.futo.platformplayer.views.overlays.DescriptionOverlay +import com.futo.platformplayer.views.overlays.LiveChatOverlay +import com.futo.platformplayer.views.overlays.QueueEditorOverlay +import com.futo.platformplayer.views.overlays.RepliesOverlay +import com.futo.platformplayer.views.overlays.slideup.* +import com.futo.platformplayer.views.pills.PillRatingLikesDislikes +import com.futo.platformplayer.views.pills.RoundButton +import com.futo.platformplayer.views.pills.RoundButtonGroup +import com.futo.platformplayer.views.platform.PlatformIndicator +import com.futo.platformplayer.views.segments.CommentsList +import com.futo.platformplayer.views.subscriptions.SubscribeButton +import com.futo.platformplayer.views.video.FutoVideoPlayer +import com.futo.platformplayer.views.video.FutoVideoPlayerBase +import com.futo.platformplayer.views.videometa.UpNextView +import com.futo.polycentric.core.* +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.Format +import com.google.android.exoplayer2.ui.PlayerControlView +import com.google.android.exoplayer2.ui.TimeBar +import com.google.android.exoplayer2.upstream.HttpDataSource.InvalidResponseCodeException +import com.google.protobuf.ByteString +import kotlinx.coroutines.* +import userpackage.Protocol +import java.time.OffsetDateTime +import kotlin.collections.ArrayList +import kotlin.math.roundToLong +import kotlin.streams.toList + + +class VideoDetailView : ConstraintLayout { + private val TAG = "VideoDetailView" + + lateinit var fragment: VideoDetailFragment; + + private var _destroyed = false; + + private var _url: String? = null; + private var _playWhenReady = true; + private var _searchVideo: IPlatformVideo? = null; + var video: IPlatformVideoDetails? = null + private set; + private var _playbackTracker: IPlaybackTracker? = null; + + val currentUrl get() = video?.url ?: _searchVideo?.url ?: _url; + + private var _liveChat: LiveChatManager? = null; + private var _videoResumePositionMilliseconds : Long = 0L; + + private val _player: FutoVideoPlayer; + private val _cast: CastView; + private val _playerProgress: PlayerControlView; + private val _timeBar: TimeBar; + private var _upNext: UpNextView; + + val rootView: ConstraintLayout; + + private val _title: TextView; + private val _subTitle: TextView; + private val _description: TextView; + private val _descriptionContainer: LinearLayout; + + private val _platform: PlatformIndicator; + + private val _channelName: TextView; + private val _channelMeta: TextView; + private val _creatorThumbnail: CreatorThumbnail; + private val _channelButton: LinearLayout; + + private val _description_viewMore: TextView; + + private val _overlay_loading: FrameLayout; + private val _overlay_loading_spinner: ImageView; + private val _rating: PillRatingLikesDislikes; + + private val _minimize_controls: LinearLayout; + private val _minimize_controls_play: ImageButton; + private val _minimize_controls_pause: ImageButton; + private val _minimize_controls_close: ImageButton; + private val _minimize_title: TextView; + private val _minimize_meta: TextView; + + private val _commentsList: CommentsList; + + private var _minimizeProgress: Float = 0f; + private val _buttonSubscribe: SubscribeButton; + + private val _buttonPins: RoundButtonGroup; + //private val _buttonMore: RoundButton; + + var preventPictureInPicture: Boolean = false; + + private val _textComments: TextView; + private val _textCommentType: TextView; + private val _addCommentView: AddCommentView; + private val _toggleCommentType: Toggle; + + private val _textResume: TextView; + private val _layoutResume: LinearLayout; + private var _jobHideResume: Job? = null; + private val _layoutPlayerContainer: TouchInterceptFrameLayout; + + //Overlays + private val _overlayContainer: FrameLayout; + private val _overlay_quality_container: FrameLayout; + private var _overlay_quality_selector: SlideUpMenuOverlay? = null; + + //Bottom Containers + private val _container_content: FrameLayout; + private val _container_content_main: FrameLayout; + private val _container_content_queue: QueueEditorOverlay; + private val _container_content_replies: RepliesOverlay; + private val _container_content_description: DescriptionOverlay; + private val _container_content_liveChat: LiveChatOverlay; + + private var _container_content_current: View; + + private val _textLikes: TextView; + private val _textDislikes: TextView; + private val _layoutRating: LinearLayout; + private val _imageDislikeIcon: ImageView; + private val _imageLikeIcon: ImageView; + + private val _buttonSupport: LinearLayout; + private val _buttonStore: LinearLayout; + private val _layoutMonetization: LinearLayout; + + private val _buttonMore: RoundButton; + + private var _didStop: Boolean = false; + private var _onPauseCalled = false; + private var _lastVideoSource: IVideoSource? = null; + private var _lastAudioSource: IAudioSource? = null; + private var _lastSubtitleSource: ISubtitleSource? = null; + private var _isCasting: Boolean = false; + var lastPositionMilliseconds: Long = 0 + private set; + private var _historicalPosition: Long = 0; + private var _commentsCount = 0; + private var _polycentricProfile: PolycentricCache.CachedPolycentricProfile? = null; + private var _slideUpOverlay: SlideUpMenuOverlay? = null; + + //Events + val onMinimize = Event0(); + val onMaximize = Event1(); + val onClose = Event0(); + val onFullscreenChanged = Event1(); + val onEnterPictureInPicture = Event0(); + val onPlayChanged = Event1(); + + var allowBackground : Boolean = false + private set; + + val onTouchCancel = Event0(); + private var _lastPositionSaveTime: Long = -1; + + private val DP_5 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics); + private val DP_2 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, resources.displayMetrics); + private var _retryJob: Job? = null; + private var _retryCount = 0; + private val _retryIntervals: Array = arrayOf(1, 2, 4, 8, 16, 32); + + constructor(context: Context, attrs : AttributeSet? = null) : super(context, attrs) { + inflate(context, R.layout.fragview_video_detail, this); + + //Declare Views + rootView = findViewById(R.id.videodetail_root); + _cast = findViewById(R.id.videodetail_cast); + _player = findViewById(R.id.videodetail_player); + _playerProgress = findViewById(R.id.videodetail_progress); + _timeBar = _playerProgress.findViewById(com.google.android.exoplayer2.ui.R.id.exo_progress); + _title = findViewById(R.id.videodetail_title); + _subTitle = findViewById(R.id.videodetail_meta); + _platform = findViewById(R.id.videodetail_platform); + _description = findViewById(R.id.videodetail_description); + _descriptionContainer = findViewById(R.id.videodetail_description_container); + _channelName = findViewById(R.id.videodetail_channel_name); + _channelMeta = findViewById(R.id.videodetail_channel_meta); + _creatorThumbnail = findViewById(R.id.creator_thumbnail); + _channelButton = findViewById(R.id.videodetail_channel_button); + _description_viewMore = findViewById(R.id.videodetail_description_view_more); + _overlay_loading = findViewById(R.id.videodetail_loading_overlay); + _overlay_loading_spinner = findViewById(R.id.videodetail_loader); + _rating = findViewById(R.id.videodetail_rating); + _upNext = findViewById(R.id.up_next); + _textCommentType = findViewById(R.id.text_comment_type); + _toggleCommentType = findViewById(R.id.toggle_comment_type); + + _overlayContainer = findViewById(R.id.overlay_container); + _overlay_quality_container = findViewById(R.id.videodetail_quality_overview); + + _minimize_controls = findViewById(R.id.minimize_controls); + _minimize_controls_pause = findViewById(R.id.minimize_pause); + _minimize_controls_close = findViewById(R.id.minimize_close); + _minimize_controls_play = findViewById(R.id.minimize_play); + _minimize_title = findViewById(R.id.videodetail_title_minimized); + _minimize_meta = findViewById(R.id.videodetail_meta_minimized); + _buttonSubscribe = findViewById(R.id.button_subscribe); + + _container_content = findViewById(R.id.contentContainer); + _container_content_main = findViewById(R.id.videodetail_container_main); + _container_content_queue = findViewById(R.id.videodetail_container_queue); + _container_content_replies = findViewById(R.id.videodetail_container_replies); + _container_content_description = findViewById(R.id.videodetail_container_description); + _container_content_liveChat = findViewById(R.id.videodetail_container_livechat); + + _textComments = findViewById(R.id.text_comments); + _addCommentView = findViewById(R.id.add_comment_view); + _commentsList = findViewById(R.id.comments_list); + + _layoutResume = findViewById(R.id.layout_resume); + _textResume = findViewById(R.id.text_resume); + _layoutPlayerContainer = findViewById(R.id.layout_player_container); + _layoutPlayerContainer.onClick.subscribe { onMaximize.emit(false); }; + + _layoutRating = findViewById(R.id.layout_rating); + _textDislikes = findViewById(R.id.text_dislikes); + _textLikes = findViewById(R.id.text_likes); + _imageLikeIcon = findViewById(R.id.image_like_icon); + _imageDislikeIcon = findViewById(R.id.image_dislike_icon); + + _buttonSupport = findViewById(R.id.button_support); + _buttonStore = findViewById(R.id.button_store); + _layoutMonetization = findViewById(R.id.layout_monetization); + + _layoutMonetization.visibility = View.GONE; + _player.attachPlayer(); + + _container_content_liveChat.onRaidNow.subscribe { + fragment.navigate(it.targetUrl); + }; + + _buttonSupport.setOnClickListener { + val author = video?.author ?: _searchVideo?.author; + author?.let { fragment.navigate(it).selectTab(2); }; + fragment.lifecycleScope.launch { + delay(100); + fragment.minimizeVideoDetail(); + }; + }; + + _buttonStore.setOnClickListener { + _polycentricProfile?.profile?.systemState?.store?.let { + try { + val uri = Uri.parse(it); + val intent = Intent(Intent.ACTION_VIEW); + intent.data = uri; + context.startActivity(intent); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to open URI: '${it}'.", e); + } + } + }; + + StateApp.instance.preventPictureInPicture.subscribe(this) { + Logger.i(TAG, "StateApp.instance.preventPictureInPicture.subscribe preventPictureInPicture = true"); + preventPictureInPicture = true; + }; + + _addCommentView.onCommentAdded.subscribe { + _commentsList.addComment(it); + } + + _commentsList.onCommentsLoaded.subscribe { count -> + _commentsCount = count; + updateCommentType(false); + }; + + _toggleCommentType.onValueChanged.subscribe { + updateCommentType(true); + }; + + _textCommentType.setOnClickListener { + _toggleCommentType.setValue(!_toggleCommentType.value, true); + updateCommentType(true); + }; + + val layoutTop: LinearLayout = findViewById(R.id.layout_top); + _container_content_main.removeView(layoutTop); + _commentsList.setPrependedView(layoutTop); + + _buttonPins = layoutTop.findViewById(R.id.buttons_pins); + _buttonPins.alwaysShowLastButton = true; + + var buttonMore: RoundButton? = null; + buttonMore = RoundButton(context, R.drawable.ic_menu, "More", TAG_MORE) { + _slideUpOverlay = UISlideOverlays.showMoreButtonOverlay(_overlayContainer, _buttonPins, listOf(TAG_MORE)) {selected -> + _buttonPins.setButtons(*(selected + listOf(buttonMore!!)).toTypedArray()); + _buttonPinStore.set(*selected.filter { it.tagRef is String }.map{ it.tagRef as String }.toTypedArray()) + _buttonPinStore.save(); + } + }; + _buttonMore = buttonMore; + updateMoreButtons(); + + + _channelButton.setOnClickListener { + (video?.author ?: _searchVideo?.author)?.let { + fragment.navigate(it); + fragment.lifecycleScope.launch { + delay(100); + fragment.minimizeVideoDetail(); + }; + }; + }; + + _rating.visibility = View.GONE; + + _cast.onSettingsClick.subscribe { showVideoSettings() }; + _player.onVideoSettings.subscribe { showVideoSettings() }; + _player.onToggleFullScreen.subscribe(::handleFullScreen); + _cast.onMinimizeClick.subscribe { + _player.setFullScreen(false); + onMinimize.emit(); + }; + _player.onMinimize.subscribe { + _player.setFullScreen(false); + onMinimize.emit(); + }; + + _player.onTimeBarChanged.subscribe { position, _ -> + if (!_isCasting && !_didStop) { + setLastPositionMilliseconds(position, true); + } + }; + + _player.onVideoClicked.subscribe { + if(_minimizeProgress < 0.5) + onMaximize.emit(false); + } + _player.onSourceChanged.subscribe(::onSourceChanged); + _player.onSourceEnded.subscribe { + if (!fragment.isInPictureInPicture) { + _player.gestureControl.showControls(false); + } + + _player.setIsReplay(true); + + val searchVideo = StatePlayer.instance.getCurrentQueueItem(); + if (searchVideo is SerializedPlatformVideo?) { + searchVideo?.let { StatePlaylists.instance.removeFromWatchLater(it) }; + } + + nextVideo(); + }; + _player.onDatasourceError.subscribe(::onDataSourceError); + + _minimize_controls_play.setOnClickListener { handlePlay(); }; + _minimize_controls_pause.setOnClickListener { handlePause(); }; + _minimize_controls_close.setOnClickListener { onClose.emit(); }; + _minimize_title.setOnClickListener { onMaximize.emit(false) }; + _minimize_meta.setOnClickListener { onMaximize.emit(false) }; + + _player.onPlayChanged.subscribe { + if (StateCasting.instance.activeDevice == null) { + handlePlayChanged(it); + } + }; + + if (!isInEditMode) { + StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { d, connectionState -> + if (_onPauseCalled) { + return@subscribe; + } + + when (connectionState) { + CastConnectionState.CONNECTED -> { + loadCurrentVideo(lastPositionMilliseconds); + updatePillButtonVisibilities(); + setCastEnabled(true); + } + CastConnectionState.DISCONNECTED -> { + loadCurrentVideo(lastPositionMilliseconds); + updatePillButtonVisibilities(); + setCastEnabled(false); + } + else -> {} + } + }; + + updatePillButtonVisibilities(); + + StateCasting.instance.onActiveDevicePlayChanged.subscribe(this) { + if (StateCasting.instance.activeDevice != null) { + handlePlayChanged(it); + } + }; + + StateCasting.instance.onActiveDeviceTimeChanged.subscribe(this) { + if (_isCasting) { + setLastPositionMilliseconds((it * 1000.0).toLong(), true); + _cast.setTime(lastPositionMilliseconds); + _timeBar.setPosition(it.toLong()); + _timeBar.setBufferedPosition(0); + _timeBar.setDuration(video?.duration ?: 0); + } + }; + } + + _playerProgress.player = _player.exoPlayer?.player; + _playerProgress.setProgressUpdateListener { position, bufferedPosition -> + StatePlayer.instance.updateMediaSessionPlaybackState(_player.exoPlayer?.getPlaybackStateCompat() ?: PlaybackStateCompat.STATE_NONE, position); + } + + StatePlayer.instance.onQueueChanged.subscribe(this) { + if(!_destroyed) { + updateQueueState(); + StatePlayer.instance.updateMediaSession(null); + } + }; + StatePlayer.instance.onVideoChanging.subscribe(this) { + setVideoOverview(it); + }; + MediaControlReceiver.onLowerVolumeReceived.subscribe(this) { handleLowerVolume() }; + MediaControlReceiver.onPlayReceived.subscribe(this) { handlePlay() }; + MediaControlReceiver.onPauseReceived.subscribe(this) { handlePause() }; + MediaControlReceiver.onNextReceived.subscribe(this) { nextVideo() }; + MediaControlReceiver.onPreviousReceived.subscribe(this) { prevVideo() }; + MediaControlReceiver.onCloseReceived.subscribe(this) { + Logger.i(TAG, "MediaControlReceiver.onCloseReceived") + onClose.emit() + }; + MediaControlReceiver.onSeekToReceived.subscribe(this) { handleSeek(it); }; + + _container_content_description.onClose.subscribe { switchContentView(_container_content_main); }; + _container_content_liveChat.onClose.subscribe { switchContentView(_container_content_main); }; + _container_content_queue.onClose.subscribe { switchContentView(_container_content_main); }; + _container_content_replies.onClose.subscribe { switchContentView(_container_content_main); }; + + _description_viewMore.setOnClickListener { + switchContentView(_container_content_description); + }; + + _upNext.onNextItem.subscribe { + val item = StatePlayer.instance.nextQueueItem(); + if(item != null) + setVideoOverview(item, true); + }; + _upNext.onOpenQueueClick.subscribe { + _container_content_queue.updateQueue(); + switchContentView(_container_content_queue); + }; + _upNext.onRestartQueue.subscribe { + val item = StatePlayer.instance.restartQueue(); + if(item != null) + setVideoOverview(item, true); + }; + + _container_content_current = _container_content_main; + + _commentsList.onClick.subscribe { c -> + val replyCount = c.replyCount ?: 0; + var metadata = ""; + if (replyCount > 0) { + metadata += "$replyCount replies"; + } + + if (c is PolycentricPlatformComment) { + var parentComment: PolycentricPlatformComment = c; + _container_content_replies.load(_toggleCommentType.value, metadata, c.contextUrl, c.reference, + { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) }, + { + val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1); + _commentsList.replaceComment(parentComment, newComment); + parentComment = newComment; + }); + } else { + _container_content_replies.load(_toggleCommentType.value, metadata, null, null, { StatePlatform.instance.getSubComments(c) }); + } + switchContentView(_container_content_replies); + }; + + onClose.subscribe { + _lastVideoSource = null; + _lastAudioSource = null; + _lastSubtitleSource = null; + video = null; + _playbackTracker = null; + }; + + _layoutResume.setOnClickListener { + handleSeek(_historicalPosition * 1000); + + val job = _jobHideResume; + _jobHideResume = null; + job?.cancel(); + + _layoutResume.visibility = View.GONE; + }; + } + + fun updateMoreButtons() { + val buttons = listOf(RoundButton(context, R.drawable.ic_add, "Add", TAG_ADD) { + (video ?: _searchVideo)?.let { + _slideUpOverlay = UISlideOverlays.showAddToOverlay(it, _overlayContainer); + } + }, + if(video?.isLive ?: false) + RoundButton(context, R.drawable.ic_chat, "Live Chat", TAG_LIVECHAT) { + video?.let { + try { + loadLiveChat(it); + } + catch(ex: Throwable) { + Logger.e(TAG, "Failed to reopen live chat", ex); + } + } + } else null, + RoundButton(context, R.drawable.ic_screen_share, "Background", TAG_BACKGROUND) { + if(!allowBackground) { + _player.switchToAudioMode(); + allowBackground = true; + it.text.text = resources.getString(R.string.background_revert); + } + else { + _player.switchToVideoMode(); + allowBackground = false; + it.text.text = resources.getString(R.string.background); + } + }, + RoundButton(context, R.drawable.ic_download, "Download", TAG_DOWNLOAD) { + video?.let { + _slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(context.contentResolver, it, _overlayContainer); + }; + }, + RoundButton(context, R.drawable.ic_share, "Share", TAG_SHARE) { + video?.let { + Logger.i(TAG, "Share preventPictureInPicture = true"); + preventPictureInPicture = true; + shareVideo(); + }; + }, + RoundButton(context, R.drawable.ic_screen_share, "Overlay", TAG_OVERLAY) { + this.startPictureInPicture(); + fragment.forcePictureInPicture(); + //PiPActivity.startPiP(context); + }, + RoundButton(context, R.drawable.ic_export, "Page", TAG_OPEN) { + video?.let { + val url = video?.shareUrl ?: _searchVideo?.shareUrl ?: _url; + fragment.navigate(url); + fragment.minimizeVideoDetail(); + }; + }, + RoundButton(context, R.drawable.ic_refresh, "Reload", "Reload") { + reloadVideo(); + }).filterNotNull(); + if(!_buttonPinStore.getAllValues().any()) + _buttonPins.setButtons(*(buttons + listOf(_buttonMore)).toTypedArray()); + else { + val selectedButtons = _buttonPinStore.getAllValues() + .map { x-> buttons.find { it.tagRef == x } } + .filter { it != null } + .map { it!! }; + _buttonPins.setButtons(*(selectedButtons + + buttons.filter { !selectedButtons.contains(it) } + + listOf(_buttonMore)).toTypedArray()); + } + } + + fun reloadVideo() { + fragment.lifecycleScope.launch (Dispatchers.IO) { + video?.let { + Logger.i(TAG, "Reloading video"); + try { + val video = StatePlatform.instance.getContentDetails(it.url, true).await(); + if(video !is IPlatformVideoDetails) + throw IllegalStateException("Expected media content, found ${video.contentType}"); + + withContext(Dispatchers.Main) { + setVideoDetails(video); + } + } + catch(ex: Throwable) { + withContext(Dispatchers.Main) { + UIDialogs.showGeneralErrorDialog(context, ex.message ?: "", ex); + } + } + } + } + } + + + //Lifecycle + fun onResume() { + Logger.i(TAG, "onResume"); + _onPauseCalled = false; + + Logger.i(TAG, "_video: ${video?.name ?: "no video"}"); + Logger.i(TAG, "_didStop: $_didStop"); + + //Recover cancelled loads + if(video == null) { + val t = (lastPositionMilliseconds / 1000.0f).roundToLong(); + if(_searchVideo != null) + setVideoOverview(_searchVideo!!, true, t); + else if(_url != null) + setVideo(_url!!, t, _playWhenReady); + } + else if(_didStop) { + _didStop = false; + Logger.i(TAG, "loadCurrentVideo _lastPosition=${lastPositionMilliseconds}"); + loadCurrentVideo(lastPositionMilliseconds); + handlePause(); + } + + if(_player.isAudioMode) { + //Requested behavior to leave it in audio mode. leaving it commented if it causes issues, revert? + if(!allowBackground) { + _player.switchToVideoMode(); + _buttonPins.getButtonByTag(TAG_BACKGROUND)?.text?.text = resources.getString(R.string.background); + } + } + if(!_player.isFitMode && !_player.isFullScreen && !fragment.isInPictureInPicture) + _player.fitHeight(); + + _player.updateRotateLock(); + } + fun onPause() { + Logger.i(TAG, "onPause"); + + _onPauseCalled = true; + _taskLoadVideo.cancel(); + + if(StateCasting.instance.isCasting) + return; + + if(allowBackground) + StatePlayer.instance.startOrUpdateMediaSession(context, video); + else { + when (Settings.instance.playback.backgroundPlay) { + 0 -> handlePause(); + 1 -> { + if(!(video?.isLive ?: false)) + _player.switchToAudioMode(); + StatePlayer.instance.startOrUpdateMediaSession(context, video); + } + } + } + } + fun onStop() { + Logger.i(TAG, "onStop"); + _player.clear(); + StatePlayer.instance.closeMediaSession(); + _overlay_quality_selector?.hide(); + _retryJob?.cancel(); + _retryJob = null; + _taskLoadVideo.cancel(); + handleStop(); + _didStop = true; + Logger.i(TAG, "_didStop set to true"); + + StatePlayer.instance.rotationLock = false; + _player.updateRotateLock(); + Logger.i(TAG, "Stopped"); + } + fun onDestroy() { + Logger.i(TAG, "onDestroy"); + _destroyed = true; + _taskLoadVideo.cancel(); + _commentsList.cancel(); + _player.clear(); + _cast.cleanup(); + _container_content_replies.cleanup(); + _container_content_queue.cleanup(); + _container_content_description.cleanup(); + StateCasting.instance.onActiveDevicePlayChanged.remove(this); + StateCasting.instance.onActiveDeviceTimeChanged.remove(this); + StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); + StateApp.instance.preventPictureInPicture.remove(this); + StatePlayer.instance.onQueueChanged.remove(this); + StatePlayer.instance.onVideoChanging.remove(this); + MediaControlReceiver.onLowerVolumeReceived.remove(this); + MediaControlReceiver.onPlayReceived.remove(this); + MediaControlReceiver.onPauseReceived.remove(this); + MediaControlReceiver.onNextReceived.remove(this); + MediaControlReceiver.onPreviousReceived.remove(this); + MediaControlReceiver.onCloseReceived.remove(this); + MediaControlReceiver.onSeekToReceived.remove(this); + + val job = _jobHideResume; + _jobHideResume = null; + job?.cancel(); + } + + //Video Setters + fun setEmpty() { + Logger.i(TAG, "setEmpty") + + _title.text = ""; + _rating.visibility = View.GONE; + + _commentsList.clear(); + _minimize_title.text = ""; + _minimize_meta.text = ""; + _platform.clearPlatform(); + _subTitle.text = ""; + _channelName.text = ""; + _channelMeta.text = ""; + _creatorThumbnail.clear(); + setDescription("".fixHtmlWhitespace()); + _descriptionContainer.visibility = View.GONE; + _player.clear(); + _textComments.visibility = View.INVISIBLE; + _commentsList.clear(); + + _lastVideoSource = null; + _lastAudioSource = null; + _lastSubtitleSource = null; + } + fun setVideo(url: String, resumeSeconds: Long = 0, playWhenReady: Boolean = true) { + Logger.i(TAG, "setVideo url=$url resumeSeconds=$resumeSeconds playWhenReady=$playWhenReady") + + _searchVideo = null; + video = null; + _playbackTracker = null; + _url = url; + _videoResumePositionMilliseconds = resumeSeconds * 1000; + _rating.visibility = View.GONE; + _layoutRating.visibility = View.GONE; + _playWhenReady = playWhenReady; + setLastPositionMilliseconds(_videoResumePositionMilliseconds, false); + _addCommentView.setContext(null, null); + + _toggleCommentType.setValue(false, false); + _commentsList.clear(); + + setEmpty(); + + updateQueueState(); + + _retryJob?.cancel(); + _retryJob = null; + _retryCount = 0; + fetchVideo(); + + switchContentView(_container_content_main); + } + fun setVideoOverview(video: IPlatformVideo, fetch: Boolean = true, resumeSeconds: Long = 0) { + Logger.i(TAG, "setVideoOverview") + + val cachedVideo = StateDownloads.instance.getCachedVideo(video.id); + if(cachedVideo != null) { + setVideoDetails(cachedVideo, true); + return; + } + + this.video = null; + this._playbackTracker = null; + _searchVideo = video; + _videoResumePositionMilliseconds = resumeSeconds * 1000; + setLastPositionMilliseconds(_videoResumePositionMilliseconds, false); + _addCommentView.setContext(null, null); + + _toggleCommentType.setValue(false, false); + + _title.text = video.name; + _rating.visibility = View.GONE; + _layoutRating.visibility = View.GONE; + _textComments.visibility = View.VISIBLE; + + _minimize_title.text = video.name; + _minimize_meta.text = video.author.name; + + val subTitleSegments : ArrayList = ArrayList(); + if(video.viewCount > 0) + subTitleSegments.add("${video.viewCount.toHumanNumber()} ${if(video.isLive) "watching now" else "views"}"); + if(video.datetime != null) { + val diff = video.datetime?.getNowDiffSeconds() ?: 0; + val ago = video.datetime?.toHumanNowDiffString(true) + if(diff >= 0) + subTitleSegments.add("${ago} ago"); + else + subTitleSegments.add("available in ${ago}"); + } + + + _commentsList.clear(); + _platform.setPlatformFromClientID(video.id.pluginId); + _subTitle.text = subTitleSegments.joinToString(" • "); + _channelName.text = video.author.name; + _playWhenReady = true; + if(video.author.subscribers != null) { + _channelMeta.text = video.author.subscribers!!.toHumanNumber() + " subscribers"; + (_channelName.layoutParams as MarginLayoutParams).setMargins(0, (DP_5 * -1).toInt(), 0, 0); + } else { + _channelMeta.text = ""; + (_channelName.layoutParams as MarginLayoutParams).setMargins(0, (DP_2).toInt(), 0, 0); + } + setDescription("".fixHtmlWhitespace()); + _player.setMetadata(video.name, video.author.name); + + _buttonSubscribe.setSubscribeChannel(video.author.url); + + if(!_description.text.isEmpty()) + _descriptionContainer.visibility = View.VISIBLE; + else + _descriptionContainer.visibility = View.GONE; + + _creatorThumbnail.setThumbnail(video.author.thumbnail, false); + + val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(video.author.url); + if (cachedPolycentricProfile != null) { + setPolycentricProfile(cachedPolycentricProfile, animate = false); + } else { + setPolycentricProfile(null, animate = false); + _taskLoadPolycentricProfile.run(video.author.id); + } + + _player.clear(); + + _url = video.url; + + updateQueueState(); + + if(fetch) { + _lastVideoSource = null; + _lastAudioSource = null; + _lastSubtitleSource = null; + + _retryJob?.cancel(); + _retryJob = null; + _retryCount = 0; + fetchVideo(); + } + + _commentsList.clear(); + + switchContentView(_container_content_main); + } + fun setVideoDetails(videoDetail: IPlatformVideoDetails, newVideo: Boolean = false) { + Logger.i(TAG, "setVideoDetails (${videoDetail.name})") + + if (newVideo) { + _lastVideoSource = null; + _lastAudioSource = null; + _lastSubtitleSource = null; + } + + if(videoDetail.datetime != null && videoDetail.datetime!! > OffsetDateTime.now()) + UIDialogs.toast(context, "Planned in ${videoDetail.datetime?.toHumanNowDiffString(true)}") + + _player.setPlaybackRate(Settings.instance.playback.getDefaultPlaybackSpeed()); + + val video = if(videoDetail is VideoLocal) + videoDetail; + else //TODO: Update cached video if it exists with video + StateDownloads.instance.getCachedVideo(videoDetail.id) ?: videoDetail; + this.video = video; + this._playbackTracker = null; + if(video is JSVideoDetails) { + val me = this; + fragment.lifecycleScope.launch(Dispatchers.IO) { + try { + val tracker = video.getPlaybackTracker() ?: StatePlatform.instance.getPlaybackTracker(video.url); + if(me.video == video) + me._playbackTracker = tracker; + } + catch(ex: Throwable) { + fragment.lifecycleScope.launch(Dispatchers.Main) { + UIDialogs.showGeneralErrorDialog(context, "Failed to get Playback Tracker", ex); + }; + } + }; + } + + val ref = video.id.value?.let { Models.referenceFromBuffer(it.toByteArray()) }; + _addCommentView.setContext(video.url, ref); + + _player.setMetadata(video.name, video.author.name); + + _toggleCommentType.setValue(false, false); + updateCommentType(true); + + //UI + _title.text = video.name; + _channelName.text = video.author.name; + if(video.author.subscribers != null) { + _channelMeta.text = video.author.subscribers!!.toHumanNumber() + " subscribers"; + (_channelName.layoutParams as MarginLayoutParams).setMargins(0, (DP_5 * -1).toInt(), 0, 0); + } else { + _channelMeta.text = ""; + (_channelName.layoutParams as MarginLayoutParams).setMargins(0, (DP_2).toInt(), 0, 0); + } + + _minimize_title.text = video.name; + _minimize_meta.text = video.author.name; + + _buttonSubscribe.setSubscribeChannel(video.author.url); + setDescription(video.description.fixHtmlLinks()); + _creatorThumbnail.setThumbnail(video.author.thumbnail, false); + + val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(video.author.url); + if (cachedPolycentricProfile != null) { + setPolycentricProfile(cachedPolycentricProfile, animate = false); + } else { + setPolycentricProfile(null, animate = false); + _taskLoadPolycentricProfile.run(video.author.id); + } + + _platform.setPlatformFromClientID(video.id.pluginId); + val subTitleSegments : ArrayList = ArrayList(); + if(video.viewCount > 0) + subTitleSegments.add("${video.viewCount.toHumanNumber()} ${if(video.isLive) "watching now" else "views"}"); + if(video.datetime != null) { + val diff = video.datetime?.getNowDiffSeconds() ?: 0; + val ago = video.datetime?.toHumanNowDiffString(true) + if(diff >= 0) + subTitleSegments.add("${ago} ago"); + else + subTitleSegments.add("available in ${ago}"); + } + _subTitle.text = subTitleSegments.joinToString(" • "); + + _rating.onLikeDislikeUpdated.remove(this); + if (ref != null) { + _rating.visibility = View.GONE; + + fragment.lifecycleScope.launch(Dispatchers.IO) { + try { + val queryReferencesResponse = ApiMethods.getQueryReferences(PolycentricCache.SERVER, ref, null,null, + arrayListOf( + Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(ContentType.OPINION.value).setValue( + ByteString.copyFrom(Opinion.like.data)).build(), + Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(ContentType.OPINION.value).setValue( + ByteString.copyFrom(Opinion.dislike.data)).build() + ) + ); + + val likes = queryReferencesResponse.countsList[0]; + val dislikes = queryReferencesResponse.countsList[1]; + val hasLiked = StatePolycentric.instance.hasLiked(ref); + val hasDisliked = StatePolycentric.instance.hasDisliked(ref); + + withContext(Dispatchers.Main) { + _rating.visibility = View.VISIBLE; + _rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked); + _rating.onLikeDislikeUpdated.subscribe(this) { processHandle, newHasLiked, newHasDisliked -> + if (newHasLiked) { + processHandle.opinion(ref, Opinion.like); + } else if (newHasDisliked) { + processHandle.opinion(ref, Opinion.dislike); + } else { + processHandle.opinion(ref, Opinion.neutral); + } + + StateApp.instance.scopeGetter().launch(Dispatchers.IO) { + try { + processHandle.fullyBackfillServers(); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to backfill servers", e) + } + } + + StatePolycentric.instance.updateLikeMap(ref, newHasLiked, newHasDisliked) + }; + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e); + _rating.visibility = View.GONE; + } + } + } else { + _rating.visibility = View.GONE; + } + + if (video.rating != null) { + when (video.rating) { + is RatingLikeDislikes -> { + val r = video.rating as RatingLikeDislikes; + _layoutRating.visibility = View.VISIBLE; + + _textLikes.visibility = View.VISIBLE; + _imageLikeIcon.visibility = View.VISIBLE; + _textLikes.text = r.likes.toHumanNumber(); + + _imageDislikeIcon.visibility = View.VISIBLE; + _textDislikes.visibility = View.VISIBLE; + _textDislikes.text = r.dislikes.toHumanNumber(); + } + is RatingLikes -> { + val r = video.rating as RatingLikes; + _layoutRating.visibility = View.VISIBLE; + + _textLikes.visibility = View.VISIBLE; + _imageLikeIcon.visibility = View.VISIBLE; + _textLikes.text = r.likes.toHumanNumber(); + + _imageDislikeIcon.visibility = View.GONE; + _textDislikes.visibility = View.GONE; + } + else -> { + _layoutRating.visibility = View.GONE; + } + } + } else { + _layoutRating.visibility = View.GONE; + } + + //Overlay + updateQualitySourcesOverlay(video); + + setLoading(false); + + //Set Mediasource + val toResume = _videoResumePositionMilliseconds; + _videoResumePositionMilliseconds = 0; + loadCurrentVideo(toResume); + _player.setGestureSoundFactor(1.0f); + + updateQueueState(); + + _historicalPosition = StatePlaylists.instance.updateHistoryPosition(video, false, (toResume.toFloat() / 1000.0f).toLong()); + Logger.i(TAG, "Historical position: $_historicalPosition, last position: $lastPositionMilliseconds"); + if (_historicalPosition > 60 && video.duration - _historicalPosition > 5 && Math.abs(_historicalPosition - lastPositionMilliseconds / 1000) > 5.0) { + _layoutResume.visibility = View.VISIBLE; + _textResume.text = "Resume at ${_historicalPosition.toHumanTime(false)}"; + + _jobHideResume = fragment.lifecycleScope.launch(Dispatchers.Main) { + try { + delay(8000); + _layoutResume.visibility = View.GONE; + _textResume.text = ""; + } catch (e: Throwable) { + Logger.e(TAG, "Failed to set resume changes.", e); + } + } + } else { + _layoutResume.visibility = View.GONE; + _textResume.text = ""; + } + + StatePlayer.instance.startOrUpdateMediaSession(context, video); + StatePlayer.instance.setCurrentlyPlaying(video); + + + if(video.isLive && video.live != null) { + loadLiveChat(video); + } + + updateMoreButtons(); + } + fun loadLiveChat(video: IPlatformVideoDetails) { + _liveChat?.stop(); + _container_content_liveChat.cancel(); + _liveChat = null; + + fragment.lifecycleScope.launch(Dispatchers.IO) { + try { + var livePager: IPager?; + var liveChatWindow: ILiveChatWindowDescriptor?; + try { + //TODO: Create video.getLiveEvents shortcut/optimalization + livePager = StatePlatform.instance.getLiveEvents(video.url); + } catch (ex: Throwable) { + livePager = null; + UIDialogs.toast("Exception retrieving live events:\n" + ex.message); + Logger.e(TAG, "Failed to retrieve live chat events", ex); + } + try { + //TODO: Create video.getLiveChatWindow shortcut/optimalization + liveChatWindow = if(Settings.instance.playback.useLiveChatWindow) + StatePlatform.instance.getLiveChatWindow(video.url); + else null; + } + catch(ex: Throwable) { + liveChatWindow = null; + UIDialogs.toast("Exception retrieving live chat window:\n" + ex.message); + Logger.e(TAG, "Failed to retrieve live chat window", ex); + } + val liveChat = livePager?.let { + val liveChatManager = LiveChatManager(fragment.lifecycleScope, livePager, video.viewCount); + liveChatManager.start(); + return@let liveChatManager; + } + _liveChat = liveChat; + + fragment.lifecycleScope.launch(Dispatchers.Main) { + try { + _container_content_liveChat.load(fragment.lifecycleScope, liveChat, liveChatWindow, if(liveChat != null) video.viewCount else null); + switchContentView(_container_content_liveChat); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to switch content view to live chat."); + } + } + } + catch(ex: Throwable) { + Logger.e(TAG, "Failed to load live chat", ex); + + UIDialogs.toast("Live chat failed to load\n" + ex.message); + //_liveChat?.handleEvents(listOf(LiveEventComment("SYSTEM", null, "Failed to load live chat:\n" + ex.message, "#FF0000"))) + /* + fragment.lifecycleScope.launch(Dispatchers.Main) { + UIDialogs.showGeneralRetryErrorDialog(context, "Failed to load live chat", ex, { loadLiveChat(video); }); + } */ + } + } + } + + //Source Loads + private fun loadCurrentVideo(resumePositionMs: Long = 0) { + _didStop = false; + + val video = video ?: return; + + try { + val videoSource = _lastVideoSource ?: _player.getPreferredVideoSource(video, Settings.instance.playback.getCurrentPreferredQualityPixelCount()); + val audioSource = _lastAudioSource ?: _player.getPreferredAudioSource(video, Settings.instance.playback.getPrimaryLanguage(context)); + val subtitleSource = _lastSubtitleSource; + Logger.i(TAG, "loadCurrentVideo(videoSource=$videoSource, audioSource=$audioSource, subtitleSource=$subtitleSource, resumePositionMs=$resumePositionMs)") + + if(videoSource == null && audioSource == null) { + handleUnavailableVideo(); + StatePlatform.instance.clearContentDetailCache(video.url); + return; + } + + val isCasting = StateCasting.instance.isCasting + if (!isCasting) { + setCastEnabled(false); + + val thumbnail = video.thumbnails.getHQThumbnail(); + if (videoSource == null && !thumbnail.isNullOrBlank()) + Glide.with(context).asBitmap().load(thumbnail) + .into(object: CustomTarget() { + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + _player.setArtwork(BitmapDrawable(resources, resource)); + } + override fun onLoadCleared(placeholder: Drawable?) { + _player.setArtwork(null); + } + }); + else + _player.setArtwork(null); + + _player.setSource(videoSource, audioSource, _playWhenReady, false); + _player.seekTo(resumePositionMs); + } + else + loadCurrentVideoCast(video, videoSource, audioSource, subtitleSource, resumePositionMs); + + _lastVideoSource = videoSource; + _lastAudioSource = audioSource; + } + catch(ex: Throwable) { + Logger.e(TAG, "Failed to load media", ex); + UIDialogs.showGeneralErrorDialog(context, "Failed to load media", ex); + } + } + private fun loadCurrentVideoCast(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long) { + Logger.i(TAG, "loadCurrentVideoCast(video=$video, videoSource=$videoSource, audioSource=$audioSource, resumePositionMs=$resumePositionMs)") + + if(StateCasting.instance.castIfAvailable(context.contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs)) { + _cast.setVideoDetails(video, resumePositionMs / 1000); + setCastEnabled(true); + } + else throw IllegalStateException("Disconnected cast during loading"); + } + + //Events + private fun onSourceChanged(videoSource: IVideoSource?, audioSource: IAudioSource?, resume: Boolean){ + Logger.i(TAG, "onSourceChanged(videoSource=$videoSource, audioSource=$audioSource, resume=$resume)") + + if((videoSource == null || videoSource is LocalVideoSource) && (audioSource == null || audioSource is LocalAudioSource)) + UIDialogs.toast(context, "Offline Playback", false); + //If LiveStream, set to end + if(videoSource is IDashManifestSource || videoSource is IHLSManifestSource) { + if (video?.isLive == true) { + _player.seekToEnd(5000); + } + + val videoTracks = _player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_VIDEO } + val audioTracks = _player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_AUDIO } + + val videoTrackFormats = mutableListOf(); + val audioTrackFormats = mutableListOf(); + + if(videoTracks != null) { + for (i in 0 until videoTracks.mediaTrackGroup.length) + videoTrackFormats.add(videoTracks.mediaTrackGroup.getFormat(i)); + } + if(audioTracks != null) { + for (i in 0 until audioTracks.mediaTrackGroup.length) + audioTrackFormats.add(audioTracks.mediaTrackGroup.getFormat(i)); + } + + updateQualityFormatsOverlay( + videoTrackFormats.distinctBy { it.height }.sortedBy { it.height }, + audioTrackFormats.distinctBy { it.bitrate }.sortedBy { it.bitrate }); + } + } + + private var _didTriggerDatasourceError = false; + private fun onDataSourceError(exception: Throwable) { + Logger.e(TAG, "onDataSourceError", exception); + if(exception.cause != null && exception.cause is InvalidResponseCodeException && (exception.cause!! as InvalidResponseCodeException).responseCode == 403) { + val currentVideo = video + if(currentVideo == null || currentVideo !is IPluginSourced) + return; + val config = currentVideo.sourceConfig; + + if(!_didTriggerDatasourceError) { + _didTriggerDatasourceError = true; + + UIDialogs.showDialog(context, R.drawable.ic_error_pred, + "Media Error", + "The media source encountered an unauthorized error.\nThis might be solved by a plugin reload.\nWould you like to reload?\n(Experimental)", + null, + 0, + UIDialogs.Action("No", { _didTriggerDatasourceError = false }), + UIDialogs.Action("Yes", { + fragment.lifecycleScope.launch(Dispatchers.IO) { + try { + StatePlatform.instance.reloadClient(context, config.id); + reloadVideo(); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to reload video.", e) + } + } + }, UIDialogs.ActionStyle.PRIMARY) + ); + } + } + } + + override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean { + if (ev?.actionMasked == MotionEvent.ACTION_CANCEL || + ev?.actionMasked == MotionEvent.ACTION_POINTER_DOWN || + ev?.actionMasked == MotionEvent.ACTION_POINTER_UP) { + onTouchCancel.emit(); + } + + return super.onInterceptTouchEvent(ev); + } + + + //Actions + private fun showVideoSettings() { + Logger.i(TAG, "showVideoSettings") + _overlay_quality_selector?.selectOption("video", _lastVideoSource); + _overlay_quality_selector?.selectOption("audio", _lastAudioSource); + _overlay_quality_selector?.selectOption("subtitles", _lastSubtitleSource); + _overlay_quality_selector?.show(); + } + + fun prevVideo() { + Logger.i(TAG, "prevVideo") + val next = StatePlayer.instance.prevQueueItem(_player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9); + if(next != null) { + setVideoOverview(next); + } + } + + fun nextVideo(): Boolean { + Logger.i(TAG, "nextVideo") + val next = StatePlayer.instance.nextQueueItem(_player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9); + if(next != null) { + setVideoOverview(next); + return true; + } + else + StatePlayer.instance.setCurrentlyPlaying(null); + return false; + } + + //Quality Selector data + private fun updateQualityFormatsOverlay(liveStreamVideoFormats : List?, liveStreamAudioFormats : List?) { + val v = video ?: return; + updateQualitySourcesOverlay(v, liveStreamVideoFormats, liveStreamAudioFormats); + } + private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, liveStreamVideoFormats: List? = null, liveStreamAudioFormats: List? = null) { + Logger.i(TAG, "updateQualitySourcesOverlay"); + + val video: IPlatformVideoDetails?; + val localVideoSources: List?; + val localAudioSource: List?; + val localSubtitleSources: List?; + + if(videoDetails is VideoLocal) { + video = videoDetails.videoSerialized; + localVideoSources = videoDetails.videoSource.toList(); + localAudioSource = videoDetails.audioSource.toList(); + localSubtitleSources = videoDetails.subtitlesSources.toList(); + } + else { + video = videoDetails; + localVideoSources = null; + localAudioSource = null; + localSubtitleSources = null; + } + + val videoSources = video?.video?.videoSources?.toList(); + val audioSources = if(video?.video?.isUnMuxed == true) + (video.video as VideoUnMuxedSourceDescriptor).audioSources.toList() + else null + + val bestVideoSources = videoSources?.map { it.height * it.width } + ?.distinct() + ?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) } + ?.filter { it != null } + ?.toList() ?: listOf(); + val bestAudioContainer = audioSources?.let { VideoHelper.selectBestAudioSource(it, FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS)?.container }; + val bestAudioSources = audioSources + ?.filter { it.container == bestAudioContainer } + ?.toList() ?: listOf(); + + _overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, "Quality", null, true, + if (!_isCasting && video?.isLive != true) SlideUpMenuTitle(this.context).apply { setTitle("Playback Rate") } else null, + if (!_isCasting && video?.isLive != true) SlideUpMenuButtonList(this.context).apply { + setButtons(listOf("0.25", "0.5", "0.75", "1.0", "1.25", "1.5", "1.75", "2.0", "2.25"), _player.getPlaybackRate().toString()); + onClick.subscribe { v -> + if (_isCasting) { + return@subscribe; + } + + _player.setPlaybackRate(v.toFloat()); + setSelected(v); + }; + } else null, + + if(localVideoSources?.isNotEmpty() == true) + SlideUpMenuGroup(this.context, "Offline Video", "video", + *localVideoSources.stream() + .map { + SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, "${it.width}x${it.height}", it, + { handleSelectVideoTrack(it) }); + }.toList().toTypedArray()) + else null, + if(localAudioSource?.isNotEmpty() == true) + SlideUpMenuGroup(this.context, "Offline Audio", "audio", + *localAudioSource.stream() + .map { + SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it, + { handleSelectAudioTrack(it) }); + }.toList().toTypedArray()) + else null, + if(localSubtitleSources?.isNotEmpty() == true) + SlideUpMenuGroup(this.context, "Offline Subtitles", "subtitles", + *localSubtitleSources + .map { + SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", it, + { handleSelectSubtitleTrack(it) }) + }.toList().toTypedArray()) + else null, + if(liveStreamVideoFormats?.isEmpty() == false) + SlideUpMenuGroup(this.context, "Stream Video", "video", + *liveStreamVideoFormats.stream() + .map { + SlideUpMenuItem(this.context, R.drawable.ic_movie, it?.label ?: it.containerMimeType ?: it.bitrate.toString(), "${it.width}x${it.height}", it, + { _player.selectVideoTrack(it.height) }); + }.toList().toTypedArray()) + else null, + if(liveStreamAudioFormats?.isEmpty() == false) + SlideUpMenuGroup(this.context, "Stream Audio", "audio", + *liveStreamAudioFormats.stream() + .map { + SlideUpMenuItem(this.context, R.drawable.ic_music, "${it?.label ?: it.containerMimeType} ${it.bitrate}", "", it, + { _player.selectAudioTrack(it.bitrate) }); + }.toList().toTypedArray()) + else null, + + if(bestVideoSources.isNotEmpty()) + SlideUpMenuGroup(this.context, "Video", "video", + *bestVideoSources.stream() + .map { + SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", it, + { handleSelectVideoTrack(it) }); + }.toList().toTypedArray()) + else null, + if(bestAudioSources.isNotEmpty()) + SlideUpMenuGroup(this.context, "Audio", "audio", + *bestAudioSources.stream() + .map { + SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it, + { handleSelectAudioTrack(it) }); + }.toList().toTypedArray()) + else null, + if(video?.subtitles?.isNotEmpty() ?: false && video != null) + SlideUpMenuGroup(this.context, "Subtitles", "subtitles", + *video.subtitles + .map { + SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", it, + { handleSelectSubtitleTrack(it) }) + }.toList().toTypedArray()) + else null); + } + + private fun updateQueueState() { + _upNext.update(); + } + + //Handlers + private fun handlePlay() { + Logger.i(TAG, "handlePlay") + if (!StateCasting.instance.resumeVideo()) { + _player.play(); + } + + //TODO: This was needed because handleLowerVolume was done. + //_player.setVolume(1.0f); + } + + private fun handleLowerVolume() { + Logger.i(TAG, "handleLowerVolume") + //TODO: This seems to be handled by OS? + //_player.setVolume(0.2f); + } + + private fun handlePause() { + Logger.i(TAG, "handlePause") + if (!StateCasting.instance.pauseVideo()) { + _player.pause(); + } + } + private fun handleSeek(ms: Long) { + Logger.i(TAG, "handleSeek(ms=$ms)") + if (!StateCasting.instance.videoSeekTo(ms.toDouble() / 1000.0)) { + _player.seekTo(ms); + } + } + private fun handleStop() { + Logger.i(TAG, "handleStop") + if (!StateCasting.instance.stopVideo()) { + _player.stop(); + } + } + + private fun handlePlayChanged(playing: Boolean) { + Logger.i(TAG, "handlePlayChanged(playing=$playing)") + + val ad = StateCasting.instance.activeDevice; + if (ad != null) { + _cast.setIsPlaying(playing); + } else { + StatePlayer.instance.updateMediaSession( null); + StatePlayer.instance.updateMediaSessionPlaybackState(_player.exoPlayer?.getPlaybackStateCompat() ?: PlaybackStateCompat.STATE_NONE, _player.exoPlayer?.player?.currentPosition ?: 0); + } + + if(playing) { + _minimize_controls_pause.visibility = View.VISIBLE; + _minimize_controls_play.visibility = View.GONE; + } + else { + _minimize_controls_pause.visibility = View.GONE; + _minimize_controls_play.visibility = View.VISIBLE; + } + + onPlayChanged.emit(playing); + updateTracker(_player.position, playing, true); + } + + private fun handleSelectVideoTrack(videoSource: IVideoSource) { + Logger.i(TAG, "handleSelectAudioTrack(videoSource=$videoSource)") + val video = video ?: return; + + if(_lastVideoSource == videoSource) + return; + + val d = StateCasting.instance.activeDevice; + if (d != null && d.connectionState == CastConnectionState.CONNECTED) + StateCasting.instance.castIfAvailable(context.contentResolver, video, videoSource, _lastAudioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong()); + else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true)) + _player.hideControls(false); //TODO: Disable player? + + _lastVideoSource = videoSource; + } + private fun handleSelectAudioTrack(audioSource: IAudioSource) { + Logger.i(TAG, "handleSelectAudioTrack(audioSource=$audioSource)") + val video = video ?: return; + + if(_lastAudioSource == audioSource) + return; + + val d = StateCasting.instance.activeDevice; + if (d != null && d.connectionState == CastConnectionState.CONNECTED) + StateCasting.instance.castIfAvailable(context.contentResolver, video, _lastVideoSource, audioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong()); + else(!_player.swapSources(_lastVideoSource, audioSource, true, true, true)) + _player.hideControls(false); //TODO: Disable player? + + _lastAudioSource = audioSource; + } + private fun handleSelectSubtitleTrack(subtitleSource: ISubtitleSource) { + Logger.i(TAG, "handleSelectSubtitleTrack(subtitleSource=$subtitleSource)") + val video = video ?: return; + + var toSet: ISubtitleSource? = subtitleSource + if(_lastSubtitleSource == subtitleSource) + toSet = null; + + val d = StateCasting.instance.activeDevice; + if (d != null && d.connectionState == CastConnectionState.CONNECTED) + StateCasting.instance.castIfAvailable(context.contentResolver, video, _lastVideoSource, _lastAudioSource, toSet, (d.expectedCurrentTime * 1000.0).toLong()); + else + _player.swapSubtitles(fragment.lifecycleScope, toSet); + + _lastSubtitleSource = toSet; + } + + private fun handleUnavailableVideo() { + if (!nextVideo()) { + if(video?.datetime == null || video?.datetime!! < OffsetDateTime.now().minusHours(1)) + UIDialogs.showDialog(context, R.drawable.ic_lock, "Unavailable video", "This video is unavailable.", null, 0, + UIDialogs.Action("Back", { + this@VideoDetailView.onClose.emit(); + }, UIDialogs.ActionStyle.PRIMARY)); + } else { + StateAnnouncement.instance.registerAnnouncement(video?.id?.value + "_Q_UNAVAILABLE", "Unavailable video", "There was an unavailable video in your queue [${video?.name}] by [${video?.author?.name}].", AnnouncementType.SESSION) + } + + video?.let { StatePlatform.instance.clearContentDetailCache(it.url) }; + } + + + //Fetch + private fun fetchComments() { + Logger.i(TAG, "fetchComments") + video?.let { + _commentsList.load(true) { StatePlatform.instance.getComments(it); }; + } + } + private fun fetchPolycentricComments() { + Logger.i(TAG, "fetchPolycentricComments") + val video = video; + val idValue = video?.id?.value + if (idValue == null) { + Logger.w(TAG, "Failed to fetch polycentric comments because id was null") + _commentsList.clear() + return + } + + _commentsList.load(false) { StatePolycentric.instance.getCommentPager(video.url, Models.referenceFromBuffer(idValue.toByteArray())); }; + } + private fun fetchVideo() { + Logger.i(TAG, "fetchVideo") + video = null; + _playbackTracker = null; + + val url = _url; + if (url != null && url.isNotBlank()) { + setLoading(true); + _taskLoadVideo.run(url); + } + } + + private fun handleFullScreen(fullscreen : Boolean) { + Logger.i(TAG, "handleFullScreen(fullscreen=$fullscreen)") + + if(fullscreen) { + _layoutPlayerContainer.setPadding(0, 0, 0, 0); + + val lp = _container_content.layoutParams as ConstraintLayout.LayoutParams; + lp.topMargin = 0; + _container_content.layoutParams = lp; + + this._player.layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ); + setProgressBarOverlayed(null); + } + else { + _layoutPlayerContainer.setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, Resources.getSystem().displayMetrics).toInt()); + + val lp = _container_content.layoutParams as ConstraintLayout.LayoutParams; + lp.topMargin = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, -18.0f, Resources.getSystem().displayMetrics).toInt(); + _container_content.layoutParams = lp; + + this._player.layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ) + setProgressBarOverlayed(false); + } + onFullscreenChanged.emit(fullscreen); + } + + private fun setCastEnabled(isCasting: Boolean) { + Logger.i(TAG, "setCastEnabled(isCasting=$isCasting)") + _player.setPlaybackRate(Settings.instance.playback.getDefaultPlaybackSpeed()); + video?.let { updateQualitySourcesOverlay(it); }; + + _isCasting = isCasting; + + if(isCasting) { + _player.stop(); + _player.hideControls(false); + _cast.visibility = View.VISIBLE; + } + else { + StateCasting.instance.stopVideo(); + _cast.stopTimeJob(); + _cast.visibility = View.GONE; + } + } + + fun setFullscreen(fullscreen : Boolean) { + Logger.i(TAG, "setFullscreen(fullscreen=$fullscreen)") + _player.setFullScreen(fullscreen); + } + private fun setLoading(isLoading : Boolean) { + if(isLoading){ + (_overlay_loading_spinner.drawable as Animatable?)?.start() + _overlay_loading.visibility = View.VISIBLE; + } + else { + _overlay_loading.visibility = View.GONE; + (_overlay_loading_spinner.drawable as Animatable?)?.stop() + } + } + + //UI Actions + private fun setDescription(text: Spanned) { + _container_content_description.load(text); + _description.text = text; + + if (_description.text.isNotEmpty()) + _descriptionContainer.visibility = View.VISIBLE; + else + _descriptionContainer.visibility = View.GONE; + } + fun onBackPressed(): Boolean { + val slideUpOverlay = _slideUpOverlay; + if (slideUpOverlay != null) { + if (slideUpOverlay.isVisible) { + slideUpOverlay.hide(); + return true; + } else { + _slideUpOverlay = null; + } + } + + if (_container_content_current != _container_content_main) { + switchContentView(_container_content_main); + return true; + } + + return false; + } + private fun switchContentView(view: View) { + val curView = _container_content_current; + if (curView == view) + return; + + val animHeight = _container_content.height; + + + if(view == _container_content_main) { + curView.elevation = 2f; + view.elevation = 1f; + view.visibility = VISIBLE; + + curView.animate() + .setDuration(300) + .translationY(animHeight.toFloat()) + .withEndAction { + curView.visibility = GONE; + _container_content_current = view; + } + .start(); + } + else { + curView.elevation = 1f; + view.elevation = 2f; + view.translationY = animHeight.toFloat(); + view.visibility = VISIBLE; + + view.animate() + .setDuration(300) + .translationY(0f) + .withEndAction { + curView.visibility = GONE; + _container_content_current = view; + } + .start(); + } + } + + //TODO: Make pill buttons dynamic instead of visiblity + private fun updatePillButtonVisibilities() { + _buttonPins.setButtonVisibility { + (it.tagRef != TAG_BACKGROUND && it.tagRef != TAG_OVERLAY) || !_isCasting + }; + } + + private fun updateCommentType(reloadComments: Boolean) { + if (_toggleCommentType.value) { + _textCommentType.text = "Platform"; + _addCommentView.visibility = View.GONE; + + if (reloadComments) { + fetchComments(); + } + } else { + _textCommentType.text = "Polycentric"; + _addCommentView.visibility = View.VISIBLE; + + if (reloadComments) { + fetchPolycentricComments() + } + } + } + + + //Picture2Picture + fun startPictureInPicture() { + Logger.i(TAG, "startPictureInPicture") + + UIDialogs.dismissAllDialogs(); + onMaximize.emit(true); + onEnterPictureInPicture.emit(); + _player.hideControls(false); + _layoutResume.visibility = View.GONE; + } + fun handleEnterPictureInPicture() { + Logger.i(TAG, "handleEnterPictureInPicture"); + + _overlayContainer.removeAllViews(); + _overlay_quality_selector?.hide(); + + _player.fillHeight(); + _layoutPlayerContainer.setPadding(0, 0, 0, 0); + } + fun handleLeavePictureInPicture() { + Logger.i(TAG, "handleLeavePictureInPicture") + + if(!_player.isFullScreen) { + _player.fitHeight(); + _layoutPlayerContainer.setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, Resources.getSystem().displayMetrics).toInt()); + } else { + _layoutPlayerContainer.setPadding(0, 0, 0, 0); + } + } + fun getPictureInPictureParams() : PictureInPictureParams { + var videoSourceWidth = _player.exoPlayer?.player?.videoSize?.width ?: 0; + var videoSourceHeight = _player.exoPlayer?.player?.videoSize?.height ?: 0; + + if(videoSourceWidth == 0 || videoSourceHeight == 0) { + videoSourceWidth = 16; + videoSourceHeight = 9; + } + val aspectRatio = videoSourceWidth.toDouble() / videoSourceHeight; + if(aspectRatio > 3) { + videoSourceWidth = 16; + videoSourceHeight = 9; + } + else if(aspectRatio < 0.3) { + videoSourceHeight = 16; + videoSourceWidth = 9; + } + + val r = Rect(); + _player.getGlobalVisibleRect(r); + r.right = r.right - _player.paddingEnd; + val playpauseAction = if(_player.playing) + RemoteAction(Icon.createWithResource(context, R.drawable.ic_pause_notif), "Pause", "Pauses the video", MediaControlReceiver.getPauseIntent(context, 5)); + else + RemoteAction(Icon.createWithResource(context, R.drawable.ic_play_notif), "Play", "Resumes the video", MediaControlReceiver.getPlayIntent(context, 6)); + + return PictureInPictureParams.Builder() + .setAspectRatio(Rational(videoSourceWidth, videoSourceHeight)) + .setSourceRectHint(r) + .setActions(listOf(playpauseAction)) + .build(); + } + + //Other + private fun shareVideo() { + Logger.i(TAG, "shareVideo") + + val url = video?.shareUrl ?: _searchVideo?.shareUrl ?: _url; + fragment.startActivity(Intent.createChooser(Intent().apply { + action = Intent.ACTION_SEND; + putExtra(Intent.EXTRA_TEXT, url); + type = "text/plain"; //TODO: Determine alt types? + }, null)); + } + + private fun setLastPositionMilliseconds(positionMilliseconds: Long, updateHistory: Boolean) { + lastPositionMilliseconds = positionMilliseconds; + + val v = video ?: return; + val currentTime = System.currentTimeMillis(); + if (updateHistory && (_lastPositionSaveTime == -1L || currentTime - _lastPositionSaveTime > 5000)) { + StatePlaylists.instance.updateHistoryPosition(v, true, (positionMilliseconds.toFloat() / 1000.0f).toLong()); + _lastPositionSaveTime = currentTime; + } + updateTracker(positionMilliseconds, _player.playing, false); + } + + private fun updateTracker(positionMs: Long, isPlaying: Boolean, forceUpdate: Boolean = false) { + val playbackTracker = _playbackTracker ?: return; + val shouldUpdate = playbackTracker.shouldUpdate() || forceUpdate; + if (!shouldUpdate) { + return; + } + + fragment.lifecycleScope.launch(Dispatchers.IO) { + playbackTracker.onProgress(positionMs.toDouble() / 1000, isPlaying); + } + } + + //Animation related setters + fun setMinimizeProgress(progress : Float) { + _minimizeProgress = progress; + _player.lockControlsAlpha(progress < 0.9); + _layoutPlayerContainer.shouldInterceptTouches = progress < 0.95; + + if(progress > 0.9) { + if(_minimize_controls.visibility != View.GONE) + _minimize_controls.visibility = View.GONE; + } + else if(_minimize_controls.visibility != View.VISIBLE) { + _minimize_controls.visibility = View.VISIBLE; + } + + //Switching video to fill + if(progress > 0.25) { + if(!_player.isFullScreen && _player.layoutParams.height != WRAP_CONTENT) { + _player.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT); + if(!fragment.isInPictureInPicture) { + _player.fitHeight(); + _layoutPlayerContainer.setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, Resources.getSystem().displayMetrics).toInt()); + } + else { + _layoutPlayerContainer.setPadding(0, 0, 0, 0); + } + _cast.layoutParams = _cast.layoutParams.apply { + (this as MarginLayoutParams).bottomMargin = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, resources.displayMetrics).toInt(); + }; + setProgressBarOverlayed(false); + _player.hideControls(false); + } + } + else { + if(_player.layoutParams.height == WRAP_CONTENT) { + _player.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT); + _player.fillHeight(); + _cast.layoutParams = _cast.layoutParams.apply { + (this as MarginLayoutParams).bottomMargin = 0; + }; + setProgressBarOverlayed(true); + _player.hideControls(false); + + _layoutPlayerContainer.setPadding(0, 0, 0, 0); + } + } + } + + private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) { + _polycentricProfile = cachedPolycentricProfile; + + if (cachedPolycentricProfile?.profile == null) { + _layoutMonetization.visibility = View.GONE; + _creatorThumbnail.setHarborAvailable(false, animate); + return; + } + + _layoutMonetization.visibility = View.VISIBLE; + _creatorThumbnail.setHarborAvailable(true, animate); + } + + fun setProgressBarOverlayed(isOverlayed: Boolean?) { + Logger.i(TAG, "setProgressBarOverlayed(isOverlayed: ${isOverlayed ?: "null"})"); + isOverlayed?.let{ _cast.setProgressBarOverlayed(it) }; + + if(isOverlayed == null) { + //For now this seems to be the best way to keep it updated? + _playerProgress.layoutParams = _playerProgress.layoutParams.apply { + (this as MarginLayoutParams).bottomMargin = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, -12f, resources.displayMetrics).toInt(); + }; + _playerProgress.elevation = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, resources.displayMetrics); + } + else if(isOverlayed) { + _playerProgress.layoutParams = _playerProgress.layoutParams.apply { + (this as MarginLayoutParams).bottomMargin = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, -6f, resources.displayMetrics).toInt(); + }; + _playerProgress.elevation = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics); + } + else { + _playerProgress.layoutParams = _playerProgress.layoutParams.apply { + (this as MarginLayoutParams).bottomMargin = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6f, resources.displayMetrics).toInt(); + }; + _playerProgress.elevation = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, resources.displayMetrics); + } + } + fun setContentAlpha(alpha: Float) { + _container_content.alpha = alpha; + } + fun setControllerAlpha(alpha: Float) { + _layoutResume.alpha = alpha; + _player.videoControls.alpha = alpha; + _cast.setButtonAlpha(alpha); + } + fun setMinimizeControlsAlpha(alpha : Float) { + _minimize_controls.alpha = alpha; + val clickable = alpha > 0.9; + if(_minimize_controls.isClickable != clickable) + _minimize_controls.isClickable = clickable; + } + fun setVideoMinimize(value : Float) { + val padRight = (resources.displayMetrics.widthPixels * 0.70 * value).toInt(); + _player.setPadding(0, _player.paddingTop, padRight, 0); + _cast.setPadding(0, _cast.paddingTop, padRight, 0); + } + fun setTopPadding(value : Float) { + _player.setPadding(0, value.toInt(), _player.paddingRight, 0); + } + + //Tasks + private val _taskLoadVideo = if(!isInEditMode) TaskHandler( + StateApp.instance.scopeGetter, + { + val result = StatePlatform.instance.getContentDetails(it).await(); + if(result !is IPlatformVideoDetails) + throw IllegalStateException("Expected media content, found ${result.contentType}"); + return@TaskHandler result; + }) + .success { setVideoDetails(it, true) } + .exception { + Logger.w(TAG, "exception", it) + + if (!nextVideo()) { + UIDialogs.showDialog(context, + R.drawable.ic_sources, + "No source enabled to support this video\n(${_url})", null, null, + 0, + UIDialogs.Action("Back", { + this@VideoDetailView.onClose.emit(); + }, UIDialogs.ActionStyle.PRIMARY) + ); + } else { + StateAnnouncement.instance.registerAnnouncement(video?.id?.value + "_Q_NOSOURCES", "Video without source", "There was a in your queue [${video?.name}] by [${video?.author?.name}] without the required source being enabled, playback was skipped.", AnnouncementType.SESSION) + } + } + .exception { + Logger.w(TAG, "exception", it) + + if (!nextVideo()) { + UIDialogs.showSingleButtonDialog(context, + R.drawable.ic_schedule, + "Video is available in ${it.availableWhen}.", + "Back", { + this@VideoDetailView.onClose.emit(); + }); + } + } + .exception { + Logger.w(TAG, "exception", it) + + if (!nextVideo()) { + UIDialogs.showGeneralRetryErrorDialog(context, "Failed to load video (ScriptImplementationException)", it, ::fetchVideo); + } else { + StateAnnouncement.instance.registerAnnouncement(video?.id?.value + "_Q_INVALIDVIDEO", "Invalid video", "There was an invalid video in your queue [${video?.name}] by [${video?.author?.name}], playback was skipped.", AnnouncementType.SESSION) + } + } + .exception { + Logger.w(TAG, "exception", it) + + if (!nextVideo()) { + UIDialogs.showDialog(context, + R.drawable.ic_lock, + "Age restricted video", + it.message, null, 0, + UIDialogs.Action("Back", { + this@VideoDetailView.onClose.emit(); + }, UIDialogs.ActionStyle.PRIMARY)); + } else { + StateAnnouncement.instance.registerAnnouncement(video?.id?.value + "_Q_AGERESTRICT", "Age restricted video", "There was an age restricted video in your queue [${video?.name}] by [${video?.author?.name}], this video was not accessible and playback was skipped.", AnnouncementType.SESSION) + } + } + .exception { + Logger.w(TAG, "exception", it); + handleUnavailableVideo(); + } + .exception { + Logger.w(TAG, "exception", it) + + handleErrorOrCall { + _retryCount = 0; + _retryJob?.cancel(); + _retryJob = null; + UIDialogs.showGeneralRetryErrorDialog(context, "Failed to load video (ScriptException)", it, ::fetchVideo); + } + } + .exception { + Logger.w(ChannelFragment.TAG, "Failed to load video.", it); + + handleErrorOrCall { + _retryCount = 0; + _retryJob?.cancel(); + _retryJob = null; + UIDialogs.showGeneralRetryErrorDialog(context, "Failed to load video", it, ::fetchVideo); + } + } else TaskHandler(IPlatformVideoDetails::class.java, {fragment.lifecycleScope}); + + private val _taskLoadPolycentricProfile = TaskHandler(StateApp.instance.scopeGetter, { PolycentricCache.instance.getProfileAsync(it) }) + .success { it -> setPolycentricProfile(it, animate = true) } + .exception { + Logger.w(TAG, "Failed to load claims.", it); + }; + + private fun handleErrorOrCall(action: () -> Unit) { + val isConnected = StateApp.instance.getCurrentNetworkState() != StateApp.NetworkState.DISCONNECTED; + + if (_retryCount < _retryIntervals.size) { + Log.i(TAG, "handleErrorOrCall _retryCount=$_retryCount, starting retry job"); + + _retryJob?.cancel(); + _retryJob = StateApp.instance.scopeGetter().launch(Dispatchers.Main) { + try { + delay(_retryIntervals[_retryCount++] * 1000); + fetchVideo(); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to fetch video.", e) + } + } + } else if (isConnected && nextVideo()) { + Log.i(TAG, "handleErrorOrCall retries failed, is connected, skipped to next video"); + } else { + Log.i(TAG, "handleErrorOrCall retries failed, no video to skip to, called action"); + action(); + } + } + + fun applyFragment(frag: VideoDetailFragment) { + fragment = frag; + fragment.onMinimize.subscribe { + _liveChat?.stop(); + _container_content_liveChat.close(); + } + } + + + companion object { + const val TAG_ADD = "add"; + const val TAG_BACKGROUND = "background"; + const val TAG_DOWNLOAD = "download"; + const val TAG_SHARE = "share"; + const val TAG_OVERLAY = "overlay"; + const val TAG_LIVECHAT = "livechat"; + const val TAG_OPEN = "open"; + const val TAG_MORE = "MORE"; + + private val _buttonPinStore = FragmentedStorage.get("videoPinnedButtons"); + + + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt new file mode 100644 index 00000000..26bc3f79 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt @@ -0,0 +1,139 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.graphics.drawable.Animatable +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.images.GlideHelper.Companion.crossfade +import com.futo.platformplayer.views.lists.VideoListEditorView + +abstract class VideoListEditorView : LinearLayout { + private var _videoListEditorView: VideoListEditorView; + private var _imagePlaylistThumbnail: ImageView; + private var _textName: TextView; + private var _textMetadata: TextView; + private var _loaderOverlay: FrameLayout; + private var _imageLoader: ImageView; + protected var overlayContainer: FrameLayout + private set; + protected var _buttonDownload: ImageButton; + private var _buttonShare: ImageButton; + private var _buttonEdit: ImageButton; + + private var _onShare: (()->Unit)? = null; + + constructor(inflater: LayoutInflater) : super(inflater.context) { + inflater.inflate(R.layout.fragment_video_list_editor, this); + + val videoListEditorView = findViewById(R.id.video_list_editor); + _textName = findViewById(R.id.text_name); + _textMetadata = findViewById(R.id.text_metadata); + _imagePlaylistThumbnail = findViewById(R.id.image_playlist_thumbnail); + _loaderOverlay = findViewById(R.id.layout_loading_overlay); + _imageLoader = findViewById(R.id.image_loader); + overlayContainer = findViewById(R.id.overlay_container); + val buttonPlayAll = findViewById(R.id.button_play_all); + val buttonShuffle = findViewById(R.id.button_shuffle); + _buttonEdit = findViewById(R.id.button_edit); + _buttonDownload = findViewById(R.id.button_download); + _buttonDownload.visibility = View.GONE; + + _buttonShare = findViewById(R.id.button_share); + val onShare = _onShare; + if(onShare != null) { + _buttonShare.setOnClickListener { onShare.invoke() }; + _buttonShare.visibility = View.VISIBLE; + } + else + _buttonShare?.visibility = View.GONE; + + buttonPlayAll.setOnClickListener { onPlayAllClick(); }; + buttonShuffle.setOnClickListener { onShuffleClick(); }; + + if (canEdit()) + _buttonEdit.setOnClickListener { onEditClick(); }; + else + _buttonEdit.visibility = View.GONE; + + videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged); + videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved); + videoListEditorView.onVideoClicked.subscribe(::onVideoClicked); + + _videoListEditorView = videoListEditorView; + } + + fun setOnShare(onShare: (()-> Unit)? = null) { + _onShare = onShare; + _buttonShare.setOnClickListener { + onShare?.invoke(); + }; + _buttonShare.visibility = View.VISIBLE; + } + + open fun canEdit(): Boolean { return false; } + open fun onPlayAllClick() { } + open fun onShuffleClick() { } + open fun onEditClick() { } + open fun onVideoRemoved(video: IPlatformVideo) {} + open fun onVideoOrderChanged(videos : List) {} + open fun onVideoClicked(video: IPlatformVideo) { + + } + + + protected fun setName(name: String?) { + _textName.text = name ?: ""; + } + + protected fun setVideoCount(videoCount: Int = -1) { + _textMetadata.text = if (videoCount == -1) "" else "${videoCount} videos"; + } + + protected fun setVideos(videos: List?, canEdit: Boolean) { + if (videos != null && videos.isNotEmpty()) { + val video = videos.first(); + _imagePlaylistThumbnail.let { + Glide.with(it) + .load(video.thumbnails.getHQThumbnail()) + .placeholder(R.drawable.placeholder_video_thumbnail) + .crossfade() + .into(it); + }; + } else { + _textMetadata.text = "0 videos"; + if(_imagePlaylistThumbnail != null) { + Glide.with(_imagePlaylistThumbnail) + .load(R.drawable.placeholder_video_thumbnail) + .into(_imagePlaylistThumbnail); + } + } + + _videoListEditorView.setVideos(videos, canEdit); + } + + protected fun setButtonDownloadVisible(isVisible: Boolean) { + _buttonDownload.visibility = if (isVisible) View.VISIBLE else View.GONE; + } + + protected fun setButtonEditVisible(isVisible: Boolean) { + _buttonEdit.visibility = if (isVisible) View.VISIBLE else View.GONE; + } + + protected fun setLoading(isLoading: Boolean) { + if(isLoading){ + (_imageLoader.drawable as Animatable?)?.start() + _loaderOverlay.visibility = View.VISIBLE; + } + else { + _loaderOverlay.visibility = View.GONE; + (_imageLoader.drawable as Animatable?)?.stop() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WatchLaterFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WatchLaterFragment.kt new file mode 100644 index 00000000..9b1e847a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WatchLaterFragment.kt @@ -0,0 +1,81 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo + +class WatchLaterFragment : MainFragment() { + override val isMainView : Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var _view: WatchLaterView? = null; + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + _view?.onShown(parameter, isBack); + } + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = WatchLaterView(this, inflater); + _view = view; + return view; + } + + override fun onDestroyMainView() { + super.onDestroyMainView(); + _view = null; + } + + @SuppressLint("ViewConstructor") + class WatchLaterView : VideoListEditorView { + private val _fragment: WatchLaterFragment; + + constructor(fragment: WatchLaterFragment, inflater: LayoutInflater) : super(inflater) { + _fragment = fragment; + + } + + fun onShown(parameter: Any ?, isBack: Boolean) { + setName("Watch Later"); + setVideos(StatePlaylists.instance.getWatchLater(), true); + } + + override fun onPlayAllClick() { + StatePlayer.instance.setQueue(StatePlaylists.instance.getWatchLater(), StatePlayer.TYPE_WATCHLATER, focus = true); + } + + override fun onShuffleClick() { + StatePlayer.instance.setQueue(StatePlaylists.instance.getWatchLater(), StatePlayer.TYPE_WATCHLATER, focus = true, shuffle = true); + } + + override fun onVideoOrderChanged(videos: List) { + StatePlaylists.instance.updateWatchLater(ArrayList(videos.map { it as SerializedPlatformVideo })); + } + override fun onVideoRemoved(video: IPlatformVideo) { + if (video is SerializedPlatformVideo) { + StatePlaylists.instance.removeFromWatchLater(video); + } + } + + override fun onVideoClicked(video: IPlatformVideo) { + val watchLater = StatePlaylists.instance.getWatchLater(); + val index = watchLater.indexOf(video); + if (index == -1) { + return; + } + + StatePlayer.instance.setQueueWithPosition(watchLater, StatePlayer.TYPE_WATCHLATER, index, focus = true); + } + } + + companion object { + fun newInstance() = WatchLaterFragment().apply {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/AddTopBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/AddTopBarFragment.kt new file mode 100644 index 00000000..c9359c9c --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/AddTopBarFragment.kt @@ -0,0 +1,49 @@ +package com.futo.platformplayer.fragment.mainactivity.topbar + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.views.casting.CastButton + +class AddTopBarFragment : TopFragment() { + private var _buttonAdd: ImageButton? = null; + private var _buttonCast: CastButton? = null; + + val onAdd = Event0(); + + override fun onShown(parameter: Any?) { + + } + override fun onHide() { + onAdd.clear(); + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = inflater.inflate(R.layout.fragment_add_top_bar, container, false); + + _buttonAdd = view.findViewById(R.id.button_add).apply { + this.setOnClickListener { + onAdd.emit(); + } + }; + _buttonCast = view.findViewById(R.id.button_cast); + + return view; + } + + override fun onDestroyView() { + super.onDestroyView() + + onAdd.clear(); + _buttonCast?.cleanup(); + _buttonCast = null; + } + + companion object { + fun newInstance() = AddTopBarFragment().apply { } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/GeneralTopBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/GeneralTopBarFragment.kt new file mode 100644 index 00000000..2b0a0f4a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/GeneralTopBarFragment.kt @@ -0,0 +1,71 @@ +package com.futo.platformplayer.fragment.mainactivity.topbar + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.ImageView +import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.fragment.mainactivity.main.CreatorsFragment +import com.futo.platformplayer.fragment.mainactivity.main.PlaylistFragment +import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment +import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment +import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragmentData +import com.futo.platformplayer.models.SearchType +import com.futo.platformplayer.views.casting.CastButton + +class GeneralTopBarFragment : TopFragment() { + private var _buttonSearch: ImageButton? = null; + private var _buttonCast: CastButton? = null; + + override fun onShown(parameter: Any?) { + if(currentMain is CreatorsFragment) { + _buttonSearch?.setImageResource(R.drawable.ic_person_search_300w); + } else { + _buttonSearch?.setImageResource(R.drawable.ic_search_300w); + } + } + override fun onHide() { + + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = inflater.inflate(R.layout.fragment_overview_top_bar, container, false); + + view.findViewById(R.id.app_icon).setOnClickListener { + UIDialogs.toast("This app is in development. Please submit bug reports and understand that many features are incomplete.", true); + }; + + val buttonSearch: ImageButton = view.findViewById(R.id.button_search); + _buttonCast = view.findViewById(R.id.button_cast); + + buttonSearch.setOnClickListener { + if(currentMain is CreatorsFragment) { + navigate(SuggestionsFragmentData("", SearchType.CREATOR)); + } else if (currentMain is PlaylistsFragment || currentMain is PlaylistFragment) { + navigate(SuggestionsFragmentData("", SearchType.PLAYLIST)); + } else { + navigate(SuggestionsFragmentData("", SearchType.VIDEO)); + } + }; + + _buttonSearch = buttonSearch; + + return view; + } + + override fun onDestroyView() { + super.onDestroyView() + + _buttonSearch?.setOnClickListener(null); + _buttonSearch = null; + _buttonCast?.cleanup(); + _buttonCast = null; + } + + companion object { + fun newInstance() = GeneralTopBarFragment().apply { } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/ImportTopBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/ImportTopBarFragment.kt new file mode 100644 index 00000000..1d157ce5 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/ImportTopBarFragment.kt @@ -0,0 +1,87 @@ +package com.futo.platformplayer.fragment.mainactivity.topbar + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.TextView +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.constructs.Event0 + +class ImportTopBarFragment : TopFragment() { + private var _buttonBack: ImageButton? = null; + private var _textImport: TextView? = null; + private var _textTitle: TextView? = null; + private var _importEnabled: Boolean = false; + + val onImport = Event0(); + var title: String + get() = _textTitle?.text?.toString() ?: "" + set(v) { _textTitle?.text = v; }; + + override fun onShown(parameter: Any?) { + if (parameter is String) { + _textTitle?.text = parameter; + } else if (parameter is IPlatformClient) { + _textTitle?.text = parameter.name; + } + } + + override fun onHide() { + + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = inflater.inflate(R.layout.fragment_import_top_bar, container, false); + + val buttonBack: ImageButton = view.findViewById(R.id.button_back); + val textImport: TextView = view.findViewById(R.id.text_import); + _textTitle = view.findViewById(R.id.text_title); + + buttonBack.setOnClickListener { + closeSegment(); + }; + + textImport.isClickable = true; + textImport.setOnClickListener { + if (!_importEnabled) { + return@setOnClickListener; + } + + onImport.emit(); + }; + + _buttonBack = buttonBack; + _textImport = textImport; + + setImportEnabled(false); + + return view; + } + + override fun onDestroyView() { + super.onDestroyView() + + _buttonBack?.setOnClickListener(null); + _buttonBack = null; + _textImport?.setOnClickListener(null); + _textImport = null; + _textTitle = null; + } + + fun setImportEnabled(enabled: Boolean) { + if (enabled) { + _textImport?.setTextColor(resources.getColor(R.color.colorPrimary)); + } else { + _textImport?.setTextColor(resources.getColor(R.color.gray_67)); + } + + _importEnabled = enabled; + } + + companion object { + fun newInstance() = ImportTopBarFragment().apply { } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/NavigationTopBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/NavigationTopBarFragment.kt new file mode 100644 index 00000000..4348edac --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/NavigationTopBarFragment.kt @@ -0,0 +1,98 @@ +package com.futo.platformplayer.fragment.mainactivity.topbar + +import android.os.Bundle +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.widget.AppCompatImageView +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile +import com.futo.platformplayer.models.Playlist +import com.futo.platformplayer.views.casting.CastButton + +class NavigationTopBarFragment : TopFragment() { + private var _buttonBack: ImageButton? = null; + private var _buttonCast: CastButton? = null; + private var _textTitle: TextView? = null; + private var _menuItems: LinearLayout? = null; + + override fun onShown(parameter: Any?) { + if(parameter is IPlatformChannel) { + _textTitle?.text = parameter.name; + } else if(parameter is PlatformAuthorLink) { + _textTitle?.text = parameter.name; + } else if (parameter is Playlist) { + _textTitle?.text = parameter.name; + } else if (parameter is String) { + _textTitle?.text = parameter; + } else if (parameter is IPlatformClient) { + _textTitle?.text = parameter.name; + } else if (parameter is PolycentricProfile) { + _textTitle?.text = parameter.systemState.username; + } + + setMenuItems(listOf()); + } + override fun onHide() { + + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = inflater.inflate(R.layout.fragment_navigation_top_bar, container, false); + + val buttonBack: ImageButton = view.findViewById(R.id.button_back); + _buttonCast = view.findViewById(R.id.button_cast); + _textTitle = view.findViewById(R.id.text_title); + _menuItems = view.findViewById(R.id.menu_buttons) + + buttonBack.setOnClickListener { + closeSegment(); + }; + + _buttonBack = buttonBack; + + return view; + } + + override fun onDestroyView() { + super.onDestroyView() + + _buttonBack?.setOnClickListener(null); + _buttonBack = null; + _buttonCast?.cleanup(); + _buttonCast = null; + _textTitle = null; + } + + fun setMenuItems(items: ListUnit>>) { + _menuItems?.removeAllViews(); + + val dp4 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 4f, resources.displayMetrics).toInt(); + val dp9 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 9f, resources.displayMetrics).toInt(); + + for(item in items) { + val compatImageItem = AppCompatImageView(requireContext()); + compatImageItem.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.MATCH_PARENT); + compatImageItem.setImageResource(item.first); + compatImageItem.setPadding(dp4, dp9, dp4, dp9); + compatImageItem.scaleType = ImageView.ScaleType.FIT_CENTER; + compatImageItem.setOnClickListener { + item.second.invoke(); + }; + + _menuItems?.addView(compatImageItem); + } + } + + companion object { + fun newInstance() = NavigationTopBarFragment().apply { } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/SearchTopBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/SearchTopBarFragment.kt new file mode 100644 index 00000000..511abd3a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/SearchTopBarFragment.kt @@ -0,0 +1,222 @@ +package com.futo.platformplayer.fragment.mainactivity.topbar + +import android.content.Context +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputMethodManager +import android.widget.EditText +import android.widget.ImageButton +import android.widget.TextView +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.R +import com.futo.platformplayer.stores.SearchHistoryStorage +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.fragment.mainactivity.main.* +import com.futo.platformplayer.models.SearchType + +class SearchTopBarFragment : TopFragment() { + private val TAG = "SearchTopBarFragment" + + private var _editSearch: EditText? = null; + private var _buttonClearSearch: ImageButton? = null; + private var _buttonFilter: ImageButton? = null; + private var _buttonBack: ImageButton? = null; + private var _inputMethodManager: InputMethodManager? = null; + private var _shouldFocus = false; + private var _searchType: SearchType = SearchType.VIDEO; + private var _channelUrl: String? = null; + + private var _lastQuery = ""; + + private val _textChangeListener = object : TextWatcher { + override fun afterTextChanged(s: Editable?) = Unit + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + val text = _editSearch?.text.toString(); + if (text.isBlank()) + _buttonClearSearch?.visibility = EditText.INVISIBLE; + else + _buttonClearSearch?.visibility = EditText.VISIBLE; + onTextChange.emit(text); + } + }; + + private val _searchDoneListener = object : TextView.OnEditorActionListener { + override fun onEditorAction(v: TextView?, actionId: Int, event: KeyEvent?): Boolean { + if (actionId != EditorInfo.IME_ACTION_DONE) + return false + + onDone(); + return true; + } + }; + + val onFilterClick = Event0(); + val onSearch = Event1(); + val onTextChange = Event1(); + + override fun onAttach(context: Context) { + super.onAttach(context); + _inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager; + } + + override fun onDetach() { + super.onDetach(); + _inputMethodManager = null; + } + + override fun onShown(parameter: Any?) { + if (parameter is String) { + this.setText(parameter); + _channelUrl = null; + } else if (parameter is SearchType) { + _searchType = parameter; + _channelUrl = null; + } else if (parameter is SuggestionsFragmentData) { + this.setText(parameter.query); + _searchType = parameter.searchType; + _channelUrl = parameter.channelUrl; + } + + if(currentMain is SuggestionsFragment) + this.focus(); + else + this.clearFocus(); + } + override fun onHide() { + clearFocus(); + } + + fun focus() { + val editSearch = _editSearch; + val inputMethodManager = _inputMethodManager; + if (editSearch != null && inputMethodManager != null) { + _editSearch?.requestFocus(); + _inputMethodManager?.showSoftInput(_editSearch, 0); + _shouldFocus = false; + } else { + _shouldFocus = true; + } + } + fun clear() { + _editSearch?.text?.clear(); + if (currentMain !is SuggestionsFragment) { + navigate(SuggestionsFragmentData("", _searchType, _channelUrl), false); + } else { + onSearch.emit(""); + } + } + fun clearFocus(){ + _editSearch?.clearFocus(); + _inputMethodManager?.hideSoftInputFromWindow(_editSearch?.windowToken, 0); + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = inflater.inflate(R.layout.fragment_search_top_bar, container, false); + + val buttonClearSearch: ImageButton = view.findViewById(R.id.button_clear_search); + val editSearch: EditText = view.findViewById(R.id.edit_search); + val buttonBack: ImageButton = view.findViewById(R.id.button_back); + _buttonFilter = view.findViewById(R.id.button_filter); + + editSearch.setOnEditorActionListener(_searchDoneListener); + editSearch.addTextChangedListener(_textChangeListener); + + buttonClearSearch.setOnClickListener { + clear(); + focus(); + } + + buttonBack.setOnClickListener { + close(); + }; + + _buttonFilter?.setOnClickListener { + onFilterClick.emit(); + }; + setFilterButtonVisible(false); + + _buttonClearSearch = buttonClearSearch; + _editSearch = editSearch; + + return view; + } + + override fun onResume() { + super.onResume(); + + //TODO: Supposed to be in onCreateView, but EditText Lifecycle appears broken there. + setText(_lastQuery); + + if (_shouldFocus) { + focus(); + } + } + + override fun onDestroyView() { + super.onDestroyView(); + + _buttonClearSearch?.setOnClickListener(null); + _buttonClearSearch = null; + _editSearch?.removeTextChangedListener(_textChangeListener); + _editSearch?.setOnClickListener(null); + _editSearch = null; + _buttonBack?.setOnClickListener(null); + _buttonBack = null; + _buttonFilter?.setOnClickListener(null); + _buttonFilter = null; + } + + fun setText(text: String) { + _lastQuery = text; + val editSearch = _editSearch ?: return; + editSearch.text.clear(); + editSearch.text.append(text); + } + + fun setFilterButtonVisible(visible: Boolean) { + _buttonFilter?.visibility = if (visible) View.VISIBLE else View.GONE; + } + + private fun onDone() { + val editSearch = _editSearch; + if (editSearch != null) { + val text = editSearch.text.toString(); + if (text.length < 3) { + UIDialogs.toast("Please use at least 3 characters"); + return; + } + + editSearch.clearFocus(); + _inputMethodManager?.hideSoftInputFromWindow(editSearch.windowToken, 0); + + if (Settings.instance.search.searchHistory) { + val storage = FragmentedStorage.get(); + storage.add(text); + } + + if (_searchType == SearchType.CREATOR) { + onSearch.emit(text); + } else { + onSearch.emit(text); + } + } else { + Logger.w(TAG, "Unexpected condition happened where done is edit search is null but done is triggered."); + } + } + + companion object { + fun newInstance() = SearchTopBarFragment().apply { } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/TopFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/TopFragment.kt new file mode 100644 index 00000000..3e88962a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/TopFragment.kt @@ -0,0 +1,15 @@ +package com.futo.platformplayer.fragment.mainactivity.topbar + +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.fragment.mainactivity.MainActivityFragment + +abstract class TopFragment : MainActivityFragment() { + + open fun onShown(parameter: Any? = null) {} + open fun onHide() {} + + fun close() { + isValidMainActivity(); + return (activity as MainActivity).closeSegment(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/helpers/DashManifestCreatorsUtils.kt b/app/src/main/java/com/futo/platformplayer/helpers/DashManifestCreatorsUtils.kt new file mode 100644 index 00000000..7a14e2f6 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/helpers/DashManifestCreatorsUtils.kt @@ -0,0 +1,228 @@ +package com.futo.platformplayer.helpers + +import org.w3c.dom.DOMException +import org.w3c.dom.Document +import org.w3c.dom.Element +import java.io.StringWriter +import java.util.* +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.transform.OutputKeys +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult + +object DashManifestCreatorsUtils { + val MPD = "MPD" + val PERIOD = "Period" + val ADAPTATION_SET = "AdaptationSet" + val ROLE = "Role" + val REPRESENTATION = "Representation" + val AUDIO_CHANNEL_CONFIGURATION = "AudioChannelConfiguration" + val BASE_URL = "BaseURL" + val SEGMENT_BASE = "SegmentBase" + val INITIALIZATION = "Initialization" + + fun setAttribute(element: Element, doc: Document, name: String?, value: String?) { + val attr = doc.createAttribute(name) + attr.value = value + element.setAttributeNode(attr) + } + + fun generateVideoDocumentAndDoCommonElementsGeneration(streamDuration: Long, mimeType: String, id: Int, codec: String, bitrate: Int, width: Int, height: Int, fps: Int): Document { + val doc: Document = generateDocumentAndMpdElement(streamDuration) + generatePeriodElement(doc) + generateAdaptationSetElement(doc, mimeType) + generateRoleElement(doc) + generateVideoRepresentationElement(doc, id, codec, bitrate, width, height, fps) + return doc + } + + //Audio + fun generateAudioDocumentAndDoCommonElementsGeneration(streamDuration: Long, mimeType: String, audioChannels: Int, id: Int, codec: String, bitrate: Int, sampleRate: Int): Document { + val doc: Document = generateDocumentAndMpdElement(streamDuration) + generatePeriodElement(doc) + generateAdaptationSetElement(doc, mimeType) + generateRoleElement(doc) + generateAudioRepresentationElement(doc, id, codec, bitrate, sampleRate) + generateAudioChannelConfigurationElement(doc, audioChannels) + return doc + } + + private fun generateDocumentAndMpdElement(duration: Long): Document { + try { + val doc: Document = newDocument() + val mpdElement = + doc.createElement(MPD) + doc.appendChild(mpdElement) + setAttribute(mpdElement, doc, "xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance") + setAttribute(mpdElement, doc, "xmlns", "urn:mpeg:DASH:schema:MPD:2011") + setAttribute(mpdElement, doc, "xsi:schemaLocation", "urn:mpeg:DASH:schema:MPD:2011 DASH-MPD.xsd") + setAttribute(mpdElement, doc, "minBufferTime", "PT1.500S") + setAttribute(mpdElement, doc, "profiles", "urn:mpeg:dash:profile:full:2011") + setAttribute(mpdElement, doc, "type", "static") + setAttribute(mpdElement, doc, "mediaPresentationDuration", String.format(Locale.ENGLISH, "PT%.3fS", duration / 1000.0)) + return doc + } catch (e: Exception) { + throw Exception("Could not generate the DASH manifest or append the MPD doc to it", e) + } + } + + private fun generatePeriodElement(doc: Document) { + try { + val mpdElement = doc.getElementsByTagName(MPD).item(0) as Element + val periodElement = doc.createElement(PERIOD) + mpdElement.appendChild(periodElement) + } catch (e: DOMException) { + throw Exception(PERIOD, e) + } + } + + private fun generateAdaptationSetElement(doc: Document, mimeType: String) { + try { + val periodElement = doc.getElementsByTagName(PERIOD).item(0) as Element + val adaptationSetElement = doc.createElement(ADAPTATION_SET) + setAttribute(adaptationSetElement, doc, "id", "0") + if (mimeType.isEmpty()) { + throw Exception("the MediaFormat or its mime type is null or empty") + } + + setAttribute(adaptationSetElement, doc, "mimeType", mimeType) + setAttribute(adaptationSetElement, doc, "subsegmentAlignment", "true") + periodElement.appendChild(adaptationSetElement) + } catch (e: DOMException) { + throw Exception(ADAPTATION_SET, e) + } + } + + private fun generateRoleElement(doc: Document) { + try { + val adaptationSetElement = doc.getElementsByTagName(ADAPTATION_SET).item(0) as Element + val roleElement = doc.createElement(ROLE) + setAttribute(roleElement, doc, "schemeIdUri", "urn:mpeg:DASH:role:2011") + setAttribute(roleElement, doc, "value", "main") + adaptationSetElement.appendChild(roleElement) + } catch (e: DOMException) { + throw Exception(e) + } + } + + private fun generateVideoRepresentationElement(doc: Document, id: Int, codec: String, bitrate: Int, width: Int, height: Int, fps: Int) { + try { + val adaptationSetElement = doc.getElementsByTagName(ADAPTATION_SET).item(0) as Element + val representationElement = doc.createElement(REPRESENTATION) + if (id <= 0) { + throw Exception("the id of the ItagItem is <= 0") + } + setAttribute(representationElement, doc, "id", id.toString()) + if (codec.isEmpty()) { + throw Exception("the codec value of the ItagItem is null or empty") + } + setAttribute(representationElement, doc, "codecs", codec) + setAttribute(representationElement, doc, "startWithSAP", "1") + setAttribute(representationElement, doc, "maxPlayoutRate", "1") + if (bitrate <= 0) { + throw Exception("the bitrate of the ItagItem is <= 0") + } + setAttribute(representationElement, doc, "bandwidth", bitrate.toString()) + if (height <= 0 && width <= 0) { + throw Exception("both width and height of the ItagItem are <= 0") + } + if (width > 0) { + setAttribute( + representationElement, + doc, + "width", + width.toString() + ) + } + setAttribute(representationElement, doc, "height", height.toString()) + if (fps > 0) { + setAttribute(representationElement, doc, "frameRate", fps.toString()) + } + adaptationSetElement.appendChild(representationElement) + } catch (e: DOMException) { + throw Exception(e) + } + } + + private fun generateAudioRepresentationElement(doc: Document, id: Int, codec: String, bitrate: Int, sampleRate: Int) { + try { + val adaptationSetElement = doc.getElementsByTagName(ADAPTATION_SET).item(0) as Element + val representationElement = doc.createElement(REPRESENTATION) + if (id <= 0) { + throw Exception("the id of the ItagItem is <= 0") + } + setAttribute(representationElement, doc, "id", id.toString()) + if (codec.isEmpty()) { + throw Exception("the codec value of the ItagItem is null or empty") + } + setAttribute(representationElement, doc, "codecs", codec) + setAttribute(representationElement, doc, "startWithSAP", "1") + setAttribute(representationElement, doc, "maxPlayoutRate", "1") + if (bitrate <= 0) { + throw Exception("the bitrate of the ItagItem is <= 0") + } + setAttribute(representationElement, doc, "bandwidth", bitrate.toString()) + val audioSamplingRateAttribute = doc.createAttribute("audioSamplingRate") + audioSamplingRateAttribute.value = sampleRate.toString() + adaptationSetElement.appendChild(representationElement) + } catch (e: DOMException) { + throw Exception(e) + } + } + + private fun generateAudioChannelConfigurationElement(doc: Document, audioChannels: Int) { + try { + val representationElement = doc.getElementsByTagName(REPRESENTATION).item(0) as Element + val audioChannelConfigurationElement = doc.createElement(AUDIO_CHANNEL_CONFIGURATION) + setAttribute(audioChannelConfigurationElement, doc, "schemeIdUri", "urn:mpeg:dash:23003:3:audio_channel_configuration:2011") + if (audioChannels <= 0) { + throw Exception("the number of audioChannels in the ItagItem is <= 0: $audioChannels") + } + setAttribute(audioChannelConfigurationElement, doc, "value", audioChannels.toString()) + representationElement.appendChild(audioChannelConfigurationElement) + } catch (e: DOMException) { + throw Exception(e) + } + } + + fun buildAndCacheResult(originalBaseStreamingUrl: String, doc: Document, manifestCreatorCache: ManifestCreatorCache): String { + try { + val documentXml: String = documentToXml(doc) + manifestCreatorCache.put(originalBaseStreamingUrl, documentXml) + return documentXml + } catch (e: Exception) { + throw Exception("Could not convert the DASH manifest generated to a string", e) + } + } + + private fun newDocument(): Document { + val documentBuilderFactory = DocumentBuilderFactory.newInstance() + try { + documentBuilderFactory.setAttribute("http://javax.xml.XMLConstants/property/accessExternalDTD", "") + documentBuilderFactory.setAttribute("http://javax.xml.XMLConstants/property/accessExternalSchema", "") + } catch (ignored: Exception) { + // Ignore exceptions as setting these attributes to secure XML generation is not + // supported by all platforms (like the Android implementation) + } + return documentBuilderFactory.newDocumentBuilder().newDocument() + } + + private fun documentToXml(doc: Document): String { + val transformerFactory = TransformerFactory.newInstance() + try { + transformerFactory.setAttribute("http://javax.xml.XMLConstants/property/accessExternalDTD", "") + transformerFactory.setAttribute("http://javax.xml.XMLConstants/property/accessExternalSchema", "") + } catch (ignored: Exception) { + // Ignore exceptions as setting these attributes to secure XML generation is not + // supported by all platforms (like the Android implementation) + } + val transformer = transformerFactory.newTransformer() + transformer.setOutputProperty(OutputKeys.VERSION, "1.0") + transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8") + transformer.setOutputProperty(OutputKeys.STANDALONE, "no") + val result = StringWriter() + transformer.transform(DOMSource(doc), StreamResult(result)) + return result.toString() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/helpers/FileHelper.kt b/app/src/main/java/com/futo/platformplayer/helpers/FileHelper.kt new file mode 100644 index 00000000..0f8565a2 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/helpers/FileHelper.kt @@ -0,0 +1,12 @@ +package com.futo.platformplayer.helpers + +class FileHelper { + companion object { + val allowedCharacters = HashSet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz-.".toCharArray().toList()); + + + fun String.sanitizeFileName(): String { + return this.filter { allowedCharacters.contains(it) }; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/helpers/ManifestCreatorCache.kt b/app/src/main/java/com/futo/platformplayer/helpers/ManifestCreatorCache.kt new file mode 100644 index 00000000..08545bdc --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/helpers/ManifestCreatorCache.kt @@ -0,0 +1,64 @@ +package com.futo.platformplayer.helpers + +import java.io.Serializable +import java.util.concurrent.ConcurrentHashMap + +class ManifestCreatorCache : Serializable { + private val concurrentHashMap: ConcurrentHashMap> + private var maximumSize: Int = DEFAULT_MAXIMUM_SIZE + private var clearFactor: Double = DEFAULT_CLEAR_FACTOR + + init { + concurrentHashMap = ConcurrentHashMap>() + } + + fun containsKey(key: K): Boolean { + return concurrentHashMap.containsKey(key) + } + + operator fun get(key: K): Pair? { + return concurrentHashMap[key] + } + + fun put(key: K, value: V): V? { + if (!concurrentHashMap.containsKey(key) && concurrentHashMap.size == maximumSize) { + val newCacheSize = Math.round(maximumSize * clearFactor).toInt() + keepNewestEntries(if (newCacheSize != 0) newCacheSize else 1) + } + val returnValue: Pair? = concurrentHashMap.put(key, Pair(concurrentHashMap.size, value)) + return if (returnValue == null) null else returnValue.second + } + + fun clear() { + concurrentHashMap.clear() + } + + fun size(): Int { + return concurrentHashMap.size + } + + override fun toString(): String { + return "ManifestCreatorCache[clearFactor=$clearFactor, maximumSize=$maximumSize, concurrentHashMap=$concurrentHashMap]" + } + + private fun keepNewestEntries(newLimit: Int) { + val difference = concurrentHashMap.size - newLimit + val entriesToRemove: ArrayList>> = ArrayList() + concurrentHashMap.entries.forEach { entry: MutableMap.MutableEntry> -> + val value: Pair = entry.value + if (value.first < difference) { + entriesToRemove.add(entry) + } else { + entry.setValue(value.copy(value.first - difference, value.second)) + } + } + entriesToRemove.forEach { (key, value): Map.Entry> -> + concurrentHashMap.remove(key, value) + } + } + + companion object { + const val DEFAULT_MAXIMUM_SIZE = Int.MAX_VALUE + const val DEFAULT_CLEAR_FACTOR = 0.75 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/helpers/ProgressiveDashManifestCreator.kt b/app/src/main/java/com/futo/platformplayer/helpers/ProgressiveDashManifestCreator.kt new file mode 100644 index 00000000..8542f684 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/helpers/ProgressiveDashManifestCreator.kt @@ -0,0 +1,78 @@ +package com.futo.platformplayer.helpers + +import org.w3c.dom.DOMException +import org.w3c.dom.Document +import org.w3c.dom.Element + +object ProgressiveDashManifestCreator { + private val PROGRESSIVE_STREAMS_CACHE: ManifestCreatorCache = ManifestCreatorCache() + + fun fromVideoProgressiveStreamingUrl(progressiveStreamingBaseUrl: String, streamDuration: Long, mimeType: String, id: Int, codec: String, bitrate: Int, width: Int, height: Int, fps: Int, indexStart: Int, indexEnd: Int, initStart: Int, initEnd: Int): String { + if (PROGRESSIVE_STREAMS_CACHE.containsKey(progressiveStreamingBaseUrl)) { + return PROGRESSIVE_STREAMS_CACHE[progressiveStreamingBaseUrl]!!.second + } + + val doc = DashManifestCreatorsUtils.generateVideoDocumentAndDoCommonElementsGeneration(streamDuration, mimeType, id, codec, bitrate, width, height, fps) + generateBaseUrlElement(doc, progressiveStreamingBaseUrl) + generateSegmentBaseElement(doc, indexStart, indexEnd) + generateInitializationElement(doc, initStart, initEnd) + return DashManifestCreatorsUtils.buildAndCacheResult(progressiveStreamingBaseUrl, doc, PROGRESSIVE_STREAMS_CACHE) + } + + fun fromAudioProgressiveStreamingUrl(progressiveStreamingBaseUrl: String, streamDuration: Long, mimeType: String, audioChannels: Int, id: Int, codec: String, bitrate: Int, sampleRate: Int, indexStart: Int, indexEnd: Int, initStart: Int, initEnd: Int): String { + if (PROGRESSIVE_STREAMS_CACHE.containsKey(progressiveStreamingBaseUrl)) { + return PROGRESSIVE_STREAMS_CACHE[progressiveStreamingBaseUrl]!!.second + } + + val doc = DashManifestCreatorsUtils.generateAudioDocumentAndDoCommonElementsGeneration(streamDuration, mimeType, audioChannels, id, codec, bitrate, sampleRate) + generateBaseUrlElement(doc, progressiveStreamingBaseUrl) + generateSegmentBaseElement(doc, indexStart, indexEnd) + generateInitializationElement(doc, initStart, initEnd) + return DashManifestCreatorsUtils.buildAndCacheResult(progressiveStreamingBaseUrl, doc, PROGRESSIVE_STREAMS_CACHE) + } + + fun clearCache() { + PROGRESSIVE_STREAMS_CACHE.clear(); + } + + private fun generateBaseUrlElement(doc: Document, baseUrl: String) { + try { + val representationElement = doc.getElementsByTagName(DashManifestCreatorsUtils.REPRESENTATION).item(0) as Element + val baseURLElement = doc.createElement(DashManifestCreatorsUtils.BASE_URL) + baseURLElement.textContent = baseUrl + representationElement.appendChild(baseURLElement) + } catch (e: DOMException) { + throw Exception(e) + } + } + + private fun generateSegmentBaseElement(doc: Document, indexStart: Int, indexEnd: Int) { + try { + val representationElement = doc.getElementsByTagName(DashManifestCreatorsUtils.REPRESENTATION).item(0) as Element + val segmentBaseElement = doc.createElement(DashManifestCreatorsUtils.SEGMENT_BASE) + val range: String = "$indexStart-$indexEnd" + if (indexStart < 0 || indexEnd < 0) { + throw Exception("ItagItem's indexStart or indexEnd are < 0: $range") + } + DashManifestCreatorsUtils.setAttribute(segmentBaseElement, doc, "indexRange", range) + representationElement.appendChild(segmentBaseElement) + } catch (e: DOMException) { + throw Exception(e) + } + } + + private fun generateInitializationElement(doc: Document, initStart: Int, initEnd: Int) { + try { + val segmentBaseElement = doc.getElementsByTagName(DashManifestCreatorsUtils.SEGMENT_BASE).item(0) as Element + val initializationElement = doc.createElement(DashManifestCreatorsUtils.INITIALIZATION) + val range = "$initStart-$initEnd" + if (initStart < 0 || initEnd < 0) { + throw Exception("ItagItem's initStart and/or initEnd are/is < 0: $range") + } + DashManifestCreatorsUtils.setAttribute(initializationElement, doc, "range", range) + segmentBaseElement.appendChild(initializationElement) + } catch (e: DOMException) { + throw Exception(e) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt new file mode 100644 index 00000000..21799036 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt @@ -0,0 +1,156 @@ +package com.futo.platformplayer.helpers + +import android.net.Uri +import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource +import com.futo.platformplayer.logging.Logger +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.source.MediaSource +import com.google.android.exoplayer2.source.dash.DashMediaSource +import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser +import com.google.android.exoplayer2.upstream.ResolvingDataSource + +class VideoHelper { + companion object { + + fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers); + fun selectBestVideoSource(sources: Iterable, desiredPixelCount : Int, prefContainers : Array) : IVideoSource? { + val targetVideo = if(desiredPixelCount > 0) + sources.toList() + .sortedBy { x -> Math.abs(x.height * x.width - desiredPixelCount) } + .firstOrNull(); + else + sources.toList() + .lastOrNull(); + + val hasPriority = sources.any { it.priority }; + + val targetPixelCount = if(targetVideo != null) targetVideo.width * targetVideo.height else desiredPixelCount; + val altSources = if(hasPriority) { + sources.filter { it.priority }.sortedBy { x -> Math.abs(x.height * x.width - targetPixelCount) }; + } else { + sources.filter { it.height == (targetVideo?.height ?: 0) }; + } + + var bestSource = altSources.firstOrNull(); + for (prefContainer in prefContainers) { + val betterSource = altSources.firstOrNull { it.container == prefContainer }; + if(betterSource != null) { + bestSource = betterSource; + break; + } + } + + return bestSource; + } + + + fun selectBestAudioSource(desc: IVideoSourceDescriptor, prefContainers : Array, prefLanguage: String? = null, targetBitrate: Long? = null) : IAudioSource? { + if(!desc.isUnMuxed) + return null; + return selectBestAudioSource((desc as VideoUnMuxedSourceDescriptor).audioSources.toList(), prefContainers, prefLanguage); + } + fun selectBestAudioSource(altSources : Iterable, prefContainers : Array, preferredLanguage: String? = null, targetBitrate: Long? = null) : IAudioSource? { + val languageToFilter = if(preferredLanguage != null && altSources.any { it.language == preferredLanguage }) + preferredLanguage + else if(preferredLanguage == null) null + else "Unknown"; + + var usableSources = if(languageToFilter != null && altSources.any { it.language == languageToFilter }) + altSources.filter { it.language == languageToFilter }.sortedBy { it.bitrate }.toList(); + else altSources.sortedBy { it.bitrate }; + + if(usableSources.any { it.priority }) + usableSources = usableSources.filter { it.priority }; + + + var bestSource = if(targetBitrate != null) + usableSources.minByOrNull { Math.abs(it.bitrate - targetBitrate) }; + else + usableSources.lastOrNull(); + + for (prefContainer in prefContainers) { + val betterSources = usableSources.filter { it.container == prefContainer }; + val betterSource = if(targetBitrate != null) + betterSources.minByOrNull { Math.abs(it.bitrate - targetBitrate) }; + else + betterSources.lastOrNull(); + + if(betterSource != null) { + bestSource = betterSource; + break; + } + } + return bestSource; + } + + var breakOnce = hashSetOf() + fun convertItagSourceToChunkedDashSource(videoSource: JSVideoUrlRangeSource) : MediaSource { + var urlToUse = videoSource.getVideoUrl(); + /* + //TODO: REMOVE THIS, PURPOSELY 403s + if(urlToUse.contains("sig=") && !breakOnce.contains(urlToUse)) { + breakOnce.add(urlToUse); + val sigIndex = urlToUse.indexOf("sig="); + urlToUse = urlToUse.substring(0, sigIndex) + "sig=0" + urlToUse.substring(sigIndex + 4); + }*/ + + val manifestConfig = ProgressiveDashManifestCreator.fromVideoProgressiveStreamingUrl(urlToUse, + videoSource.duration * 1000, + videoSource.container, + videoSource.itagId ?: 1, + videoSource.codec, + videoSource.bitrate, + videoSource.width, + videoSource.height, + -1, + videoSource.indexStart ?: 0, + videoSource.indexEnd ?: 0, + videoSource.initStart ?: 0, + videoSource.initEnd ?: 0 + ); + + val manifest = DashManifestParser().parse(Uri.parse(""), manifestConfig.byteInputStream()); + + return DashMediaSource.Factory(ResolvingDataSource.Factory(videoSource.getHttpDataSourceFactory(), ResolvingDataSource.Resolver { dataSpec -> + Logger.v("PLAYBACK", "Video REQ Range [" + dataSpec.position + "-" + (dataSpec.position + dataSpec.length) + "](" + dataSpec.length + ")", null); + return@Resolver dataSpec; + })) + .createMediaSource(manifest, + MediaItem.Builder() + .setUri(Uri.parse(videoSource.getVideoUrl())) + .build()) + } + + fun convertItagSourceToChunkedDashSource(audioSource: JSAudioUrlRangeSource) : MediaSource { + val manifestConfig = ProgressiveDashManifestCreator.fromAudioProgressiveStreamingUrl(audioSource.getAudioUrl(), + audioSource.duration?.times(1000) ?: 0, + audioSource.container, + audioSource.audioChannels, + audioSource.itagId ?: 1, + audioSource.codec, + audioSource.bitrate, + -1, + audioSource.indexStart ?: 0, + audioSource.indexEnd ?: 0, + audioSource.initStart ?: 0, + audioSource.initEnd ?: 0 + ); + + val manifest = DashManifestParser().parse(Uri.parse(""), manifestConfig.byteInputStream()); + + return DashMediaSource.Factory(ResolvingDataSource.Factory(audioSource.getHttpDataSourceFactory(), ResolvingDataSource.Resolver { dataSpec -> + Logger.v("PLAYBACK", "Audio REQ Range [" + dataSpec.position + "-" + (dataSpec.position + dataSpec.length) + "](" + dataSpec.length + ")", null); + return@Resolver dataSpec; + })) + .createMediaSource(manifest, + MediaItem.Builder() + .setUri(Uri.parse(audioSource.getAudioUrl())) + .build()) + } + } +} diff --git a/app/src/main/java/com/futo/platformplayer/images/GlideHelper.kt b/app/src/main/java/com/futo/platformplayer/images/GlideHelper.kt new file mode 100644 index 00000000..682ad9be --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/images/GlideHelper.kt @@ -0,0 +1,38 @@ +package com.futo.platformplayer.images + +import android.graphics.drawable.Drawable +import android.widget.ImageView +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions +import com.futo.platformplayer.api.media.models.Thumbnails + +class GlideHelper { + + + companion object { + fun ImageView.loadThumbnails(thumbnails: Thumbnails, isHQ: Boolean = true, continuation: ((RequestBuilder) -> Unit)? = null) { + val url = if(isHQ) thumbnails.getHQThumbnail() ?: thumbnails.getLQThumbnail() else thumbnails.getLQThumbnail(); + + val req = Glide.with(this).load(url); + + if (thumbnails.hasMultiple() && false) { //TODO: Resolve issue where fallback triggered on second loads? + val fallbackUrl = if (isHQ) thumbnails.getLQThumbnail() else thumbnails.getHQThumbnail(); + if (continuation != null) + req.error(continuation(Glide.with(this).load(fallbackUrl))) + else + req.error(Glide.with(this).load(fallbackUrl).into(this)); + } + else if (continuation != null) + continuation(req); + else + req.into(this); + } + + + fun RequestBuilder.crossfade(): RequestBuilder { + return this.transition(DrawableTransitionOptions.withCrossFade()); + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/images/GrayjayAppGlideModule.java b/app/src/main/java/com/futo/platformplayer/images/GrayjayAppGlideModule.java new file mode 100644 index 00000000..60d962ac --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/images/GrayjayAppGlideModule.java @@ -0,0 +1,18 @@ +package com.futo.platformplayer.images; + +import android.content.Context; +import android.util.Log; +import com.bumptech.glide.Glide; +import com.bumptech.glide.Registry; +import com.bumptech.glide.annotation.GlideModule; +import com.bumptech.glide.module.AppGlideModule; +import java.nio.ByteBuffer; + +@GlideModule +public class GrayjayAppGlideModule extends AppGlideModule { + @Override + public void registerComponents(Context context, Glide glide, Registry registry) { + Log.i("GrayjayAppGlideModule", "registerComponents called"); + registry.prepend(String.class, ByteBuffer.class, new PolycentricModelLoader.Factory()); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/images/PolycentricModelLoader.java b/app/src/main/java/com/futo/platformplayer/images/PolycentricModelLoader.java new file mode 100644 index 00000000..1b8a3e76 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/images/PolycentricModelLoader.java @@ -0,0 +1,89 @@ +package com.futo.platformplayer.images; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.Priority; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.data.DataFetcher; +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.load.model.ModelLoaderFactory; +import com.bumptech.glide.load.model.MultiModelLoaderFactory; +import com.bumptech.glide.signature.ObjectKey; +import com.futo.platformplayer.polycentric.PolycentricCache; + +import kotlin.Unit; +import kotlinx.coroutines.Deferred; +import java.lang.Exception; +import java.nio.ByteBuffer; +import java.util.concurrent.CancellationException; + +public class PolycentricModelLoader implements ModelLoader { + + @Override + public boolean handles(String model) { + return model.startsWith("polycentric://"); + } + + @Override + public ModelLoader.LoadData buildLoadData(@NonNull String model, int width, int height, @NonNull Options options) { + return new ModelLoader.LoadData(new ObjectKey(model), new Fetcher(model)); + } + + public static class Factory implements ModelLoaderFactory { + @NonNull + @Override + public ModelLoader build(@NonNull MultiModelLoaderFactory multiFactory) { + return new PolycentricModelLoader(); + } + + @Override + public void teardown() { } + } + + public static class Fetcher implements DataFetcher { + private final String _model; + private Deferred _deferred; + + public Fetcher(String model) { + this._model = model; + } + + @NonNull + @Override + public DataSource getDataSource() { + return DataSource.REMOTE; + } + + @Override + public void loadData(@NonNull Priority priority, @NonNull DataFetcher.DataCallback callback) { + _deferred = PolycentricCache.getInstance().getDataAsync(_model); + _deferred.invokeOnCompletion(throwable -> { + if (throwable != null) { + callback.onLoadFailed(new Exception(throwable)); + } + final ByteBuffer completed = _deferred.getCompleted(); + callback.onDataReady(completed); + return Unit.INSTANCE; + }); + } + + @Override + public void cancel() { + if (_deferred != null) { + _deferred.cancel(new CancellationException("Cancelled by Fetcher.")); + } + } + + @Override + public void cleanup() { + _deferred = null; + } + + @NonNull + @Override + public Class getDataClass() { + return ByteBuffer.class; + } + } +} diff --git a/app/src/main/java/com/futo/platformplayer/listeners/OrientationManager.kt b/app/src/main/java/com/futo/platformplayer/listeners/OrientationManager.kt new file mode 100644 index 00000000..f78b4652 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/listeners/OrientationManager.kt @@ -0,0 +1,47 @@ +package com.futo.platformplayer.listeners + +import android.content.Context +import android.view.OrientationEventListener +import com.futo.platformplayer.constructs.Event1 + +class OrientationManager : OrientationEventListener { + + val onOrientationChanged = Event1(); + + var orientation : Orientation = Orientation.PORTRAIT; + + constructor(context: Context) : super(context) { } + constructor(context: Context, rate: Int) : super(context, rate) { } + init { + + } + + override fun onOrientationChanged(orientationAnglep: Int) { + if(orientationAnglep == -1) + return; + + var newOrientation = Orientation.PORTRAIT; + if(orientationAnglep > 60 && orientationAnglep < 140) + newOrientation = Orientation.REVERSED_LANDSCAPE; + else if(orientationAnglep >= 140 && orientationAnglep <= 220) + newOrientation = Orientation.REVERSED_PORTRAIT; + else if(orientationAnglep >= 220 && orientationAnglep <= 300) + newOrientation = Orientation.LANDSCAPE; + else + newOrientation = Orientation.PORTRAIT; + + if(newOrientation != orientation) { + orientation = newOrientation; + onOrientationChanged.emit(newOrientation); + } + } + + + //TODO: Perhaps just use ActivityInfo orientations instead.. + enum class Orientation { + PORTRAIT, + LANDSCAPE, + REVERSED_PORTRAIT, + REVERSED_LANDSCAPE + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/logging/AndroidLogConsumer.kt b/app/src/main/java/com/futo/platformplayer/logging/AndroidLogConsumer.kt new file mode 100644 index 00000000..d121a2ae --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/logging/AndroidLogConsumer.kt @@ -0,0 +1,27 @@ +package com.futo.platformplayer.logging + +import android.util.Log +import com.futo.platformplayer.logging.ILogConsumer +import com.futo.platformplayer.logging.LogLevel + +class AndroidLogConsumer : ILogConsumer { + override fun willConsume(level: LogLevel, tag: String): Boolean { + return Log.isLoggable(tag, when (level) { + LogLevel.VERBOSE -> Log.VERBOSE + LogLevel.INFORMATION -> Log.INFO + LogLevel.WARNING -> Log.WARN + LogLevel.ERROR -> Log.ERROR + else -> throw Exception("Unknown log level") + }); + } + + override fun consume(level: LogLevel, tag: String, text: String?, e: Throwable?) { + when (level) { + LogLevel.VERBOSE -> Log.v("INTERNAL;$tag", text, e) + LogLevel.INFORMATION -> Log.i("INTERNAL;$tag", text, e) + LogLevel.WARNING -> Log.w("INTERNAL;$tag", text, e) + LogLevel.ERROR -> Log.e("INTERNAL;$tag", text, e) + else -> throw Exception("Unknown log level") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/logging/FileLogConsumer.kt b/app/src/main/java/com/futo/platformplayer/logging/FileLogConsumer.kt new file mode 100644 index 00000000..ef773462 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/logging/FileLogConsumer.kt @@ -0,0 +1,95 @@ +package com.futo.platformplayer.logging + +import android.util.Log +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.constructs.Event1 +import java.io.BufferedWriter +import java.io.Closeable +import java.io.File +import java.io.FileWriter +import java.util.concurrent.ConcurrentLinkedQueue + +class FileLogConsumer : ILogConsumer, Closeable { + private var _logThread: Thread? = null; + private var _shouldSubmitLogs = false; + private val _linesToWrite = ConcurrentLinkedQueue(); + private var _writer: BufferedWriter? = null; + private var _running: Boolean = false; + private var _file: File; + private val _level: LogLevel; + + constructor(file: File, level: LogLevel, append: Boolean) { + _file = file; + _level = level; + + if (level.value < LogLevel.ERROR.value) { + throw Exception("Do not use a file logger with log level NONE."); + } + + if (!file.exists()) { + file.createNewFile(); + } + + _writer = BufferedWriter(FileWriter(file, append)) + val t = Thread { + Log.i(TAG, "Started log writer."); + + while (_running) { + Thread.sleep(1000); + + try { + if (_shouldSubmitLogs) { + submitLogs(); + } + + while (_linesToWrite.isNotEmpty()) { + _writer?.appendLine(_linesToWrite.remove()); + } + + _writer?.flush(); + } catch (e: Throwable) { + Log.e(TAG, "Failed to process logs.", e); + } + } + + Log.i(TAG, "Stopped log writer."); + } + t.start(); + + _logThread = t; + _running = true; + } + + + override fun willConsume(level: LogLevel, tag: String): Boolean { + return level.value <= _level.value; + } + + override fun consume(level: LogLevel, tag: String, text: String?, e: Throwable?) { + _linesToWrite.add(Logging.buildLogString(level, tag, text, e)); + } + + fun submitLogs() { + val id = Logging.submitLog(_file); + _shouldSubmitLogs = false; + Logger.onLogSubmitted.emit(id) + } + + fun submitLogsAsync() { + _shouldSubmitLogs = true; + } + + override fun close() { + Log.i(TAG, "Requesting log writer exit."); + + _running = false; + _writer?.close(); + _writer = null; + _logThread?.join(); + _logThread = null; + } + + companion object { + private const val TAG = "FileLogConsumer" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/logging/Logger.kt b/app/src/main/java/com/futo/platformplayer/logging/Logger.kt new file mode 100644 index 00000000..0480e5ed --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/logging/Logger.kt @@ -0,0 +1,89 @@ +package com.futo.platformplayer.logging + +import com.futo.platformplayer.constructs.Event1 + +enum class LogLevel(val value: Int) { + NONE(0), + ERROR(1), + WARNING(2), + INFORMATION(3), + VERBOSE(4); + + companion object { + fun fromInt(value: Int): LogLevel { + return when (value) { + 0 -> NONE + 1 -> ERROR + 2 -> WARNING + 3 -> INFORMATION + 4 -> VERBOSE + else -> throw IllegalArgumentException("Invalid LogLevel value: $value") + } + } + } +} + +interface ILogConsumer { + fun willConsume(level: LogLevel, tag: String) : Boolean; + fun consume(level: LogLevel, tag: String, text: String?, e: Throwable? = null); +} + +class Logger { + companion object { + private const val TAG = "Logger"; + + private var _logConsumers = emptyList(); + + val onLogSubmitted = Event1(); + + val hasConsumers: Boolean get() = !_logConsumers.isEmpty(); + + fun setLogConsumers(logConsumers: List) { + _logConsumers = logConsumers; + } + + fun i(tag: String, e: Throwable? = null, text: () -> String) { log(LogLevel.INFORMATION, tag, e, text); } + fun e(tag: String, e: Throwable? = null, text: () -> String?) { log(LogLevel.ERROR, tag, e, text); } + fun w(tag: String, e: Throwable? = null, text: () -> String?) { log(LogLevel.WARNING, tag, e, text); } + fun v(tag: String, e: Throwable? = null, text: () -> String?) { log(LogLevel.VERBOSE, tag, e, text); } + + fun i(tag: String, text: String, e: Throwable? = null) { log(LogLevel.INFORMATION, tag, text, e); } + fun e(tag: String, text: String?, e: Throwable? = null) { log(LogLevel.ERROR, tag, text, e); } + fun w(tag: String, text: String?, e: Throwable? = null) { log(LogLevel.WARNING, tag, text, e); } + fun v(tag: String, text: String?, e: Throwable? = null) { log(LogLevel.VERBOSE, tag, text, e); } + + fun submitLogs(): Boolean { + var loggingEnabled = false; + for (logConsumer in _logConsumers) { + if (logConsumer is FileLogConsumer) { + logConsumer.submitLogs(); + loggingEnabled = true; + } + } + return loggingEnabled; + } + + fun submitLogsAsync(): Boolean { + var loggingEnabled = false; + for (logConsumer in _logConsumers) { + if (logConsumer is FileLogConsumer) { + logConsumer.submitLogsAsync(); + loggingEnabled = true; + } + } + return loggingEnabled; + } + + private fun log(level: LogLevel, tag: String, e: Throwable? = null, textBuilder: () -> String?) { + if (!_logConsumers.any { c -> c.willConsume(level, tag) }) { + return; + } + + log(level, tag, textBuilder(), e); + } + + private fun log(level: LogLevel, tag: String, text: String?, e: Throwable? = null) { + _logConsumers.forEach { c -> c.consume(level, tag, text, e) }; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/logging/Logging.kt b/app/src/main/java/com/futo/platformplayer/logging/Logging.kt new file mode 100644 index 00000000..8d9e09f6 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/logging/Logging.kt @@ -0,0 +1,77 @@ +package com.futo.platformplayer.logging + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File +import java.io.PrintWriter +import java.io.StringWriter +import java.text.SimpleDateFormat +import java.util.* + +class Logging { + + companion object { + val logDateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss").apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + + //TODO: Why not just stackTraceToString() + private fun throwableToString(t: Throwable): String { + val sw = StringWriter() + val pw = PrintWriter(sw) + t.printStackTrace(pw) + return "${t.message}\n${sw}" + } + + fun buildLogString(logLevel: LogLevel, tag: String, text: String?, e: Throwable? = null): String { + val currentDate = Date(System.currentTimeMillis()); + val timestamp = logDateFormat.format(currentDate); + + val levelString = when (logLevel) { + LogLevel.ERROR -> "e" + LogLevel.WARNING -> "w" + LogLevel.INFORMATION -> "i" + LogLevel.VERBOSE -> "v" + else -> throw Exception("Invalid log level $logLevel") + } + + if (e != null) { + return "($levelString, $tag, ${timestamp}): ${text ?: ""}\n${throwableToString(e)}"; + } else { + return "($levelString, $tag, ${timestamp}): $text"; + } + } + + fun submitLog(file: File): String? { + if (!file.exists()) { + return null; + } + + val requestBody: RequestBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("file", file.name, file.asRequestBody("application/octet-stream".toMediaTypeOrNull())) + .build(); + + val request: Request = Request.Builder() + .url("https://logs.grayjay.app/logs") + //.url("http://192.168.1.231:5413/logs") + .post(requestBody) + .build() + + val client = OkHttpClient() + val response: Response = client.newCall(request).execute() + if (response.isSuccessful) { + val body = response.body?.string(); + return if (body != null) Json.decodeFromString(body) else null; + } else { + Logger.e("Failed to submit log.") { "Failed to submit logs (${response.code}): ${response.body?.string()}" }; + return null; + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/models/CastingDeviceInfo.kt b/app/src/main/java/com/futo/platformplayer/models/CastingDeviceInfo.kt new file mode 100644 index 00000000..a530e415 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/models/CastingDeviceInfo.kt @@ -0,0 +1,18 @@ +package com.futo.platformplayer.models + +import com.futo.platformplayer.casting.CastProtocolType + +@kotlinx.serialization.Serializable +class CastingDeviceInfo { + var name: String; + var type: CastProtocolType; + var addresses: Array; + var port: Int; + + constructor(name: String, type: CastProtocolType, addresses: Array, port: Int) { + this.name = name; + this.type = type; + this.addresses = addresses; + this.port = port; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/models/DiskUsage.kt b/app/src/main/java/com/futo/platformplayer/models/DiskUsage.kt new file mode 100644 index 00000000..0d331596 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/models/DiskUsage.kt @@ -0,0 +1,8 @@ +package com.futo.platformplayer.models + +data class DiskUsage ( + val usage: Long, + val available: Long +) { + val percentage: Double = if((available + usage) > 0) usage.toDouble() / (usage + available) else 0.0; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/models/HistoryVideo.kt b/app/src/main/java/com/futo/platformplayer/models/HistoryVideo.kt new file mode 100644 index 00000000..b6f092a1 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/models/HistoryVideo.kt @@ -0,0 +1,21 @@ +package com.futo.platformplayer.models + +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import com.futo.platformplayer.serializers.OffsetDateTimeSerializer +import java.time.OffsetDateTime + +@kotlinx.serialization.Serializable +class HistoryVideo { + var video: SerializedPlatformVideo; + var position: Long; + + @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) + var date: OffsetDateTime; + + + constructor(video: SerializedPlatformVideo, position: Long, date: OffsetDateTime) { + this.video = video; + this.position = position; + this.date = date; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt b/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt new file mode 100644 index 00000000..1497b52d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt @@ -0,0 +1,51 @@ +package com.futo.platformplayer.models + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.widget.ImageView +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import java.io.File + +data class ImageVariable(val url: String? = null, val resId: Int? = null, val bitmap: Bitmap? = null) { + + fun setImageView(imageView: ImageView, fallbackResId: Int = -1) { + if(bitmap != null) { + Glide.with(imageView) + .load(bitmap) + .into(imageView) + } else if(resId != null) { + Glide.with(imageView) + .load(resId) + .into(imageView) + } else if(!url.isNullOrEmpty()) { + Glide.with(imageView) + .load(url) + .placeholder(R.drawable.placeholder_channel_thumbnail) + .into(imageView); + } else if (fallbackResId != -1) { + Glide.with(imageView) + .load(fallbackResId) + .into(imageView) + } else { + Glide.with(imageView) + .clear(imageView) + } + } + + + companion object { + fun fromUrl(url: String): ImageVariable { + return ImageVariable(url, null, null); + } + fun fromResource(id: Int): ImageVariable { + return ImageVariable(null, id, null); + } + fun fromBitmap(bitmap: Bitmap): ImageVariable { + return ImageVariable(null, null, bitmap); + } + fun fromFile(file: File): ImageVariable { + return ImageVariable.fromBitmap(BitmapFactory.decodeFile(file.absolutePath)); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/models/PlatformVideoWithTime.kt b/app/src/main/java/com/futo/platformplayer/models/PlatformVideoWithTime.kt new file mode 100644 index 00000000..4c304928 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/models/PlatformVideoWithTime.kt @@ -0,0 +1,5 @@ +package com.futo.platformplayer.models + +import com.futo.platformplayer.api.media.models.video.IPlatformVideo + +data class PlatformVideoWithTime(val video: IPlatformVideo, val time: Long); \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/models/Playlist.kt b/app/src/main/java/com/futo/platformplayer/models/Playlist.kt new file mode 100644 index 00000000..d7b1035f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/models/Playlist.kt @@ -0,0 +1,61 @@ +package com.futo.platformplayer.models + +import com.caoccao.javet.values.reference.V8ValueArray +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.platforms.js.models.JSVideo +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.serializers.OffsetDateTimeSerializer +import kotlinx.serialization.Serializable +import java.time.OffsetDateTime +import java.util.* + +@Serializable +class Playlist { + var id: String = UUID.randomUUID().toString(); + var name: String = ""; + var videos: ArrayList = arrayListOf(); + + @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) + var dateCreation: OffsetDateTime = OffsetDateTime.now(); + @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) + var dateUpdate: OffsetDateTime = OffsetDateTime.now(); + @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) + var datePlayed: OffsetDateTime = OffsetDateTime.MIN; + + constructor(){} + constructor(name: String, list: List) { + this.name = name; + this.videos = ArrayList(list); + } + constructor(id: String, name: String, list: List) { + this.id = id; + this.name = name; + this.videos = ArrayList(list); + } + + + companion object { + fun fromV8(config: SourcePluginConfig, obj: V8ValueObject?): Playlist? { + if(obj == null) + return null; + + val contextName = "Playlist"; + + val id = obj.getOrThrow(config, "id", contextName); + val name = obj.getOrThrow(config, "name", contextName); + val videoObjs = obj.getOrThrow(config, "videos", contextName); + + val videos = mutableListOf(); + + for(videoKey in videoObjs.keys) { + val videoObj = videoObjs.get(videoKey); + val jVideo = JSVideo(config, videoObj); + videos.add(jVideo); + } + + return Playlist(id, name, videos.map { SerializedPlatformVideo.fromVideo(it) }); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/models/PlaylistDownloaded.kt b/app/src/main/java/com/futo/platformplayer/models/PlaylistDownloaded.kt new file mode 100644 index 00000000..ed293890 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/models/PlaylistDownloaded.kt @@ -0,0 +1,8 @@ +package com.futo.platformplayer.models + +import com.futo.platformplayer.downloads.PlaylistDownloadDescriptor + +data class PlaylistDownloaded( + val downloadDescriptor: PlaylistDownloadDescriptor, + val playlist: Playlist +); \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/models/SearchType.kt b/app/src/main/java/com/futo/platformplayer/models/SearchType.kt new file mode 100644 index 00000000..368d755b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/models/SearchType.kt @@ -0,0 +1,7 @@ +package com.futo.platformplayer.models + +enum class SearchType { + VIDEO, + CREATOR, + PLAYLIST +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/models/Subscription.kt b/app/src/main/java/com/futo/platformplayer/models/Subscription.kt new file mode 100644 index 00000000..dbf59fd4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/models/Subscription.kt @@ -0,0 +1,31 @@ +package com.futo.platformplayer.models + +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.api.media.models.channels.SerializedChannel +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.serializers.OffsetDateTimeSerializer +import java.time.OffsetDateTime + +@kotlinx.serialization.Serializable +class Subscription { + var channel: SerializedChannel; + + @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) + var lastVideo : OffsetDateTime = OffsetDateTime.MAX; + @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) + var lastLiveStream : OffsetDateTime = OffsetDateTime.MAX; + + var uploadInterval : Int = 0; + + constructor(channel : SerializedChannel) { + this.channel = channel; + } + + fun updateChannel(channel: IPlatformChannel) { + this.channel = SerializedChannel.fromChannel(channel); + } + + fun updateVideoStatus(allVideos: List? = null, liveStreams: List? = null) { + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/models/Telemetry.kt b/app/src/main/java/com/futo/platformplayer/models/Telemetry.kt new file mode 100644 index 00000000..ec4b9935 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/models/Telemetry.kt @@ -0,0 +1,16 @@ +package com.futo.platformplayer.models + +@kotlinx.serialization.Serializable +data class Telemetry( + val id: String, + val applicationId: String, + val versionCode: String, + val versionName: String, + val buildType: String, + val debug: Boolean, + val isUnstableBuild: Boolean, + val time: Long, + val brand: String, + val manufacturer: String, + val model: String +) { } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/models/UrlVideoWithTime.kt b/app/src/main/java/com/futo/platformplayer/models/UrlVideoWithTime.kt new file mode 100644 index 00000000..c41b1f91 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/models/UrlVideoWithTime.kt @@ -0,0 +1,3 @@ +package com.futo.platformplayer.models + +data class UrlVideoWithTime(val url: String, val timeSeconds: Long, val playWhenReady: Boolean); \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/others/Language.kt b/app/src/main/java/com/futo/platformplayer/others/Language.kt new file mode 100644 index 00000000..04a0ba82 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/others/Language.kt @@ -0,0 +1,20 @@ +package com.futo.platformplayer.others + +class Language { + //TODO: Do this differently, somehow map them to ids for multilanguage? + companion object { + val UNKNOWN = "Unknown"; + val ARABIC = "Arabic"; + val SPANISH = "Spanish"; + val FRENCH = "French"; + val HINDI = "Hindi"; + val INDONESIAN = "Indonesian"; + val KOREAN = "Korean"; + val PORTBRAZIL = "Portuguese Brazilian"; + val RUSSIAN = "Russian"; + val THAI = "Thai"; + val TURKISH = "Turkish"; + val VIETNAMESE = "Vietnamese"; + val ENGLISH = "English"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/others/LoginWebViewClient.kt b/app/src/main/java/com/futo/platformplayer/others/LoginWebViewClient.kt new file mode 100644 index 00000000..eed9b04a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/others/LoginWebViewClient.kt @@ -0,0 +1,183 @@ +package com.futo.platformplayer.others + +import android.net.Uri +import android.webkit.* +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.api.media.Serializer +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.api.media.platforms.js.SourceAuth +import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.matchesDomain +import kotlinx.serialization.encodeToString + +class LoginWebViewClient : WebViewClient { + private val LOG_VERBOSE = false; + + private val _pluginConfig: SourcePluginConfig?; + private val _authConfig: SourcePluginAuthConfig; + + private val _client = ManagedHttpClient(); + + val onLogin = Event1(); + val onPageLoaded = Event2() + + constructor(config: SourcePluginConfig) : super() { + _pluginConfig = config; + _authConfig = config.authentication!!; + Logger.i(TAG, "Login [${config.name}]" + + "\nRequired Headers: ${config.authentication?.headersToFind?.joinToString(", ")}" + + "\nRequired Domain Headers: ${Serializer.json.encodeToString(config.authentication?.domainHeadersToFind)}" + + "\nRequired Cookies: ${Serializer.json.encodeToString(config.authentication?.cookiesToFind)}",); + } + constructor(auth: SourcePluginAuthConfig) : super() { + _pluginConfig = null; + _authConfig = auth; + } + + private val headersFoundMap: HashMap> = hashMapOf(); + private val cookiesFoundMap = hashMapOf>(); + private var urlFound = false; + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url); + onPageLoaded.emit(view, url); + } + + override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? { + if(request == null) + return super.shouldInterceptRequest(view, request as WebResourceRequest?); + + if (_authConfig.allowedDomains != null && !_authConfig.allowedDomains.contains(request.url.host)) { + return null; + } + + val domain = request.url.host; + val domainLower = request.url.host?.lowercase(); + if(_authConfig.completionUrl == null) + urlFound = true; + else urlFound = urlFound || request.url == Uri.parse(_authConfig.completionUrl); + + //HEADERS + if(domainLower != null) { + val headersToFind = ((_authConfig.headersToFind?.map { Pair(it.lowercase(), domainLower) } ?: listOf()) + + (_authConfig.domainHeadersToFind?.filter { domainLower.matchesDomain(it.key.lowercase())} + ?.flatMap { it.value.map { header -> Pair(header.lowercase(), it.key.lowercase()) } } ?: listOf())); + + val foundHeaders = request.requestHeaders.filter { requestHeader -> headersToFind.any { it.first.equals(requestHeader.key, true)} && + (!requestHeader.key.equals("Authorization", ignoreCase = true) || requestHeader.value != "undefined") } //TODO: More universal fix (optional regex?) + for(header in foundHeaders) { + for(headerDomain in headersToFind.filter { it.first.equals(header.key, true) }) { + if (!headersFoundMap.containsKey(headerDomain.second)) + headersFoundMap[headerDomain.second] = hashMapOf(); + headersFoundMap[headerDomain.second]!![header.key.lowercase()] = header.value; + } + } + } + + + //COOKIES + //TODO: This is not an ideal solution, we want to intercept the response, but interception need to be rewritten to support that. Correct implementation commented underneath + //TODO: For now we assume cookies are legit for all subdomains of a top-level domain, this is the most common scenario anyway + val cookieString = CookieManager.getInstance().getCookie(request.url.toString()); + if(cookieString != null) { + val domainParts = domain!!.split("."); + val cookieDomain = "." + domainParts.drop(domainParts.size - 2).joinToString("."); + if(_pluginConfig == null || _pluginConfig.allowUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) }) + _authConfig.cookiesToFind?.let { cookiesToFind -> + val cookies = cookieString.split(";"); + for(cookieStr in cookies) { + val cookieSplitIndex = cookieStr.indexOf("="); + if(cookieSplitIndex <= 0) continue; + val cookieKey = cookieStr.substring(0, cookieSplitIndex).trim(); + val cookieVal = cookieStr.substring(cookieSplitIndex + 1).trim(); + + if (_authConfig.cookiesExclOthers && !cookiesToFind.contains(cookieKey)) + continue; + + if (cookiesFoundMap.containsKey(cookieDomain)) + cookiesFoundMap[cookieDomain]!![cookieKey] = cookieVal; + else + cookiesFoundMap[cookieDomain] = hashMapOf(Pair(cookieKey, cookieVal)); + } + }; + } + //Correct implementation if we could get the response here, but we cant it seems at least for any request with a body. + //This checks for the true domain for a cookie + /* + var cookiesFound = _authConfig.cookiesToFind?.let { cookiesToFind -> + for(setCookie in response.responseHeaders.filter { it.key.equals("Set-Cookie", true) }) { + val cookieParts = setCookie.value.split(';'); + if (cookieParts.size == 0) + continue; + val cookieSplitIndex = cookieParts[0].indexOf("="); + val cookieKey = cookieParts[0].substring(0, cookieSplitIndex); + val cookieValue = cookieParts[0].substring(cookieSplitIndex + 1); + + if (_authConfig.cookiesExclOthers && !cookiesToFind.contains(cookieKey)) + continue; + + val cookieVariables = cookieParts.drop(1).map { + val splitIndex = it.indexOf("="); + return@map Pair( + it.substring(0, splitIndex), + it.substring(splitIndex + 1).trim() + ); + }.toMap(); + val domainToUse = if (cookieVariables.containsKey("domain")) + cookieVariables["domain"]!! + else domain!!; + + if (cookiesFoundMap.containsKey(domainToUse)) + cookiesFoundMap[domainToUse]!![cookieKey] = cookieValue; + else + cookiesFoundMap[domainToUse] = hashMapOf(Pair(cookieKey, cookieValue)); + } + return@let cookiesToFind.all { toFind -> cookiesFoundMap.any { it.value.containsKey(toFind) } }; + } ?: true; + */ + + val headersFound = _authConfig.headersToFind?.map { it.lowercase() }?.all { reqHeader -> headersFoundMap.any { it.value.containsKey(reqHeader) } } ?: true + val domainHeadersFound = _authConfig.domainHeadersToFind?.all { + if(it.value.isEmpty()) + return@all true; + if(!headersFoundMap.containsKey(it.key.lowercase())) + return@all false; + val foundDomainHeaders = headersFoundMap[it.key.lowercase()] ?: mapOf(); + return@all it.value.all { reqHeader -> foundDomainHeaders.containsKey(reqHeader.lowercase()) }; + } ?: true; + val cookiesFound = _authConfig.cookiesToFind?.all { toFind -> cookiesFoundMap.any { it.value.containsKey(toFind) } } ?: true; + + if(LOG_VERBOSE) { + val builder = StringBuilder(); + builder.appendLine("Request (method: ${request.method}, host: ${request.url.host}, url: ${request.url}, path: ${request.url.path}):"); + for (pair in request.requestHeaders) { + builder.appendLine(" ${pair.key}: ${pair.value}"); + } + builder.appendLine(" Cookies: ${cookiesFoundMap.values.sumOf { it.values.size }}"); + Logger.i(TAG, builder.toString()); + Logger.i(TAG, "Result (urlFound: $urlFound, headersFound: $headersFound, cookiesFound: $cookiesFound)"); + } + + if (urlFound && headersFound && domainHeadersFound && cookiesFound) { + onLogin.emit(SourceAuth( + cookieMap = cookiesFoundMap, + headers = headersFoundMap /*.associate { headerToFind -> + headerToFind to headersFoundMap.firstNotNullOf { requestHeader -> + if (requestHeader.key.equals(headerToFind, ignoreCase = true)) + requestHeader.value + else null; + } + } ?: mapOf()*/ + )); + } + + return super.shouldInterceptRequest(view, request); + } + + companion object { + private val TAG = "LoginWebViewClient"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/others/PlatformLinkMovementMethod.kt b/app/src/main/java/com/futo/platformplayer/others/PlatformLinkMovementMethod.kt new file mode 100644 index 00000000..dc414440 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/others/PlatformLinkMovementMethod.kt @@ -0,0 +1,74 @@ +package com.futo.platformplayer.others + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.text.Spannable +import android.text.method.LinkMovementMethod +import android.text.style.URLSpan +import android.view.MotionEvent +import android.widget.TextView +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.receivers.MediaControlReceiver +import com.futo.platformplayer.timestampRegex + +class PlatformLinkMovementMethod : LinkMovementMethod { + private val _context: Context; + + constructor(context: Context) : super() { + _context = context; + } + + override fun onTouchEvent(widget: TextView, buffer: Spannable, event: MotionEvent): Boolean { + val action = event.action; + if (action == MotionEvent.ACTION_UP) { + val x = event.x.toInt() - widget.totalPaddingLeft + widget.scrollX; + val y = event.y.toInt() - widget.totalPaddingTop + widget.scrollY; + + val layout = widget.layout; + val line = layout.getLineForVertical(y); + val off = layout.getOffsetForHorizontal(line, x.toFloat()); + val links = buffer.getSpans(off, off, URLSpan::class.java); + + if (links.isNotEmpty()) { + for (link in links) { + Logger.i(TAG) { "Link clicked '${link.url}'." }; + + if (_context is MainActivity) { + if (_context.handleUrl(link.url)) { + continue; + } + + if (timestampRegex.matches(link.url)) { + val tokens = link.url.split(':'); + + var time_s = -1L; + if (tokens.size == 2) { + time_s = tokens[0].toLong() * 60 + tokens[1].toLong(); + } else if (tokens.size == 3) { + time_s = tokens[0].toLong() * 60 * 60 + tokens[1].toLong() * 60 + tokens[2].toLong(); + } + + if (time_s != -1L) { + MediaControlReceiver.onSeekToReceived.emit(time_s * 1000); + continue; + } + } + } + + + _context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url))); + } + + return true; + } + } + + return super.onTouchEvent(widget, buffer, event); + } + + companion object { + val TAG = "PlatformLinkMovementMethod"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/polycentric/PolycentricCache.kt b/app/src/main/java/com/futo/platformplayer/polycentric/PolycentricCache.kt new file mode 100644 index 00000000..5854249e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/polycentric/PolycentricCache.kt @@ -0,0 +1,292 @@ +package com.futo.platformplayer.polycentric + +import com.futo.polycentric.core.* +import userpackage.Protocol +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.constructs.BatchedTaskHandler +import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile +import com.futo.platformplayer.getNowDiffSeconds +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.resolveChannelUrl +import com.futo.platformplayer.serializers.OffsetDateTimeSerializer +import com.futo.platformplayer.stores.CachedPolycentricProfileStorage +import com.futo.platformplayer.stores.FragmentedStorage +import com.google.protobuf.ByteString +import kotlinx.coroutines.* +import kotlinx.serialization.Serializable +import java.nio.ByteBuffer +import java.time.OffsetDateTime + +class PolycentricCache { + data class CachedOwnedClaims(val ownedClaims: List?, val creationTime: OffsetDateTime = OffsetDateTime.now()); + @Serializable + data class CachedPolycentricProfile(val profile: PolycentricProfile?, @Serializable(with = OffsetDateTimeSerializer::class) val creationTime: OffsetDateTime = OffsetDateTime.now()); + + private val _cacheExpirationSeconds = 60 * 60 * 3; + private val _cache = hashMapOf() + private val _profileCache = hashMapOf() + private val _profileUrlCache = FragmentedStorage.get("profileUrlCache") + private val _scope = CoroutineScope(Dispatchers.IO); + + private val _taskGetProfile = BatchedTaskHandler(_scope, { system -> + val signedProfileEvents = ApiMethods.getQueryLatest( + SERVER, + system.toProto(), + listOf( + ContentType.BANNER.value, + ContentType.AVATAR.value, + ContentType.USERNAME.value, + ContentType.DESCRIPTION.value, + ContentType.STORE.value + ) + ).eventsList.map { e -> SignedEvent.fromProto(e) }; + + val storageSystemState = StorageTypeSystemState.create() + for (signedEvent in signedProfileEvents) { + storageSystemState.update(signedEvent.event) + } + + val signedClaimEvents = ApiMethods.getQueryIndex( + SERVER, + system.toProto(), + ContentType.CLAIM.value, + limit = 200 + ).eventsList.map { e -> SignedEvent.fromProto(e) }; + + val ownedClaims: ArrayList = arrayListOf() + for (signedEvent in signedClaimEvents) { + if (signedEvent.event.contentType != ContentType.CLAIM.value) { + continue; + } + + val response = ApiMethods.getQueryReferences( + SERVER, + Protocol.Reference.newBuilder() + .setReference(signedEvent.toPointer().toProto().toByteString()) + .setReferenceType(2) + .build(), + null, + Protocol.QueryReferencesRequestEvents.newBuilder() + .setFromType(ContentType.VOUCH.value) + .build() + ); + + val ownedClaim = response.itemsList.map { SignedEvent.fromProto(it.event) }.getClaimIfValid(signedEvent); + if (ownedClaim != null) { + ownedClaims.add(ownedClaim); + } + } + + Logger.i(TAG, "Retrieved profile (ownedClaims = $ownedClaims)"); + val systemState = SystemState.fromStorageTypeSystemState(storageSystemState); + return@BatchedTaskHandler CachedPolycentricProfile(PolycentricProfile(system, systemState, ownedClaims)); + }, + { system -> return@BatchedTaskHandler getCachedProfile(system); }, + { system, result -> + synchronized(_cache) { + _profileCache[system] = result; + + if (result.profile != null) { + for (claim in result.profile.ownedClaims) { + val url = claim.claim.resolveChannelUrl() ?: continue; + _profileUrlCache.map[url] = result; + } + } + + _profileUrlCache.save(); + } + }); + + private val _batchTaskGetClaims = BatchedTaskHandler(_scope, + { id -> + val resolved = if (id.claimFieldType == -1) ApiMethods.getResolveClaim(SERVER, system, id.claimType.toLong(), id.value!!) + else ApiMethods.getResolveClaim(SERVER, system, id.claimType.toLong(), id.claimFieldType.toLong(), id.value!!); + Logger.v(TAG, "getResolveClaim(url = $SERVER, system = $system, id = $id, claimType = ${id.claimType}, matchAnyField = ${id.value})"); + val protoEvents = resolved.matchesList.flatMap { arrayListOf(it.claim).apply { addAll(it.proofChainList) } } + val resolvedEvents = protoEvents.map { i -> SignedEvent.fromProto(i) }; + return@BatchedTaskHandler CachedOwnedClaims(resolvedEvents.getValidClaims()); + }, + { id -> return@BatchedTaskHandler getCachedValidClaims(id); }, + { id, result -> + synchronized(_cache) { + _cache[id] = result; + } + }); + + private val _batchTaskGetData = BatchedTaskHandler(_scope, + { + val urlData = if (it.startsWith("polycentric://")) { + it.substring("polycentric://".length) + } else it; + + val urlBytes = urlData.base64UrlToByteArray(); + val urlInfo = Protocol.URLInfo.parseFrom(urlBytes); + if (urlInfo.urlType != 4L) { + throw Exception("Only URLInfoDataLink is supported"); + } + + val dataLink = Protocol.URLInfoDataLink.parseFrom(urlInfo.body); + return@BatchedTaskHandler ApiMethods.getDataFromServerAndReassemble(dataLink); + }, + { return@BatchedTaskHandler null }, + { _, _ -> }); + + fun getCachedValidClaims(id: PlatformID, ignoreExpired: Boolean = false): CachedOwnedClaims? { + if (id.claimType <= 0) { + return CachedOwnedClaims(null); + } + + synchronized(_cache) { + val cached = _cache[id] + if (cached == null) { + return null + } + + if (!ignoreExpired && cached.creationTime.getNowDiffSeconds() > _cacheExpirationSeconds) { + return null; + } + + return cached; + } + } + + //TODO: Review all return null in this file, perhaps it should be CachedX(null) instead + fun getValidClaimsAsync(id: PlatformID): Deferred { + if (id.value == null || id.claimType <= 0) { + return _scope.async { CachedOwnedClaims(null) }; + } + + Logger.v(TAG, "getValidClaims (id: $id)") + val def = _batchTaskGetClaims.execute(id); + def.invokeOnCompletion { + if (it == null) { + return@invokeOnCompletion + } + + handleException(it, handleNetworkException = { /* Do nothing (do not cache) */ }, handleOtherException = { + //Cache failed result + synchronized(_cache) { + _cache[id] = CachedOwnedClaims(null); + } + }) + }; + return def; + } + + fun getDataAsync(url: String): Deferred { + return _batchTaskGetData.execute(url); + } + + fun getCachedProfile(url: String, ignoreExpired: Boolean = false): CachedPolycentricProfile? { + synchronized (_profileCache) { + val cached = _profileUrlCache.get(url) ?: return null; + if (!ignoreExpired && cached.creationTime.getNowDiffSeconds() > _cacheExpirationSeconds) { + return null; + } + + return cached; + } + } + + fun getCachedProfile(system: PublicKey, ignoreExpired: Boolean = false): CachedPolycentricProfile? { + synchronized(_profileCache) { + val cached = _profileCache[system] ?: return null; + if (!ignoreExpired && cached.creationTime.getNowDiffSeconds() > _cacheExpirationSeconds) { + return null; + } + + return cached; + } + } + + suspend fun getProfileAsync(id: PlatformID): CachedPolycentricProfile? { + if (id.claimType <= 0) { + return CachedPolycentricProfile(null); + } + + val cachedClaims = getCachedValidClaims(id); + if (cachedClaims != null) { + if (!cachedClaims.ownedClaims.isNullOrEmpty()) { + Logger.v(TAG, "getProfileAsync (id: $id) != null (with cached valid claims)") + return getProfileAsync(cachedClaims.ownedClaims.first().system).await(); + } else { + return null; + } + } else { + Logger.v(TAG, "getProfileAsync (id: $id) no cached valid claims, will be retrieved") + + val claims = getValidClaimsAsync(id).await() + if (!claims.ownedClaims.isNullOrEmpty()) { + Logger.v(TAG, "getProfileAsync (id: $id) != null (with retrieved valid claims)") + return getProfileAsync(claims.ownedClaims.first().system).await() + } else { + return null; + } + } + } + + fun getProfileAsync(system: PublicKey): Deferred { + Logger.i(TAG, "getProfileAsync (system: ${system})") + val def = _taskGetProfile.execute(system); + def.invokeOnCompletion { + if (it == null) { + return@invokeOnCompletion + } + + handleException(it, handleNetworkException = { /* Do nothing (do not cache) */ }, handleOtherException = { + //Cache failed result + synchronized(_cache) { + val cachedProfile = CachedPolycentricProfile(null); + _profileCache[system] = cachedProfile; + } + }) + }; + return def; + } + + private fun handleException(e: Throwable, handleNetworkException: () -> Unit, handleOtherException: () -> Unit) { + val isNetworkException = when(e) { + is java.net.UnknownHostException, + is java.net.SocketTimeoutException, + is java.net.ConnectException -> true + else -> when(e.cause) { + is java.net.UnknownHostException, + is java.net.SocketTimeoutException, + is java.net.ConnectException -> true + else -> false + } + } + if (isNetworkException) { + handleNetworkException() + } else { + handleOtherException() + } + } + + companion object { + private val system = Protocol.PublicKey.newBuilder() + .setKeyType(1) + .setKey(ByteString.copyFrom("gX0eCWctTm6WHVGot4sMAh7NDAIwWsIM5tRsOz9dX04=".base64ToByteArray())) //Production key + //.setKey(ByteString.copyFrom("LeQkzn1j625YZcZHayfCmTX+6ptrzsA+CdAyq+BcEdQ".base64ToByteArray())) //Test key koen-futo + .build(); + + private const val TAG = "PolycentricCache" + const val SERVER = "https://srv1-stg.polycentric.io" + private var _instance: PolycentricCache? = null; + + @JvmStatic + val instance: PolycentricCache + get(){ + if(_instance == null) + _instance = PolycentricCache(); + return _instance!!; + }; + + fun finish() { + _instance?.let { + _instance = null; + it._scope.cancel("PolycentricCache finished"); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/receivers/AudioNoisyReceiver.kt b/app/src/main/java/com/futo/platformplayer/receivers/AudioNoisyReceiver.kt new file mode 100644 index 00000000..a8c3894d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/receivers/AudioNoisyReceiver.kt @@ -0,0 +1,19 @@ +package com.futo.platformplayer.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.futo.platformplayer.logging.Logger + + +class AudioNoisyReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context?, intent: Intent?) { + Logger.i(TAG, "Audio Noisy received"); + MediaControlReceiver.onPauseReceived.emit(); + } + + companion object { + private val TAG = "AudioNoisyReceiver" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/receivers/InstallReceiver.kt b/app/src/main/java/com/futo/platformplayer/receivers/InstallReceiver.kt new file mode 100644 index 00000000..abac844a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/receivers/InstallReceiver.kt @@ -0,0 +1,46 @@ +package com.futo.platformplayer.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event1 + + +class InstallReceiver : BroadcastReceiver() { + private val TAG = "InstallReceiver" + + companion object { + val onReceiveResult = Event1(); + } + + override fun onReceive(context: Context, intent: Intent) { + val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1); + Logger.i(TAG, "Received status $status."); + + when (status) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + val activityIntent = intent.getParcelableExtra(Intent.EXTRA_INTENT) + if (activityIntent == null) { + Logger.w(TAG, "Received STATUS_PENDING_USER_ACTION and activity intent is null.") + return; + } + context.startActivity(activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + PackageInstaller.STATUS_SUCCESS -> onReceiveResult.emit(null); + PackageInstaller.STATUS_FAILURE -> onReceiveResult.emit(context.getString(R.string.general_failure)); + PackageInstaller.STATUS_FAILURE_ABORTED -> onReceiveResult.emit(context.getString(R.string.aborted)); + PackageInstaller.STATUS_FAILURE_BLOCKED -> onReceiveResult.emit(context.getString(R.string.blocked)); + PackageInstaller.STATUS_FAILURE_CONFLICT -> onReceiveResult.emit(context.getString(R.string.conflict)); + PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> onReceiveResult.emit(context.getString(R.string.incompatible)); + PackageInstaller.STATUS_FAILURE_INVALID -> onReceiveResult.emit(context.getString(R.string.invalid)); + PackageInstaller.STATUS_FAILURE_STORAGE -> onReceiveResult.emit(context.getString(R.string.not_enough_storage)); + else -> { + val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE); + onReceiveResult.emit(msg) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/receivers/MediaControlReceiver.kt b/app/src/main/java/com/futo/platformplayer/receivers/MediaControlReceiver.kt new file mode 100644 index 00000000..61bec334 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/receivers/MediaControlReceiver.kt @@ -0,0 +1,69 @@ +package com.futo.platformplayer.receivers + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 + + +class MediaControlReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context?, intent: Intent?) { + val act = intent?.getStringExtra(EXTRA_MEDIA_ACTION); + Logger.i(TAG, "Received MediaControl Event $act"); + + try { + when (act) { + EVENT_PLAY -> onPlayReceived.emit(); + EVENT_PAUSE -> onPauseReceived.emit(); + EVENT_NEXT -> onNextReceived.emit(); + EVENT_PREV -> onPreviousReceived.emit(); + EVENT_CLOSE -> onCloseReceived.emit(); + } + } + catch(ex: Throwable) { + Logger.w(TAG, "Failed to handle intent: ${act}"); + } + } + + companion object { + private val TAG = "MediaControlReceiver" + + const val EXTRA_MEDIA_ACTION = "MediaAction"; + + const val EVENT_PLAY = "Play"; + const val EVENT_PAUSE = "Pause"; + const val EVENT_NEXT = "Next"; + const val EVENT_PREV = "Prev"; + const val EVENT_CLOSE = "Close"; + + val onPlayReceived = Event0(); + val onPauseReceived = Event0(); + val onNextReceived = Event0(); + val onPreviousReceived = Event0(); + val onSeekToReceived = Event1(); + + val onLowerVolumeReceived = Event0(); + + val onCloseReceived = Event0() + + fun getPlayIntent(context: Context, code: Int = 0) : PendingIntent = PendingIntent.getBroadcast(context, code, Intent(context, MediaControlReceiver::class.java).apply { + this.putExtra(EXTRA_MEDIA_ACTION, EVENT_PLAY); + },PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT); + fun getPauseIntent(context: Context, code: Int = 0) : PendingIntent = PendingIntent.getBroadcast(context, code, Intent(context, MediaControlReceiver::class.java).apply { + this.putExtra(EXTRA_MEDIA_ACTION, EVENT_PAUSE); + },PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT); + fun getNextIntent(context: Context, code: Int = 0) : PendingIntent = PendingIntent.getBroadcast(context, code, Intent(context, MediaControlReceiver::class.java).apply { + this.putExtra(EXTRA_MEDIA_ACTION, EVENT_NEXT); + },PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT); + fun getPrevIntent(context: Context, code: Int = 0) : PendingIntent = PendingIntent.getBroadcast(context, code, Intent(context, MediaControlReceiver::class.java).apply { + this.putExtra(EXTRA_MEDIA_ACTION, EVENT_PREV); + },PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT); + fun getCloseIntent(context: Context, code: Int = 0) : PendingIntent = PendingIntent.getBroadcast(context, code, Intent(context, MediaControlReceiver::class.java).apply { + this.putExtra(EXTRA_MEDIA_ACTION, EVENT_CLOSE); + },PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/serializers/FlexibleBooleanSerializer.kt b/app/src/main/java/com/futo/platformplayer/serializers/FlexibleBooleanSerializer.kt new file mode 100644 index 00000000..b73f1492 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/serializers/FlexibleBooleanSerializer.kt @@ -0,0 +1,49 @@ +package com.futo.platformplayer.serializers + +import com.futo.platformplayer.Settings +import com.futo.platformplayer.logging.Logger +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.int +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset + +class FlexibleBooleanSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("FlexibleBoolean", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Boolean) { + encoder.encodeBoolean(value); + } + override fun deserialize(decoder: Decoder): Boolean { + Logger.i("Settings", "Deserializing Flexible Boolean"); + + val element = (decoder as JsonDecoder).decodeJsonElement(); + + if(element.jsonPrimitive.booleanOrNull != null) + return element.jsonPrimitive.boolean; + else if(element.jsonPrimitive.intOrNull != null) + return element.jsonPrimitive.int == 1; + else if(element.jsonPrimitive.isString) { + val strValue = element.jsonPrimitive.content; + val intValue = strValue.toIntOrNull(); + val value = if(intValue != null) + intValue == 1; + else + strValue.toBooleanStrictOrNull() ?: throw SerializationException("Non-Boolean type found in flexible boolean for value [${strValue}]"); + return value; + } + else throw SerializationException("Failed to deserialize flexible boolean with value: ${element.jsonPrimitive.contentOrNull}"); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/serializers/IRatingSerializer.kt b/app/src/main/java/com/futo/platformplayer/serializers/IRatingSerializer.kt new file mode 100644 index 00000000..66b607cb --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/serializers/IRatingSerializer.kt @@ -0,0 +1,39 @@ +package com.futo.platformplayer.serializers + +import com.futo.platformplayer.api.media.models.ratings.* +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.KSerializer +import kotlinx.serialization.PolymorphicSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.encoding.decodeStructure +import kotlinx.serialization.json.* +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset +import kotlin.reflect.KClass + + +class IRatingSerializer() : JsonContentPolymorphicSerializer(IRating::class) { + + override fun selectDeserializer(element: JsonElement): DeserializationStrategy { + val obj = element.jsonObject["type"]; + if(obj?.jsonPrimitive?.isString ?: true) + return when(obj?.jsonPrimitive?.contentOrNull) { + "LIKES" -> RatingLikes.serializer(); + "LIKEDISLIKES" -> RatingLikeDislikes.serializer(); + "SCALE" -> RatingScaler.serializer(); + else -> throw NotImplementedError("Rating Value: ${obj?.jsonPrimitive?.contentOrNull}") + }; + else + return when(element.jsonObject["type"]?.jsonPrimitive?.int) { + RatingType.LIKES.value -> RatingLikes.serializer(); + RatingType.LIKEDISLIKES.value -> RatingLikeDislikes.serializer(); + RatingType.SCALE.value -> RatingScaler.serializer(); + else -> throw NotImplementedError("Rating Value: ${obj?.jsonPrimitive?.int}") + }; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt b/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt new file mode 100644 index 00000000..31fbaadd --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt @@ -0,0 +1,42 @@ +package com.futo.platformplayer.serializers + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset + +class OffsetDateTimeNullableSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("OffsetDateTime", PrimitiveKind.LONG) + + override fun serialize(encoder: Encoder, value: OffsetDateTime?) { + encoder.encodeLong(value?.toEpochSecond() ?: -1); + } + override fun deserialize(decoder: Decoder): OffsetDateTime? { + val epochSecond = decoder.decodeLong(); + if(epochSecond > 9999999999) + return OffsetDateTime.MAX; + else if(epochSecond < -9999999999) + return OffsetDateTime.MIN; + return OffsetDateTime.of(LocalDateTime.ofEpochSecond(epochSecond, 0, ZoneOffset.UTC), ZoneOffset.UTC); + } +} +class OffsetDateTimeSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("OffsetDateTime", PrimitiveKind.LONG) + + override fun serialize(encoder: Encoder, value: OffsetDateTime) { + encoder.encodeLong(value.toEpochSecond()); + } + override fun deserialize(decoder: Decoder): OffsetDateTime { + val epochSecond = Math.max(decoder.decodeLong(), 0); + if(epochSecond > 9999999999) + return OffsetDateTime.MAX; + else if(epochSecond < -9999999999) + return OffsetDateTime.MIN; + return OffsetDateTime.of(LocalDateTime.ofEpochSecond(epochSecond, 0, ZoneOffset.UTC), ZoneOffset.UTC); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/serializers/PlatformContentSerializer.kt b/app/src/main/java/com/futo/platformplayer/serializers/PlatformContentSerializer.kt new file mode 100644 index 00000000..6214d62e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/serializers/PlatformContentSerializer.kt @@ -0,0 +1,37 @@ +package com.futo.platformplayer.serializers + +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent +import com.futo.platformplayer.api.media.models.video.SerializedPlatformNestedContent +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.json.* + + +class PlatformContentSerializer() : JsonContentPolymorphicSerializer(SerializedPlatformContent::class) { + + override fun selectDeserializer(element: JsonElement): DeserializationStrategy { + val obj = element.jsonObject["contentType"]; + + //TODO: Remove this temporary fallback..at some point + if(obj == null && element.jsonObject["isLive"]?.jsonPrimitive?.booleanOrNull != null) + return SerializedPlatformVideo.serializer(); + + if(obj?.jsonPrimitive?.isString ?: true) + return when(obj?.jsonPrimitive?.contentOrNull) { + "MEDIA" -> SerializedPlatformVideo.serializer(); + "NESTED" -> SerializedPlatformNestedContent.serializer(); + "ARTICLE" -> throw NotImplementedError("Articles not yet implemented"); + "POST" -> throw NotImplementedError("Post not yet implemented"); + else -> throw NotImplementedError("Unknown Content Type Value: ${obj?.jsonPrimitive?.int}") + }; + else + return when(obj?.jsonPrimitive?.int) { + ContentType.MEDIA.value -> SerializedPlatformVideo.serializer(); + ContentType.NESTED_VIDEO.value -> SerializedPlatformNestedContent.serializer(); + ContentType.ARTICLE.value -> throw NotImplementedError("Articles not yet implemented"); + ContentType.POST.value -> throw NotImplementedError("Post not yet implemented"); + else -> throw NotImplementedError("Unknown Content Type Value: ${obj?.jsonPrimitive?.int}") + }; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/serializers/VideoDescriptorSerializer.kt b/app/src/main/java/com/futo/platformplayer/serializers/VideoDescriptorSerializer.kt new file mode 100644 index 00000000..b9ffee13 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/serializers/VideoDescriptorSerializer.kt @@ -0,0 +1,19 @@ +package com.futo.platformplayer.serializers + +import com.futo.platformplayer.api.media.models.video.ISerializedVideoSourceDescriptor +import com.futo.platformplayer.api.media.models.video.SerializedVideoMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.video.SerializedVideoNonMuxedSourceDescriptor +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.json.* + + +class VideoDescriptorSerializer() : JsonContentPolymorphicSerializer(ISerializedVideoSourceDescriptor::class) { + + override fun selectDeserializer(element: JsonElement): DeserializationStrategy { + return when(element.jsonObject["isUnMuxed"]?.jsonPrimitive?.boolean) { + false -> SerializedVideoMuxedSourceDescriptor.serializer(); + true -> SerializedVideoNonMuxedSourceDescriptor.serializer(); + else -> throw NotImplementedError("Unknown mux") + }; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt b/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt new file mode 100644 index 00000000..dc094b21 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt @@ -0,0 +1,291 @@ +package com.futo.platformplayer.services + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import androidx.core.app.NotificationCompat +import com.futo.platformplayer.* +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.downloads.VideoDownload +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.Announcement +import com.futo.platformplayer.states.AnnouncementType +import com.futo.platformplayer.states.StateAnnouncement +import com.futo.platformplayer.states.StateDownloads +import com.futo.platformplayer.stores.FragmentedStorage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.net.SocketException +import java.time.OffsetDateTime + +class DownloadService : Service() { + private val TAG = "DownloadService"; + + private val DOWNLOAD_NOTIF_ID = 3; + private val DOWNLOAD_NOTIF_TAG = "download"; + private val DOWNLOAD_NOTIF_CHANNEL_ID = "downloadChannel"; + + //Context + private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Default); + private var _notificationManager: NotificationManager? = null; + private var _notificationChannel: NotificationChannel? = null; + + private val _client = ManagedHttpClient(); + + private var _started = false; + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Logger.i(TAG, "onStartCommand"); + synchronized(this) { + if(_started) + return START_STICKY; + + if(!FragmentedStorage.isInitialized) { + Logger.i(TAG, "Attempted to start DownloadService without initialized files"); + closeDownloadSession(); + return START_NOT_STICKY; + } + _started = true; + } + setupNotificationRequirements(); + notifyDownload(null); + + _callOnStarted?.invoke(this); + _instance = this; + + _scope.launch { + try { + doDownloading(); + } + catch(ex: Throwable) { + try { + StateAnnouncement.instance.registerAnnouncementSession( + Announcement( + "rootDownloadException", + "An root download service exception happened", + ex.message ?: "", + AnnouncementType.SESSION, + OffsetDateTime.now() + ) + ); + } catch(_: Throwable){} + try { + closeDownloadSession(); + } + catch(ex: Throwable) { + + } + } + }; + + return START_STICKY; + } + fun setupNotificationRequirements() { + _notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager; + _notificationChannel = NotificationChannel(DOWNLOAD_NOTIF_CHANNEL_ID, "Temp", NotificationManager.IMPORTANCE_DEFAULT).apply { + this.enableVibration(false); + this.setSound(null, null); + }; + _notificationManager!!.createNotificationChannel(_notificationChannel!!); + } + + override fun onCreate() { + Logger.i(TAG, "onCreate"); + super.onCreate() + } + + override fun onBind(p0: Intent?): IBinder? { + return null; + } + + private suspend fun doDownloading() { + Logger.i(TAG, "doDownloading - Starting Downloads"); + val ignore = mutableListOf(); + var currentVideo: VideoDownload? = StateDownloads.instance.getDownloading().firstOrNull(); + while (currentVideo != null) + { + try { + doDownload(currentVideo); + } + catch(ex: SocketException) { + var msg = ex.message; + if(ex.message == "Software caused connection abort") + msg = "Downloading disabled on current network"; + + Logger.e(TAG, "Failed download [${currentVideo.name}]: ${msg}", ex); + currentVideo.error = msg; + currentVideo.changeState(VideoDownload.State.ERROR); + ignore.add(currentVideo); + + //Give it a sec + Thread.sleep(500); + } + catch(ex: Throwable) { + Logger.e(TAG, "Download failed", ex); + if(currentVideo.video == null && currentVideo.videoDetails == null) { + //Corrupt? + Logger.w(TAG, "Video had no video or videodetail, removing download"); + StateDownloads.instance.removeDownload(currentVideo); + } + else + Logger.e(TAG, "Failed download [${currentVideo.name}]: ${ex.message}", ex); + currentVideo.error = ex.message; + currentVideo.changeState(VideoDownload.State.ERROR); + ignore.add(currentVideo); + + StateAnnouncement.instance.registerAnnouncement(currentVideo?.id?.value?:"" + currentVideo?.id?.pluginId?:"" + "_FailDownload", + "Download failed", + "Download for [${currentVideo.name}] failed.\nDownloads are automatically retried.\nReason: ${ex.message}", AnnouncementType.SESSION, null, "download"); + + //Give it a sec + Thread.sleep(500); + } + StateDownloads.instance.updateDownloading(currentVideo); + + currentVideo = StateDownloads.instance.getDownloading().filter { !ignore.contains(it) }.firstOrNull(); + } + Logger.i(TAG, "doDownloading - Ending Downloads"); + stopService(this); + } + private suspend fun doDownload(download: VideoDownload) { + if(!Settings.instance.downloads.shouldDownload()) + throw IllegalStateException("Downloading disabled on current network"); + + if((download.prepareTime?.getNowDiffMinutes() ?: 99) > 15) { + Logger.w(TAG, "Video Download [${download.name}] expired, re-preparing"); + download.videoDetails = null; + + if(download.targetPixelCount == null && download.videoSource != null) + download.targetPixelCount = (download.videoSource!!.width * download.videoSource!!.height).toLong(); + download.videoSource = null; + if(download.targetBitrate == null && download.audioSource != null) + download.targetBitrate = download.audioSource!!.bitrate.toLong(); + download.audioSource = null; + } + if(download.videoDetails == null || (download.videoSource == null && download.audioSource == null)) + download.changeState(VideoDownload.State.PREPARING); + notifyDownload(download); + + Logger.i(TAG, "Preparing [${download.name}] started"); + if(download.state == VideoDownload.State.PREPARING) + download.prepare(); + download.changeState(VideoDownload.State.DOWNLOADING); + notifyDownload(download); + + var lastNotifyTime: Long = 0L; + Logger.i(TAG, "Downloading [${download.name}] started"); + //TODO: Use plugin client? + download.download(_client) { progress -> + download.progress = progress; + + val currentTime = System.currentTimeMillis(); + if (currentTime - lastNotifyTime > 500) { + notifyDownload(download); + lastNotifyTime = currentTime; + } + }; + Logger.i(TAG, "Download [${download.name}] finished"); + + download.changeState(VideoDownload.State.VALIDATING); + notifyDownload(download); + + Logger.i(TAG, "Validating [${download.name}]"); + download.validate(); + download.changeState(VideoDownload.State.FINALIZING); + notifyDownload(download); + + Logger.i(TAG, "Completing [${download.name}]"); + download.complete(); + download.changeState(VideoDownload.State.COMPLETED); + + StateDownloads.instance.removeDownload(download); + notifyDownload(download); + } + + private fun notifyDownload(download: VideoDownload?) { + val channel = _notificationChannel ?: return; + + val bringUpIntent = Intent(this, MainActivity::class.java); + bringUpIntent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + bringUpIntent.action = "TAB"; + bringUpIntent.putExtra("TAB", "Downloads"); + + var builder = if(download != null) + NotificationCompat.Builder(this, DOWNLOAD_NOTIF_TAG) + .setSmallIcon(R.drawable.ic_download) + .setOngoing(true) + .setContentIntent(PendingIntent.getActivity(this, 5, bringUpIntent, PendingIntent.FLAG_IMMUTABLE)) + .setContentTitle("${download.state}: ${download.name}") + .setContentText(download.getDownloadInfo()) + .setProgress(100, (download.progress * 100).toInt(), download.progress == 0.0) + .setChannelId(channel.id) + else + NotificationCompat.Builder(this, DOWNLOAD_NOTIF_TAG) + .setSmallIcon(R.drawable.ic_download) + .setOngoing(true) + .setContentIntent(PendingIntent.getActivity(this, 5, bringUpIntent, PendingIntent.FLAG_IMMUTABLE)) + .setContentTitle("Preparing for download...") + .setContentText("Initializing download process...") + .setChannelId(channel.id) + + val notif = builder.build(); + notif.flags = notif.flags or NotificationCompat.FLAG_ONGOING_EVENT or NotificationCompat.FLAG_NO_CLEAR; + + startForeground(DOWNLOAD_NOTIF_ID, notif); + } + + fun closeDownloadSession() { + Logger.i(TAG, "closeDownloadSession"); + stopForeground(true); + _notificationManager?.cancel(DOWNLOAD_NOTIF_ID); + stopService(); + _started = false; + super.stopSelf(); + } + override fun onDestroy() { + Logger.i(TAG, "onDestroy"); + _instance = null; + _scope.cancel("onDestroy"); + super.onDestroy(); + } + + companion object { + private var _instance: DownloadService? = null; + private var _callOnStarted: ((DownloadService)->Unit)? = null; + + @Synchronized + fun getOrCreateService(context: Context, handle: ((DownloadService)->Unit)? = null) { + if(!FragmentedStorage.isInitialized) + return; + if(_instance == null) { + _callOnStarted = handle; + val intent = Intent(context, DownloadService::class.java); + context.startForegroundService(intent); + } + else _instance?.let { + if(handle != null) + handle(it); + } + } + @Synchronized + fun getService() : DownloadService? { + return _instance; + } + + @Synchronized + fun stopService(service: DownloadService? = null) { + (service ?: _instance)?.let { + if(_instance == it) + _instance = null; + it.closeDownloadSession(); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/services/ExportingService.kt b/app/src/main/java/com/futo/platformplayer/services/ExportingService.kt new file mode 100644 index 00000000..c76991f4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/services/ExportingService.kt @@ -0,0 +1,225 @@ +package com.futo.platformplayer.services + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.content.FileProvider +import com.futo.platformplayer.* +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.downloads.VideoExport +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.Announcement +import com.futo.platformplayer.states.AnnouncementType +import com.futo.platformplayer.states.StateAnnouncement +import com.futo.platformplayer.states.StateDownloads +import com.futo.platformplayer.stores.FragmentedStorage +import kotlinx.coroutines.* +import java.time.OffsetDateTime +import java.util.UUID + + +class ExportingService : Service() { + private val TAG = "ExportingService"; + + private val EXPORT_NOTIF_ID = 4; + private val EXPORT_NOTIF_TAG = "export"; + private val EXPORT_NOTIF_CHANNEL_ID = "exportChannel"; + + //Context + private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Default); + private var _notificationManager: NotificationManager? = null; + private var _notificationChannel: NotificationChannel? = null; + + private val _client = ManagedHttpClient(); + + private var _started = false; + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Logger.i(TAG, "onStartCommand"); + + synchronized(this) { + if(_started) + return START_STICKY; + + if(!FragmentedStorage.isInitialized) { + closeExportSession(); + return START_NOT_STICKY; + } + + _started = true; + } + setupNotificationRequirements(); + + _callOnStarted?.invoke(this); + _instance = this; + + _scope.launch { + try { + doExporting(); + } + catch(ex: Throwable) { + try { + StateAnnouncement.instance.registerAnnouncementSession( + Announcement( + "rootExportException", + "An root export service exception happened", + ex.message ?: "", + AnnouncementType.SESSION, + OffsetDateTime.now() + ) + ); + } catch(_: Throwable){} + } + }; + + return START_STICKY; + } + fun setupNotificationRequirements() { + _notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager; + _notificationChannel = NotificationChannel(EXPORT_NOTIF_CHANNEL_ID, "Temp", NotificationManager.IMPORTANCE_DEFAULT).apply { + this.enableVibration(false); + this.setSound(null, null); + }; + _notificationManager!!.createNotificationChannel(_notificationChannel!!); + } + + override fun onCreate() { + Logger.i(TAG, "onCreate"); + super.onCreate() + } + + override fun onBind(p0: Intent?): IBinder? { + return null; + } + + private suspend fun doExporting() { + Logger.i(TAG, "doExporting - Starting Exports"); + val ignore = mutableListOf(); + var currentExport: VideoExport? = StateDownloads.instance.getExporting().firstOrNull(); + while (currentExport != null) + { + try{ + notifyExport(currentExport); + doExport(currentExport); + } + catch(ex: Throwable) { + Logger.e(TAG, "Failed export [${currentExport.videoLocal.name}]: ${ex.message}", ex); + currentExport.error = ex.message; + currentExport.changeState(VideoExport.State.ERROR); + ignore.add(currentExport); + + //Give it a sec + Thread.sleep(500); + } + + currentExport = StateDownloads.instance.getExporting().filter { !ignore.contains(it) }.firstOrNull(); + } + Logger.i(TAG, "doExporting - Ending Exports"); + stopService(this); + } + + private suspend fun doExport(export: VideoExport) { + Logger.i(TAG, "Exporting [${export.videoLocal.name}] started"); + + export.changeState(VideoExport.State.EXPORTING); + + var lastNotifyTime: Long = 0L; + val file = export.export { progress -> + export.progress = progress; + + val currentTime = System.currentTimeMillis(); + if (currentTime - lastNotifyTime > 500) { + notifyExport(export); + lastNotifyTime = currentTime; + } + } + export.changeState(VideoExport.State.COMPLETED); + Logger.i(TAG, "Export [${export.videoLocal.name}] finished"); + StateDownloads.instance.removeExport(export); + notifyExport(export); + + withContext(Dispatchers.Main) { + StateAnnouncement.instance.registerAnnouncement(UUID.randomUUID().toString(), "File exported", "Exported [${file.path}]", AnnouncementType.SESSION, time = null, category = "download", actionButton = "Open") { + file.share(this@ExportingService); + }; + } + } + + private fun notifyExport(export: VideoExport) { + val channel = _notificationChannel ?: return; + + val bringUpIntent = Intent(this, MainActivity::class.java); + bringUpIntent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + bringUpIntent.action = "TAB"; + bringUpIntent.putExtra("TAB", "Exports"); + + var builder = NotificationCompat.Builder(this, EXPORT_NOTIF_TAG) + .setSmallIcon(R.drawable.ic_export) + .setOngoing(true) + .setContentIntent(PendingIntent.getActivity(this, 5, bringUpIntent, PendingIntent.FLAG_IMMUTABLE)) + .setContentTitle("${export.state}: ${export.videoLocal.name}") + .setContentText(export.getExportInfo()) + .setProgress(100, (export.progress * 100).toInt(), export.progress == 0.0) + .setChannelId(channel.id) + + val notif = builder.build(); + notif.flags = notif.flags or NotificationCompat.FLAG_ONGOING_EVENT or NotificationCompat.FLAG_NO_CLEAR; + + startForeground(EXPORT_NOTIF_ID, notif); + } + + fun closeExportSession() { + Logger.i(TAG, "closeExportSession"); + stopForeground(true); + _notificationManager?.cancel(EXPORT_NOTIF_ID); + stopService(); + _started = false; + super.stopSelf(); + } + override fun onDestroy() { + Logger.i(TAG, "onDestroy"); + _instance = null; + _scope.cancel("onDestroy"); + super.onDestroy(); + } + + companion object { + private var _instance: ExportingService? = null; + private var _callOnStarted: ((ExportingService)->Unit)? = null; + + @Synchronized + fun getOrCreateService(context: Context, handle: ((ExportingService)->Unit)? = null) { + if(!FragmentedStorage.isInitialized) + return; + if(_instance == null) { + _callOnStarted = handle; + val intent = Intent(context, ExportingService::class.java); + context.startForegroundService(intent); + } + else _instance?.let { + if(handle != null) + handle(it); + } + } + @Synchronized + fun getService() : ExportingService? { + return _instance; + } + + @Synchronized + fun stopService(service: ExportingService? = null) { + (service ?: _instance)?.let { + if(_instance == it) + _instance = null; + it.closeExportSession(); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/services/MediaPlaybackService.kt b/app/src/main/java/com/futo/platformplayer/services/MediaPlaybackService.kt new file mode 100644 index 00000000..c4852057 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/services/MediaPlaybackService.kt @@ -0,0 +1,385 @@ +package com.futo.platformplayer.services + +import android.app.* +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.media.AudioManager.OnAudioFocusChangeListener +import android.media.MediaMetadata +import android.os.IBinder +import android.os.SystemClock +import android.support.v4.media.MediaMetadataCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import android.util.Log +import androidx.core.app.NotificationCompat +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.R +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.receivers.MediaControlReceiver +import com.futo.platformplayer.stores.FragmentedStorage + +class MediaPlaybackService : Service() { + private val TAG = "MediaPlaybackService"; + + private val MEDIA_NOTIF_ID = 2; + private val MEDIA_NOTIF_TAG = "media"; + private val MEDIA_NOTIF_CHANNEL_ID = "mediaChannel"; + private val MEDIA_NOTIF_CHANNEL_NAME = "Player"; + + //Notifs + private var _notif_last_video: IPlatformVideo? = null; + private var _notif_last_bitmap: Bitmap? = null; + + //Context + private var _audioManager: AudioManager? = null; + private var _notificationManager: NotificationManager? = null; + private var _notificationChannel: NotificationChannel? = null; + private var _mediaSession: MediaSessionCompat? = null; + private var _hasFocus: Boolean = false; + private var _focusRequest: AudioFocusRequest? = null; + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Logger.i(TAG, "onStartCommand"); + + + if(!FragmentedStorage.isInitialized) { + Logger.i(TAG, "Attempted to start MediaPlaybackService without initialized files"); + closeMediaSession(); + return START_NOT_STICKY; + } + + try { + + setupNotificationRequirements(); + + notifyMediaSession(null, null); + + _callOnStarted?.invoke(this); + _instance = this; + } + catch(ex: Throwable) { + Logger.e(TAG, "Failed to start MediaPlaybackService due to: " + ex.message, ex); + closeMediaSession(); + return START_NOT_STICKY; + } + + return START_STICKY; + } + fun setupNotificationRequirements() { + _audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager; + _notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager; + _notificationChannel = NotificationChannel(MEDIA_NOTIF_CHANNEL_ID, MEDIA_NOTIF_CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH).apply { + this.enableVibration(false); + this.setSound(null, null); + }; + _notificationManager!!.createNotificationChannel(_notificationChannel!!); + + _mediaSession = MediaSessionCompat(this, "PlayerState"); + _mediaSession?.setPlaybackState(PlaybackStateCompat.Builder() + .setState(PlaybackStateCompat.STATE_PLAYING, 0, 1f) + .build()); + _mediaSession?.setCallback(object: MediaSessionCompat.Callback() { + override fun onSeekTo(pos: Long) { + super.onSeekTo(pos) + Log.i(TAG, "Media session callback onSeekTo(pos = $pos)"); + MediaControlReceiver.onSeekToReceived.emit(pos); + } + + override fun onPlay() { + super.onPlay(); + Log.i(TAG, "Media session callback onPlay()"); + MediaControlReceiver.onPlayReceived.emit(); + } + + override fun onPause() { + super.onPause(); + Log.i(TAG, "Media session callback onPause()"); + MediaControlReceiver.onPauseReceived.emit(); + } + + override fun onStop() { + super.onStop(); + Log.i(TAG, "Media session callback onStop()"); + MediaControlReceiver.onCloseReceived.emit(); + } + + override fun onSkipToPrevious() { + super.onSkipToPrevious(); + Log.i(TAG, "Media session callback onSkipToPrevious()"); + MediaControlReceiver.onPreviousReceived.emit(); + } + }); + } + + override fun onCreate() { + Logger.i(TAG, "onCreate called"); + super.onCreate() + } + + override fun onDestroy() { + Logger.i(TAG, "onDestroy called"); + _instance = null; + MediaControlReceiver.onCloseReceived.emit(); + super.onDestroy(); + } + + override fun onBind(p0: Intent?): IBinder? { + return null; + } + + fun closeMediaSession() { + Logger.i(TAG, "closeMediaSession called"); + stopForeground(true); + + val focusRequest = _focusRequest; + if (focusRequest != null) { + _audioManager?.abandonAudioFocusRequest(focusRequest); + _focusRequest = null; + } + _hasFocus = false; + + _notificationManager?.cancel(MEDIA_NOTIF_ID); + _notif_last_video = null; + _notif_last_bitmap = null; + _mediaSession = null; + + if(_instance == this) + _instance = null; + this.stopSelf(); + } + + fun updateMediaSession(videoUpdated: IPlatformVideo?) { + Logger.i(TAG, "updateMediaSession called"); + var isUpdating = false; + val video: IPlatformVideo; + if(videoUpdated == null) { + val notifLastVideo = _notif_last_video ?: return; + video = notifLastVideo; + isUpdating = true; + } + else + video = videoUpdated; + + if(_notificationChannel == null || _mediaSession == null) + setupNotificationRequirements(); + + _mediaSession?.setMetadata( + MediaMetadataCompat.Builder() + .putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name) + .putString(MediaMetadata.METADATA_KEY_TITLE, video.name) + .putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000) + .build()); + + val thumbnail = video.thumbnails.getHQThumbnail(); + + _notif_last_video = video; + + if(isUpdating) + notifyMediaSession(video, _notif_last_bitmap); + else if(thumbnail != null) { + notifyMediaSession(video, null); + val tag = video; + Glide.with(this).asBitmap() + .load(thumbnail) + .into(object: CustomTarget() { + override fun onResourceReady(resource: Bitmap,transition: Transition?) { + if(tag == _notif_last_video) + notifyMediaSession(video, resource) + } + override fun onLoadCleared(placeholder: Drawable?) { + if(tag == _notif_last_video) + notifyMediaSession(video, null) + } + }); + } + else + notifyMediaSession(video, null); + } + private fun generateMediaAction(context: Context, icon: Int, title: String, intent: PendingIntent) : NotificationCompat.Action { + return NotificationCompat.Action.Builder(icon, title, intent).build(); + } + private fun notifyMediaSession(video: IPlatformVideo?, desiredBitmap: Bitmap?) { + val channel = _notificationChannel ?: return; + val session = _mediaSession ?: return; + val icon = StatePlatform.instance.getPlatformIcon(video?.id?.pluginId)?.resId ?: R.drawable.ic_play_white_nopad; + var bitmap = desiredBitmap; + + val bringUpIntent = Intent(this, MainActivity::class.java); + bringUpIntent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + + val hasQueue = StatePlayer.instance.getNextQueueItem() != null; + + /* Fixes album art on older devices, not sure we wanna use it yet. + if(desiredBitmap != null) { + _mediaSession?.setMetadata( + MediaMetadataCompat.Builder() + .putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name) + .putString(MediaMetadata.METADATA_KEY_TITLE, video.name) + .putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, desiredBitmap) + .putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000) + .build()); + }*/ + + val deleteIntent = MediaControlReceiver.getCloseIntent(this, 99); + var builder = NotificationCompat.Builder(this, MEDIA_NOTIF_TAG) + .setSmallIcon(icon) + .setOngoing(true) + .setContentIntent(PendingIntent.getActivity(this, 5, bringUpIntent, PendingIntent.FLAG_IMMUTABLE)) + .setStyle(if(hasQueue) + androidx.media.app.NotificationCompat.MediaStyle() + .setMediaSession(session.sessionToken) + .setShowActionsInCompactView(0, 1, 2) + else + androidx.media.app.NotificationCompat.MediaStyle() + .setMediaSession(session.sessionToken) + .setShowActionsInCompactView(0)) + .setDeleteIntent(deleteIntent) + .setChannelId(channel.id) + + val playWhenReady = StatePlayer.instance.isPlaying; + + if(hasQueue) + builder = builder.addAction(generateMediaAction(this, R.drawable.ic_fast_rewind_notif, "Back", MediaControlReceiver.getPrevIntent(this, 3))) + + if(playWhenReady) + builder = builder.addAction(generateMediaAction(this, R.drawable.ic_pause_notif, "Pause", MediaControlReceiver.getPauseIntent(this, 2))); + else + builder = builder.addAction(generateMediaAction(this, R.drawable.ic_play_notif, "Play", MediaControlReceiver.getPlayIntent(this, 1))); + + if(hasQueue) + builder = builder.addAction(generateMediaAction(this, R.drawable.ic_fast_forward_notif, "Forward", MediaControlReceiver.getNextIntent(this, 4))); + + builder = builder.addAction(generateMediaAction(this, R.drawable.ic_stop_notif, "Stop", MediaControlReceiver.getCloseIntent(this, 5))); + + if(bitmap?.isRecycled ?: false) + bitmap = null; + if(bitmap != null) + builder.setLargeIcon(bitmap); + + val notif = builder.build(); + notif.flags = notif.flags or NotificationCompat.FLAG_ONGOING_EVENT or NotificationCompat.FLAG_NO_CLEAR; + + Logger.i(TAG, "Updating notification bitmap=${if (bitmap != null) "not null" else "null"} channelId=${channel.id} icon=${icon} video=${video?.name ?: ""} playWhenReady=${playWhenReady} session.sessionToken=${session.sessionToken}"); + + startForeground(MEDIA_NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK); + + _notif_last_bitmap = bitmap; + } + + fun updateMediaSessionPlaybackState(state: Int, pos: Long) { + _mediaSession?.setPlaybackState( + PlaybackStateCompat.Builder() + .setActions( + PlaybackStateCompat.ACTION_SEEK_TO or + PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_PAUSE or + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or + PlaybackStateCompat.ACTION_PLAY_PAUSE + ) + .setState(state, pos, 1f, SystemClock.elapsedRealtime()) + .build()); + + if(_focusRequest == null) + setAudioFocus(); + } + + //TODO: (TBD) This code probably more fitting inside FutoVideoPlayer, as this service is generally only used for global events + private fun setAudioFocus() { + Log.i(TAG, "Requested audio focus."); + + val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAcceptsDelayedFocusGain(true) + .setOnAudioFocusChangeListener(_audioFocusChangeListener) + .build() + + _focusRequest = focusRequest; + val result = _audioManager?.requestAudioFocus(focusRequest) + Log.i(TAG, "Audio focus request result $result"); + if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + //TODO: Handle when not possible to get audio focus + _hasFocus = true; + Log.i(TAG, "Audio focus received"); + } + } + + private val _audioFocusChangeListener = + OnAudioFocusChangeListener { focusChange -> + try { + when (focusChange) { + AudioManager.AUDIOFOCUS_GAIN -> { + //Do not start playing on gaining audo focus + //MediaControlReceiver.onPlayReceived.emit(); + _hasFocus = true; + Log.i(TAG, "Audio focus gained"); + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + MediaControlReceiver.onPauseReceived.emit(); + Log.i(TAG, "Audio focus transient loss"); + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + Log.i(TAG, "Audio focus transient loss, can duck"); + } + AudioManager.AUDIOFOCUS_LOSS -> { + _hasFocus = false; + MediaControlReceiver.onPauseReceived.emit(); + Log.i(TAG, "Audio focus lost"); + + val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val runningAppProcesses = activityManager.runningAppProcesses + for (processInfo in runningAppProcesses) { + // Check the importance of the running app process + if (processInfo.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) { + // This app is in the foreground, which might have caused the loss of audio focus + Log.i("AudioFocus", "App ${processInfo.processName} might have caused the loss of audio focus") + } + } + } + } + } catch(ex: Throwable) { + Logger.w(TAG, "Failed to handle audio focus event", ex); + } + } + + companion object { + private const val TAG = "MediaPlaybackService"; + private var _ignore = false; + private var _instance: MediaPlaybackService? = null; + + private var _callOnStarted: ((MediaPlaybackService)->Unit)? = null; + + @Synchronized + fun getOrCreateService(context: Context, handle: (MediaPlaybackService)->Unit) { + if(_instance == null) { + _callOnStarted = handle; + val intent = Intent(context, MediaPlaybackService::class.java); + context.startForegroundService(intent); + } + else _instance?.let { + handle(it); + } + } + @Synchronized + fun getService() : MediaPlaybackService? { + return _instance; + } + + @Synchronized + fun closeService() { + _instance?.let { + _instance = null; + it.closeMediaSession(); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateAnnouncement.kt b/app/src/main/java/com/futo/platformplayer/states/StateAnnouncement.kt new file mode 100644 index 00000000..76d06783 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/states/StateAnnouncement.kt @@ -0,0 +1,353 @@ +package com.futo.platformplayer.states + +import android.content.Context +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringHashSetStorage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import java.time.OffsetDateTime +import java.util.Random +import java.util.UUID + +class StateAnnouncement { + private val _lock = Object(); + + private val _sessionAnnouncementsNever = FragmentedStorage.get("announcementNeverSession"); + private val _sessionAnnouncements: HashMap = hashMapOf(); + private val _sessionActions: HashMapUnit> = hashMapOf(); + + private val _announcementsNever = FragmentedStorage.get("announcementNever"); + private val _announcementsStore = FragmentedStorage.storeJson("announcements").load(); + private val _announcementsClosed = HashSet(); + + val onAnnouncementChanged = Event0(); + + suspend fun loadAnnouncements() { + Logger.i(TAG, "Loading announcements") + + withContext(Dispatchers.IO) { + try { + val client = ManagedHttpClient(); + val response = client.get("https://announcements.grayjay.app/grayjay.json"); + if (response.isOk && response.body != null) { + val body = response.body.string(); + val announcements = Json.decodeFromString>(body); + + synchronized(_lock) { + for (announcement in announcements) { + if (_sessionAnnouncements.containsKey(announcement.id)) + return@synchronized; + + if (!_announcementsStore.hasItem { it.id == announcement.id }) { + _announcementsStore.saveAsync(announcement); + } + } + } + + withContext(Dispatchers.Main) { + onAnnouncementChanged.emit(); + } + } + } catch (e: Throwable) { + Logger.w(TAG, "Failed to load announcements", e) + } + } + + Logger.i(TAG, "Finished loading announcements") + } + + fun registerAnnouncement(id: String?, title: String, msg: String, announceType: AnnouncementType = AnnouncementType.SESSION, time: OffsetDateTime? = null, category: String? = null, actionButton: String, action: ((announcement: Announcement)->Unit)) { + synchronized(_lock) { + val idActual = id ?: UUID.randomUUID().toString(); + val announcement = SessionAnnouncement(idActual, title, msg, announceType, time, category, actionButton, idActual); + + if(action != null) + _sessionActions.put(idActual, action); + registerAnnouncementSession(announcement); + } + } + fun registerAnnouncement(id: String?, title: String, msg: String, announceType: AnnouncementType = AnnouncementType.SESSION, time: OffsetDateTime? = null, category: String? = null, actionButton: String, action: ((announcement: Announcement)->Unit), cancelButton: String? = null, cancelAction: ((announcement: Announcement)-> Unit)? = null) { + synchronized(_lock) { + val idActual = id ?: UUID.randomUUID().toString(); + val announcement = SessionAnnouncement(idActual, title, msg, announceType, time, category, actionButton, idActual, cancelButton, if(cancelAction != null) idActual + "_cancel" else null); + + if(action != null) + _sessionActions.put(idActual, action); + if(cancelAction != null) + _sessionActions.put(idActual + "_cancel", cancelAction); + registerAnnouncementSession(announcement); + } + } + fun registerAnnouncement(id: String?, title: String, msg: String, announceType: AnnouncementType = AnnouncementType.DELETABLE, time: OffsetDateTime? = null, category: String? = null, actionButton: String? = null, actionId: String? = null) { + val newAnnouncement = Announcement(if(id == null) UUID.randomUUID().toString() else id, title, msg, announceType, time, category, actionButton, actionId); + + if(announceType == AnnouncementType.SESSION || announceType == AnnouncementType.SESSION_RECURRING) + registerAnnouncementSession(newAnnouncement); + else + registerAnnouncement(newAnnouncement); + } + fun registerAnnouncementSession(announcement: Announcement) { + synchronized(_lock) { + _sessionAnnouncements.put(announcement.id, announcement); + } + + onAnnouncementChanged.emit(); + } + fun registerAnnouncement(announcement: Announcement) { + synchronized(_lock) { + if(_sessionAnnouncements.containsKey(announcement.id)) + return@synchronized; + + if (!_announcementsStore.hasItem { it.id == announcement.id }) { + _announcementsStore.saveAsync(announcement); + } + } + + onAnnouncementChanged.emit(); + } + + fun getVisibleAnnouncements(category: String? = null): List { + synchronized(_lock) { + if (category != null) { + return _announcementsStore.getItems().filter { it.category == category && !_announcementsNever.contains(it.id) && !_announcementsClosed.contains(it.id) } + + _sessionAnnouncements.values.filter { it.category == category && !_sessionAnnouncementsNever.contains(it.id) && !_announcementsClosed.contains(it.id) } + } else { + return _announcementsStore.getItems().filter { !_announcementsNever.contains(it.id) && !_announcementsClosed.contains(it.id) } + + _sessionAnnouncements.values.filter { !_sessionAnnouncementsNever.contains(it.id) && !_announcementsClosed.contains(it.id) } + } + } + } + + fun closeAnnouncement(id: String) { + val item: Announcement?; + synchronized(_lock) { + item = _announcementsStore.findItem { it.id == id }; + + if (item != null) { + when (item.announceType) { + AnnouncementType.DELETABLE -> { + neverAnnouncement(item.id); + } + AnnouncementType.SESSION -> { + deleteAnnouncement(item.id); + } + else -> { + _announcementsClosed.add(item.id); + } + } + } + val itemSession = _sessionAnnouncements.get(id); + if(itemSession != null) { + when (itemSession.announceType) { + AnnouncementType.DELETABLE -> { + neverAnnouncement(itemSession.id); + } + AnnouncementType.SESSION -> { + deleteAnnouncement(itemSession.id); + + if(itemSession is SessionAnnouncement) + cancelActionAnnouncement(itemSession); + } + else -> { + _announcementsClosed.add(itemSession.id); + } + } + } + } + if(item is SessionAnnouncement) { + if(item.cancelActionId != null) { + val cancelAction = _sessionActions[item.cancelActionId]; + cancelAction?.invoke(item); + } + } + } + + fun deleteAllAnnouncements() { + synchronized(_lock) { + val items = _announcementsStore.getItems().toList(); + for (item in items) { + _announcementsStore.delete(item); + } + + val sessionItems = _sessionAnnouncements.toList(); + for (item in sessionItems) { + _sessionAnnouncements.remove(item.first); + } + } + + onAnnouncementChanged.emit(); + } + + fun deleteAnnouncement(id: String) { + synchronized(_lock) { + val item = _announcementsStore.findItem { it.id == id }; + if (item != null) + _announcementsStore.delete(item); + val itemSession = _sessionAnnouncements.get(id); + if(itemSession != null) + _sessionAnnouncements.remove(id); + } + + onAnnouncementChanged.emit(); + } + fun neverAnnouncement(id: String) { + synchronized(_lock) { + val item = _announcementsStore.findItem { it.id == id }; + if (item != null && !_announcementsNever.contains(id)) + _announcementsNever.add(id); + val itemSession = _sessionAnnouncements.get(id); + if(itemSession != null && !_sessionAnnouncementsNever.contains(id)) + _sessionAnnouncementsNever.add(id); + } + + _sessionAnnouncementsNever.save(); + _announcementsNever.save(); + onAnnouncementChanged.emit(); + } + fun actionAnnouncement(id: String) { + val item = _announcementsStore.findItem { it.id == id } ?: _sessionAnnouncements[id]; + if(item != null) + actionAnnouncement(item); + } + fun actionAnnouncement(item: Announcement) { + val action = _sessionActions[item.id]; + if (action != null) { + action(item); + } else { + when (item.actionId) { + ACTION_NEVER -> neverAnnouncement(item.id); + ACTION_SOMETHING -> actionSomething(); + } + } + } + fun cancelActionAnnouncement(id: String) { + val item = _announcementsStore.findItem { it.id == id } ?: _sessionAnnouncements[id]; + if(item != null) + cancelActionAnnouncement(item); + } + fun cancelActionAnnouncement(item: Announcement) { + if(item is SessionAnnouncement && item.cancelActionId != null) { + val action = _sessionActions[item.cancelActionId]; + action?.invoke(item); + } + } + + fun resetAnnouncements() { + _announcementsClosed.clear(); + _announcementsNever.values.clear(); + _announcementsNever.save(); + _sessionAnnouncementsNever.values.clear(); + _sessionAnnouncementsNever.save(); + _sessionAnnouncements.clear(); + onAnnouncementChanged.emit(); + } + + //TODO Actions + private fun actionSomething() { + + } + + + + + fun registerDidYouKnow() { + val random = Random(); + val message: String? = when (random.nextInt(4 * 18 + 1)) { + 0 -> "You can login to different platforms and unify your content experience. Check it out in the source settings!" + 1 -> "Importing your playlists and subscriptions from other platforms to Grayjay is quick and easy. Check it out in the source settings!" + 2 -> "Want to cast to a big screen? Try out FCast (https://fcast.org/)." + 3 -> "Explore Grayjay's gesture controls. When in full-screen swipe on the left to change brightness, swipe on the right to change volume." + 4 -> "Explore Grayjay's gesture controls. Swipe up in the center of a video to toggle full-screen." + 5 -> "Grayjay's multi-platform search lets you find content from various sources." + 6 -> "Grayjay's multi-platform search filters will unify filters across platforms. If your expected filters are not there, try toggling some platforms off in the search filters." + 7 -> "You can share playlists with friends on the playlist page and make full-backups in the settings page." + 8 -> "Discover Grayjay's offline playback feature. Save content for when you're on the go!" + 9 -> "Paid content from your favorite creators gets seamlessly integrated into your Grayjay feed. Login to a platform to seamlessly see content you paid for." + 10 -> "Explore Grayjay's plugin features! Login, import playlists, and tweak plugin settings for a tailored experience." + 11 -> "Directly engage with content by liking, disliking, or leaving comments on the Polycentric network." + 12 -> "With Grayjay's rotation lock, you can watch videos in your preferred orientation regardless of device settings. Check it out during playback!" + 13 -> "Grayjay supports background play. Listen to your favorite content even while multitasking!" + 14 -> "Use Grayjay's quality selection to adjust video resolution. Save data or watch in high definition – it's up to you." + 15 -> "Customize your Grayjay experience by changing playback speed. Watch content at your own pace." + 16 -> "Save time by adding videos to your 'Watch Later' list. Perfect for catching up on content during your free time." + 17 -> "On Grayjay, your playlists, subscriptions, and settings are stored offline for privacy and quick access." + 18 -> "Explore and engage with live content using Grayjay's live stream feature." + else -> null + }; + + if (message != null) { + registerAnnouncement( + "did-you-know?", + "Did you know?", + message, + AnnouncementType.SESSION_RECURRING + ); + } + } + + companion object { + private var _instance: StateAnnouncement? = null; + val instance: StateAnnouncement + get(){ + if(_instance == null) + _instance = StateAnnouncement(); + return _instance!!; + }; + + + const val ACTION_SOMETHING = "SOMETHING"; + const val ACTION_NEVER = "NEVER"; + private const val TAG = "StateAnnouncement"; + } +} + +@Serializable +open class Announcement( + val id: String, + val title: String, + val msg: String, + val announceType: AnnouncementType, + @Serializable(with = OffsetDateTimeNullableSerializer::class) + val time: OffsetDateTime? = null, + val category: String? = null, + val actionName: String? = null, + val actionId: String? = null +); +class SessionAnnouncement( + id: String, + title: String, + msg: String, + announceType: AnnouncementType, + time: OffsetDateTime? = null, + category: String? = null, + actionName: String? = null, + actionId: String? = null, + val cancelName: String? = null, + val cancelActionId: String? = null +): Announcement( + id= id, + title = title, + msg = msg, + announceType = announceType, + time = time, + category = category, + actionName = actionName, + actionId = actionId +); + +enum class AnnouncementType(val value : Int) { + DELETABLE(0), //Close button deletes announcement (generally for actions) + RECURRING(1), //Shows up till never is pressed (generally for patchnotes etc) + PERMANENT(2), //Shows up until deleted through other means (action) + SESSION(3), //Not persistent, only during this session + SESSION_RECURRING(4); //Not persistent, only during this session, recurring id +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt new file mode 100644 index 00000000..444eafa4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -0,0 +1,578 @@ +package com.futo.platformplayer.states + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.media.AudioManager +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.os.Environment +import android.util.DisplayMetrics +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.work.* +import com.futo.platformplayer.* +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.background.BackgroundWorker +import com.futo.platformplayer.casting.StateCasting +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.logging.AndroidLogConsumer +import com.futo.platformplayer.logging.FileLogConsumer +import com.futo.platformplayer.logging.LogLevel +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.receivers.AudioNoisyReceiver +import com.futo.platformplayer.services.DownloadService +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.v2.ManagedStore +import kotlinx.coroutines.* +import java.io.File +import java.time.OffsetDateTime +import java.util.* +import java.util.concurrent.TimeUnit + +/*** + * This class contains global context for unconventional cases where obtaining context is hard. + * This context is only alive while MainActivity is active + * Ideally StateApp.withContext is used to only run code when it is available or throw + */ +class StateApp { + val isMainActive: Boolean get() = contextOrNull != null && contextOrNull is MainActivity; //if context is MainActivity, it means its active + + private val externalRootDirectory = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "Grayjay"); + fun getExternalRootDirectory(): File? { + if(!externalRootDirectory.exists()) { + val result = externalRootDirectory.mkdirs(); + if(!result) + return null; + return externalRootDirectory; + } + else + return externalRootDirectory; + } + + //Scope + private var _scope: CoroutineScope? = null; + val scopeOrNull: CoroutineScope? get() { + return _scope; + } + val scope: CoroutineScope get() { + val thisScope = scopeOrNull + ?: throw IllegalStateException("Attempted to use a global lifetime scope while MainActivity is no longer available"); + return thisScope; + } + val scopeGetter: ()->CoroutineScope get() { + return {scope}; + } + + var displayMetrics: DisplayMetrics? = null; + + //Context + private var _context: Context? = null; + val contextOrNull: Context? get() { + return _context; + } + val context: Context get() { + val thisContext = contextOrNull + ?: throw IllegalStateException("Attempted to use a global context while MainActivity is no longer available"); + return thisContext; + } + + //Files + private var _tempDirectory: File? = null; + + + //AutoRotate + var systemAutoRotate: Boolean = false; + + //Network + private var _lastMeteredState: Boolean = false; + private var _connectivityManager: ConnectivityManager? = null; + private var _lastNetworkState: NetworkState = NetworkState.UNKNOWN; + + //Logging + private var _fileLogConsumer: FileLogConsumer? = null; + + //Receivers + private var _receiverBecomingNoisy: AudioNoisyReceiver? = null; + + val onConnectionAvailable = Event0(); + val preventPictureInPicture = Event0(); + + fun getTempDirectory(): File { + return _tempDirectory!!; + } + fun getTempFile(extension: String? = null): File { + val name = UUID.randomUUID().toString() + + if(extension != null) + ".${extension}" + else + ""; + + return File(_tempDirectory, name); + } + + fun getCurrentSystemAutoRotate(): Boolean { + _context?.let { + systemAutoRotate = android.provider.Settings.System.getInt( + it.contentResolver, + android.provider.Settings.System.ACCELEROMETER_ROTATION, 0 + ) == 1; + }; + return systemAutoRotate; + } + + + fun isCurrentMetered(): Boolean { + ensureConnectivityManager(); + return _connectivityManager?.isActiveNetworkMetered ?: throw IllegalStateException("Connectivity manager not available"); + } + fun isNetworkState(vararg states: NetworkState): Boolean { + return states.contains(getCurrentNetworkState()); + } + fun getCurrentNetworkState(): NetworkState { + var state = NetworkState.DISCONNECTED; + + ensureConnectivityManager(); + _connectivityManager?.activeNetwork?.let { + val networkCapabilities = _connectivityManager?.getNetworkCapabilities(it) ?: throw IllegalStateException("Connectivity manager could not be found"); + + val connected = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET); + + if(connected && networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) { + state = NetworkState.ETHERNET; + return@let; + } + if(connected && networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { + state = NetworkState.WIFI; + return@let; + } + if(connected && networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) { + state = NetworkState.CELLULAR; + return@let; + } + } + return state; + } + + //Lifecycle + fun setGlobalContext(context: Context, coroutineScope: CoroutineScope? = null) { + _context = context; + _scope = coroutineScope + + //System checks + systemAutoRotate = getCurrentSystemAutoRotate(); + } + + fun initializeFiles(force: Boolean = false) { + if(force || !FragmentedStorage.isInitialized) { + FragmentedStorage.initialize(context.filesDir); + _tempDirectory = File(context.filesDir, "temp"); + if (_tempDirectory?.exists() == true) { + Logger.i(TAG, "Deleting ${_tempDirectory?.listFiles()?.size} temp files"); + _tempDirectory?.deleteRecursively(); + } + _tempDirectory?.mkdirs(); + } + } + + /*** + * This method starts a background context, should only be used under the assumption that your app is not active (eg. Scheduled background worker) + */ + suspend fun startBackground(context: Context, withFiles: Boolean, withPlugins: Boolean, backgroundWorker: suspend () -> Unit) { + withContext(Dispatchers.IO) { + backgroundStarting(context, this, withFiles, withPlugins); + try { + backgroundWorker(); + } + catch (ex: Throwable) { + Logger.e(TAG, "Background work failed: ${ex.message}", ex); + throw ex; + } + finally { + backgroundStopping(context); + } + } + } + suspend fun backgroundStarting(context: Context, scope: CoroutineScope, withFiles: Boolean, withPlugins: Boolean) { + if(contextOrNull == null) { + Logger.i(TAG, "BACKGROUND STATE: Starting"); + if(!Logger.hasConsumers && BuildConfig.DEBUG) { + Logger.i(TAG, "BACKGROUND STATE: Initialize logger"); + Logger.setLogConsumers(listOf(AndroidLogConsumer())); + } + + Logger.i(TAG, "BACKGROUND STATE: Initialize context"); + setGlobalContext(context, scope); + + if(withFiles) { + Logger.i(TAG, "BACKGROUND STATE: Initialize files"); + initializeFiles(); + } + + if (withPlugins) { + Logger.i(TAG, "BACKGROUND STATE: Initialize plugins"); + StatePlatform.instance.updateAvailableClients(context, true); + } + } + } + fun backgroundStopping(context: Context) { + if(contextOrNull == context || contextOrNull == null) { + Logger.i(TAG, "STOPPING BACKGROUND STATE"); + StatePlatform.instance.disableAllClients(); + dispose(); + } + } + + fun mainAppStarting(context: Context) { + initializeFiles(true); + + val logFile = File(context.filesDir, "log.txt"); + if (Settings.instance.logging.logLevel > LogLevel.NONE.value) { + val fileLogConsumer = FileLogConsumer(logFile, LogLevel.fromInt(Settings.instance.logging.logLevel), false); + Logger.setLogConsumers(listOf( + AndroidLogConsumer(), + fileLogConsumer + )); + + _fileLogConsumer = fileLogConsumer; + } else if (BuildConfig.DEBUG) { + if (logFile.exists()) { + logFile.delete(); + } + + Logger.setLogConsumers(listOf(AndroidLogConsumer())); + } + + StatePayment.instance.initialize(); + StatePolycentric.instance.load(context); + StateSaved.instance.load(); + + displayMetrics = context.resources.displayMetrics; + ensureConnectivityManager(context); + + if (!BuildConfig.DEBUG) { + StateTelemetry.instance.initialize(); + StateTelemetry.instance.upload(); + } + + Logger.onLogSubmitted.subscribe { + scopeGetter().launch(Dispatchers.Main) { + try { + if (it != null) { + UIDialogs.toast("Uploaded " + (it ?: "null"), true); + } else { + UIDialogs.toast("Failed to upload"); + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to show toast", e) + } + } + } + } + fun mainAppStarted(context: Context) { + Logger.i(TAG, "App started"); + + StateAnnouncement.instance.registerAnnouncement("fa4647d3-36fa-4c8c-832d-85b00fc72dca", "Disclaimer", "This is an early alpha build of the application, expect bugs and unfinished features.", AnnouncementType.DELETABLE, OffsetDateTime.now()) + + if(SettingsDev.instance.developerMode && SettingsDev.instance.devServerSettings.devServerOnBoot) + StateDeveloper.instance.runServer(); + + if(StateSubscriptions.instance.shouldMigrate()) + StateSubscriptions.instance.tryMigrateIfNecessary(); + + if(Settings.instance.downloads.shouldDownload()) { + StateDownloads.instance.checkForOutdatedPlaylists(); + + StateDownloads.instance.getDownloadPlaylists(); + if (!StateDownloads.instance.getDownloading().isEmpty()) + DownloadService.getOrCreateService(context); + } + + StateDownloads.instance.checkForExportTodos(); + + val autoUpdateEnabled = Settings.instance.autoUpdate.isAutoUpdateEnabled(); + val shouldDownload = Settings.instance.autoUpdate.shouldDownload(); + val backgroundDownload = Settings.instance.autoUpdate.backgroundDownload == 1; + when { + //Background download + autoUpdateEnabled && shouldDownload && backgroundDownload -> { + StateUpdate.instance.setShouldBackgroundUpdate(true); + } + + autoUpdateEnabled && !shouldDownload && backgroundDownload -> { + Logger.i(TAG, "Auto update skipped due to wrong network state"); + } + + //Foreground download + autoUpdateEnabled -> { + StateUpdate.instance.checkForUpdates(context, false); + } + + else -> { + Logger.i(TAG, "Auto update disabled"); + } + } + + _receiverBecomingNoisy?.let { + _receiverBecomingNoisy = null; + context.unregisterReceiver(it); + } + _receiverBecomingNoisy = AudioNoisyReceiver(); + context.registerReceiver(_receiverBecomingNoisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)); + + //Migration + migrateStores(context, listOf( + StateSubscriptions.instance.toMigrateCheck(), + StatePlaylists.instance.toMigrateCheck() + ).flatten(), 0); + + scope.launch { + delay(5000); + StateSubscriptions.instance.updateSubscriptionFeed(scope, false); + } + + val interval = Settings.instance.subscriptions.getSubscriptionsBackgroundIntervalMinutes(); + scheduleBackgroundWork(context, interval != 0, interval); + + + if(!Settings.instance.backup.didAskAutoBackup && !Settings.instance.backup.shouldAutomaticBackup()) { + StateAnnouncement.instance.registerAnnouncement("backup", "Set Automatic Backup", "Configure daily backups of your data to restore in case of catastrophic failure.", AnnouncementType.SESSION, null, null, "Configure", { + UIDialogs.showAutomaticBackupDialog(context); + StateAnnouncement.instance.deleteAnnouncement("backup"); + }, "No Backup", { + Settings.instance.backup.didAskAutoBackup = true; + Settings.instance.save(); + }); + } + + instance.scopeOrNull?.launch(Dispatchers.IO) { + try { + StateAnnouncement.instance.loadAnnouncements(); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to load announcements.", e) + } + } + + if (BuildConfig.IS_PLAYSTORE_BUILD) { + StateAnnouncement.instance.registerAnnouncement( + "playstore-version", + "Playstore version", + "This version is the playstore version of the app. Your experience will be more limited.", + AnnouncementType.SESSION_RECURRING + ); + } + + StateAnnouncement.instance.registerDidYouKnow(); + + } + fun mainAppStartedWithExternalFiles(context: Context) { + if(!Settings.instance.didFirstStart) { + if(StateBackup.hasAutomaticBackup()) { + UIDialogs.showAutomaticRestoreDialog(context, if(context is LifecycleOwner) context.lifecycleScope else scope); + } + + + Settings.instance.didFirstStart = true; + Settings.instance.save(); + } + if(Settings.instance.backup.shouldAutomaticBackup()) { + try { + StateBackup.startAutomaticBackup(); + } + catch(ex: Throwable) { + Logger.e("StateApp", "Automatic backup failed", ex); + UIDialogs.toast(context, "Automatic backup failed due to:\n" + ex.message); + } + } + else + Logger.i("StateApp", "No AutoBackup configured"); + } + + + fun scheduleBackgroundWork(context: Context, active: Boolean = true, intervalMinutes: Int = 60 * 12) { + val wm = WorkManager.getInstance(context); + + if(active) { + if(BuildConfig.DEBUG) + UIDialogs.toast(context, "Scheduling background every ${intervalMinutes} minutes"); + + val req = PeriodicWorkRequest.Builder(BackgroundWorker::class.java, intervalMinutes.toLong(), TimeUnit.MINUTES, 5, TimeUnit.MINUTES) + .setConstraints(Constraints.Builder() + .setRequiredNetworkType(NetworkType.UNMETERED) + .build()) + .build(); + wm.enqueueUniquePeriodicWork("backgroundSubscriptions", ExistingPeriodicWorkPolicy.UPDATE, req); + } + else + wm.cancelAllWork(); + } + + + private fun migrateStores(context: Context, managedStores: List>, index: Int) { + if(managedStores.size <= index) + return; + val store = managedStores[index]; + if(store.hasMissingReconstructions()) + UIDialogs.showMigrateDialog(context, store) { + migrateStores(context, managedStores, index + 1); + }; + else + migrateStores(context, managedStores, index + 1); + } + + fun mainAppDestroyed(context: Context) { + Logger.i(TAG, "App ended"); + _receiverBecomingNoisy?.let { + _receiverBecomingNoisy = null; + context.unregisterReceiver(it); + } + + Logger.i(TAG, "Unregistered network callback on connectivityManager.") + _connectivityManager?.unregisterNetworkCallback(_connectivityEvents); + + StatePlayer.instance.closeMediaSession(); + StateCasting.instance.stop(); + StatePlayer.dispose(); + Companion.dispose(); + _fileLogConsumer?.close(); + } + + fun dispose(){ + _context = null; + _scope = null; + } + + private val _connectivityEvents = object : ConnectivityManager.NetworkCallback() { + override fun onUnavailable() { + super.onUnavailable(); + Logger.i(TAG, "_connectivityEvents onUnavailable"); + + updateNetworkState(); + } + + override fun onLost(network: Network) { + super.onLost(network); + Logger.i(TAG, "_connectivityEvents onLost"); + + updateNetworkState(); + } + + override fun onAvailable(network: Network) { + super.onAvailable(network); + Logger.i(TAG, "_connectivityEvents onAvailable"); + + updateNetworkState(); + + try { + if (_lastNetworkState != NetworkState.DISCONNECTED) { + scopeOrNull?.launch(Dispatchers.Main) { + try { + Logger.i(TAG, "onConnectionAvailable emitted"); + onConnectionAvailable.emit(); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to emit onConnectionAvailable", e) + } + }; + } + } catch(ex: Throwable) { + Logger.w(TAG, "Failed to handle connection available event", ex); + } + } + override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { + super.onCapabilitiesChanged(network, networkCapabilities); + + updateNetworkState(); + + try { + if(FragmentedStorage.isInitialized && Settings.instance.downloads.shouldDownload()) + StateDownloads.instance.checkForDownloadsTodos(); + + val autoUpdateEnabled = Settings.instance.autoUpdate.isAutoUpdateEnabled(); + val shouldDownload = Settings.instance.autoUpdate.shouldDownload(); + val backgroundDownload = Settings.instance.autoUpdate.backgroundDownload == 1; + if (autoUpdateEnabled && shouldDownload && backgroundDownload) { + StateUpdate.instance.setShouldBackgroundUpdate(true); + } else { + StateUpdate.instance.setShouldBackgroundUpdate(false); + } + } catch(ex: Throwable) { + Logger.w(TAG, "Failed to handle capabilities changed event", ex); + } + } + + private fun updateNetworkState() { + try { + val beforeNetworkState = _lastNetworkState; + val beforeMeteredState = _lastMeteredState; + _lastNetworkState = getCurrentNetworkState(); + _lastMeteredState = isCurrentMetered(); + if(beforeNetworkState != _lastNetworkState || beforeMeteredState != _lastMeteredState) + Logger.i(TAG, "Network capabilities changed (State: ${_lastNetworkState}, Metered: ${_lastMeteredState})"); + } catch(ex: Throwable) { + Logger.w(TAG, "Failed to update network state", ex); + } + } + }; + private fun ensureConnectivityManager(context: Context? = null) { + if(_connectivityManager == null) { + _connectivityManager = + (context ?: contextOrNull)?.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager? + ?: throw IllegalStateException("Connectivity manager could not be found"); + + val netReq = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET) + .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) + .build(); + _connectivityManager!!.registerNetworkCallback(netReq, _connectivityEvents); + } + } + + companion object { + private val TAG = "StateApp"; + @SuppressLint("StaticFieldLeak") //This is only alive while MainActivity is alive + private var _instance : StateApp? = null; + val instance : StateApp + get(){ + if(_instance == null) + _instance = StateApp(); + return _instance!!; + }; + + fun dispose(){ + val instance = _instance; + _instance = null; + instance?.dispose(); + Logger.i(TAG, "StateApp has been disposed"); + } + + fun withContext(handle: (Context)->Unit) { + val context = _instance?.contextOrNull; + if(context != null) + handle(context); + } + fun withContext(throwIfNotAvailable: Boolean, handle: (Context)->Unit) { + if(!throwIfNotAvailable) + withContext(handle); + val context = _instance?.contextOrNull; + if(context != null) + handle(context); + else if(throwIfNotAvailable) + throw IllegalStateException("Attempted to use a global context while MainActivity is no longer available"); + } + } + + + enum class NetworkState { + UNKNOWN, + DISCONNECTED, + CELLULAR, + WIFI, + ETHERNET + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateAssets.kt b/app/src/main/java/com/futo/platformplayer/states/StateAssets.kt new file mode 100644 index 00000000..94db9864 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/states/StateAssets.kt @@ -0,0 +1,67 @@ +package com.futo.platformplayer.states + +import android.content.Context +import kotlin.streams.toList + +/*** + * Used to read assets + */ +class StateAssets { + companion object { + private val _cache: HashMap = HashMap(); + + private fun resolvePath(base: String, path: String, maxParent: Int = 1): String { + var parentAllowance = maxParent; + var toSkip = 0; + val parts1 = base.split('/').toMutableList(); + val parts2 = path.split('/').toMutableList(); + + for(part in parts2) { + if(part == "." || part == "..") { + if(parentAllowance <= 0) + throw IllegalStateException("Path [${path}] attempted to escape path.."); + parts1.removeLast(); + toSkip++; + } + else + break; + } + return (parts1 + parts2.stream().skip(toSkip.toLong()).toList()).joinToString("/"); + } + + /** + * Does basic asset resolving under certain conditions + */ + fun readAssetRelative(context: Context, base: String, path: String, cache: Boolean = false) : String? { + val finalPath = resolvePath(base, path); + return readAsset(context, finalPath, cache); + } + fun readAssetBinRelative(context: Context, base: String, path: String) : ByteArray? { + val finalPath = resolvePath(base, path); + return readAssetBin(context, finalPath); + } + + fun readAsset(context: Context, path: String, cache: Boolean = false) : String? { + var text: String? = null; + synchronized(_cache) { + if (!_cache.containsKey(path)) { + text = context + ?.assets + ?.open(path) + ?.bufferedReader() + ?.use { it.readText(); }; + _cache.put(path, text); + } + else + text = _cache.get(path); + } + return text; + } + fun readAssetBin(context: Context, path: String) : ByteArray? { + val str = context.assets?.open(path); + if(str == null) + return null; + else return str.use { it.readBytes() }; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateBackup.kt b/app/src/main/java/com/futo/platformplayer/states/StateBackup.kt new file mode 100644 index 00000000..c8d35f21 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/states/StateBackup.kt @@ -0,0 +1,405 @@ +package com.futo.platformplayer.states + +import android.content.Context +import androidx.core.app.ShareCompat +import androidx.core.content.FileProvider +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.activities.SettingsActivity +import com.futo.platformplayer.encryption.EncryptionProvider +import com.futo.platformplayer.getNowDiffHours +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.v2.ManagedStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.time.OffsetDateTime +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream +import kotlin.IllegalStateException + +class StateBackup { + companion object { + val TAG = "StateBackup"; + + private val _autoBackupLock = Object(); + + private fun getAutomaticBackupFiles(): Pair { + val dir = StateApp.instance.getExternalRootDirectory(); + if(dir == null) + throw IllegalStateException("Can't access external files"); + return Pair(File(dir, "GrayjayBackup.ezip"), File(dir, "GrayjayBackup.ezip.old")) + } + + + fun getAllMigrationStores(): List> = listOf( + StateSubscriptions.instance.toMigrateCheck(), + StatePlaylists.instance.toMigrateCheck() + ).flatten(); + + + private fun getAutomaticBackupPassword(customPassword: String? = null): String { + val password = customPassword ?: Settings.instance.backup.autoBackupPassword ?: ""; + val pbytes = password.toByteArray(); + if(pbytes.size < 4 || pbytes.size > 32) + throw IllegalStateException("Automatic backup passwords should atleast be 4 character and smaller than 32"); + return password.padStart(32, '9'); + } + fun hasAutomaticBackup(): Boolean { + if(StateApp.instance.getExternalRootDirectory() == null) + return false; + val files = getAutomaticBackupFiles(); + return files.first.exists() || files.second.exists(); + } + fun startAutomaticBackup(force: Boolean = false) { + val lastBackupHoursAgo = Settings.instance.backup.lastAutoBackupTime.getNowDiffHours(); + if(!force && lastBackupHoursAgo < 24) { + Logger.i(TAG, "Not AutoBackuping, last backup ${lastBackupHoursAgo} hours ago"); + return; + } + + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){ + try { + Logger.i(TAG, "Starting AutoBackup (Last ${lastBackupHoursAgo} ago)"); + synchronized(_autoBackupLock) { + val data = export(); + val zip = data.asZip(); + + val encryptedZip = EncryptionProvider.instance.encrypt(zip, getAutomaticBackupPassword()); + + val backupFiles = getAutomaticBackupFiles(); + val exportFile = backupFiles.first; + if (exportFile.exists()) + exportFile.copyTo(backupFiles.second, true); + + exportFile.writeBytes(encryptedZip); + + Settings.instance.backup.lastAutoBackupTime = OffsetDateTime.now(); //OffsetDateTime.now(); + Settings.instance.save(); + } + Logger.i(TAG, "Finished AutoBackup"); + } + catch(ex: Throwable) { + Logger.e(TAG, "Failed to AutoBackup", ex); + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { + UIDialogs.toast("Failed to auto backup:\n" + ex.message); + }; + } + } + } + fun restoreAutomaticBackup(context: Context, scope: CoroutineScope, password: String, ifExists: Boolean = false) { + if(ifExists && !hasAutomaticBackup()) { + Logger.i(TAG, "No AutoBackup exists, not restoring"); + return; + } + + //TODO: Sadly on reinstalls of app this fails on file permissions. + + Logger.i(TAG, "Starting AutoBackup restore"); + synchronized(_autoBackupLock) { + + val backupFiles = getAutomaticBackupFiles(); + try { + if (!backupFiles.first.exists()) + throw IllegalStateException("Backup file does not exist"); + + val backupBytesEncrypted = backupFiles.first.readBytes(); + val backupBytes = EncryptionProvider.instance.decrypt(backupBytesEncrypted, getAutomaticBackupPassword(password)); + importZipBytes(context, scope, backupBytes); + Logger.i(TAG, "Finished AutoBackup restore"); + } catch (ex: Throwable) { + Logger.e(TAG, "Failed main AutoBackup restore", ex) + if (!backupFiles.second.exists()) + throw ex; + + val backupBytesEncrypted = backupFiles.second.readBytes(); + val backupBytes = EncryptionProvider.instance.decrypt(backupBytesEncrypted, getAutomaticBackupPassword(password)); + importZipBytes(context, scope, backupBytes); + Logger.i(TAG, "Finished AutoBackup restore"); + } + } + } + + fun startExternalBackup() { + val data = export(); + val now = OffsetDateTime.now(); + val exportFile = File( + FragmentedStorage.getOrCreateDirectory("shares"), + "export_${now.year}-${now.monthValue.toString().padStart(2, '0')}-${now.dayOfMonth.toString().padStart(2, '0')}_${now.hour.toString().padStart(2, '0')}${now.minute.toString().padStart(2, '0')}.zip"); + exportFile.writeBytes(data.asZip()); + + StateApp.instance.contextOrNull?.let { + val uri = FileProvider.getUriForFile(it, it.resources.getString(R.string.authority), exportFile); + + val activity = SettingsActivity.getActivity() ?: return@let; + activity.startActivity( + ShareCompat.IntentBuilder(activity) + .setType("application/zip") + .setStream(uri) + .intent); + } + } + + fun export(): ExportStructure { + val exportInfo = mapOf( + Pair("version", "1") + ); + val storesToSave = getAllMigrationStores() + .associateBy { it.name } + .mapValues { it.value.getAllReconstructionStrings() }; + val settings = Settings.instance.encode(); + val pluginSettings = StatePlugins.instance.getPlugins() + .associateBy { it.config.id } + .mapValues { it.value.settings }; + val pluginUrls = StatePlugins.instance.getPlugins() + .filter { it.config.sourceUrl != null } + .associateBy { it.config.id } + .mapValues { it.value.config.sourceUrl!! }; + + return ExportStructure(exportInfo, settings, storesToSave, pluginUrls, pluginSettings); + } + + + fun importZipBytes(context: Context, scope: CoroutineScope, zipData: ByteArray) { + val import: StateBackup.ExportStructure; + try { + ByteArrayInputStream(zipData).use { + ZipInputStream(it).use { + import = StateBackup.ExportStructure.fromZip(it); + } + } + } + catch(ex: Throwable) { + UIDialogs.showGeneralErrorDialog(context, "Failed to import zip", ex); + return; + } + import(context, scope, import); + } + fun import(context: Context, scope: CoroutineScope, export: ExportStructure) { + + val availableStores = getAllMigrationStores(); + val unknownPlugins = export.plugins.filter { !StatePlugins.instance.hasPlugin(it.key) }; + + var doImport = false; + var doImportSettings = false; + var doImportPlugins = false; + var doImportPluginSettings = false; + var doEnablePlugins = false; + var doImportStores = false; + Logger.i(TAG, "Starting import choices"); + UIDialogs.multiShowDialog(context, { + Logger.i(TAG, "Starting import"); + if(!doImport) + return@multiShowDialog; + val enabledBefore = StatePlatform.instance.getEnabledClients().map { it.id }; + + val onConclusion = { + scope.launch(Dispatchers.IO) { + StatePlatform.instance.selectClients(*enabledBefore.toTypedArray()); + + withContext(Dispatchers.Main) { + UIDialogs.showDialog(context, R.drawable.ic_update_success_251dp, + "Import has finished", null, null, 0, UIDialogs.Action("Ok", {})); + } + } + }; + //TODO: Probably restructure this to be less nested + scope.launch(Dispatchers.IO) { + try { + if (doImportSettings && export.settings != null) { + Logger.i(TAG, "Importing settings"); + try { + Settings.replace(export.settings); + } + catch(ex: Throwable) { + UIDialogs.toast(context, "Failed to import settings\n(" + ex.message + ")"); + } + } + + val afterPluginInstalls = { + scope.launch(Dispatchers.IO) { + if (doEnablePlugins) { + val availableClients = StatePlatform.instance.getEnabledClients().toMutableList(); + availableClients.addAll(StatePlatform.instance.getAvailableClients().filter { !availableClients.contains(it) }); + + Logger.i(TAG, "Import enabling plugins [${availableClients.map{it.name}.joinToString(", ")}]"); + StatePlatform.instance.updateAvailableClients(context, false); + StatePlatform.instance.selectClients(*availableClients.map { it.id }.toTypedArray()); + } + if(doImportPluginSettings) { + for(settings in export.pluginSettings) { + Logger.i(TAG, "Importing Plugin settings [${settings.key}]"); + StatePlugins.instance.setPluginSettings(settings.key, settings.value); + } + } + val toAwait = export.stores.map { it.key }.toMutableList(); + if(doImportStores) { + for(store in export.stores) { + Logger.i(TAG, "Importing store [${store.key}]"); + val relevantStore = availableStores.find { it.name == store.key }; + if(relevantStore == null) { + Logger.w(TAG, "Unknown store [${store.key}] import"); + continue; + } + withContext(Dispatchers.Main) { + UIDialogs.showImportDialog(context, relevantStore, store.key, store.value) { + synchronized(toAwait) { + toAwait.remove(store.key); + if(toAwait.isEmpty()) + onConclusion(); + } + }; + } + } + } + } + } + + if (doImportPlugins) { + Logger.i(TAG, "Importing plugins"); + StatePlugins.instance.installPlugins(context, scope, unknownPlugins.map { it.value }) { + afterPluginInstalls(); + } + } + else + afterPluginInstalls(); + } + catch(ex: Throwable) { + Logger.e(TAG, "Import failed", ex); + UIDialogs.showGeneralErrorDialog(context, "Import failed", ex); + } + } + }, + UIDialogs.Descriptor(R.drawable.ic_move_up, + "Do you want to import data?", + "Several dialogs will follow asking individual parts", + "Settings: ${export.settings != null}\n" + + "Plugins: ${unknownPlugins.size}\n" + + "Plugin Settings: ${export.pluginSettings.size}\n" + + export.stores.map { "${it.key}: ${it.value.size}" }.joinToString("\n").trim() + , 1, + UIDialogs.Action("Import", { + doImport = true; + }, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("Cancel", { doImport = false}) + ), + if(export.settings != null) UIDialogs.Descriptor(R.drawable.ic_settings, + "Would you like to import settings", + "These are the settings that configure how your app works", + null, 0, + UIDialogs.Action("Yes", { + doImportSettings = true; + }, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {}) + ).withCondition { doImport } else null, + if(unknownPlugins.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_sources, + "Would you like to import plugins?", + "Your import contains the following plugins", + unknownPlugins.map { it.value }.joinToString("\n"), 1, + UIDialogs.Action("Yes", { + doImportPlugins = true; + }, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {}) + ).withCondition { doImport } else null, + if(export.pluginSettings.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_sources, + "Would you like to import plugin settings?", + null, null, 1, + UIDialogs.Action("Yes", { + doImportPluginSettings = true; + }, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {}) + ).withCondition { doImport } else null, + UIDialogs.Descriptor(R.drawable.ic_sources, + "Would you like to enable all plugins?", + "Enabling all plugins ensures all required plugins are available during import", + null, 0, + UIDialogs.Action("Yes", { + doEnablePlugins = true; + }, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {}) + ).withCondition { doImport }, + if(export.stores.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_move_up, + "Would you like to import stores", + "Stores contain playlists, watch later, subscriptions, etc", + null, 0, + UIDialogs.Action("Yes", { + doImportStores = true; + }, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {}) + ).withCondition { doImport } else null + ); + } + } + + class ExportStructure( + val exportInfo: Map, + val settings: String?, + val stores: Map>, + val plugins: Map, + val pluginSettings: Map>, + ) { + + fun asZip(): ByteArray { + return ByteArrayOutputStream().use { byteStream -> + ZipOutputStream(byteStream).use { zipStream -> + zipStream.putNextEntry(ZipEntry("exportInfo")); + zipStream.write(Json.encodeToString(exportInfo).toByteArray()); + + if(settings != null) { + zipStream.putNextEntry(ZipEntry("settings")); + zipStream.write(settings.toByteArray()); + } + + zipStream.putNextEntry(ZipEntry("stores/")); + for(store in stores.mapValues { Json.encodeToString(it.value) }) { + zipStream.putNextEntry(ZipEntry("stores/${store.key}")); + zipStream.write(store.value.toByteArray()); + } + + zipStream.putNextEntry(ZipEntry("plugins")); + zipStream.write(Json.encodeToString(plugins).toByteArray()); + + zipStream.putNextEntry(ZipEntry("plugin_settings")); + zipStream.write(Json.encodeToString(pluginSettings).toByteArray()); + }; + return byteStream.toByteArray(); + } + } + + companion object { + fun fromZip(zipStream: ZipInputStream): ExportStructure { + var entry: ZipEntry? = null + + var exportInfo: Map = mapOf(); + var settings: String? = null; + var stores: MutableMap> = mutableMapOf(); + var plugins: Map = mapOf(); + var pluginSettings: Map> = mapOf(); + + while (zipStream.nextEntry.also { entry = it } != null) { + if(entry!!.isDirectory) + continue; + try{ + if(!entry!!.name.startsWith("stores/")) + when(entry!!.name) { + "exportInfo" -> exportInfo = Json.decodeFromString(String(zipStream.readBytes())); + "settings" -> settings = String(zipStream.readBytes()); + "plugins" -> plugins = Json.decodeFromString(String(zipStream.readBytes())); + "plugin_settings" -> pluginSettings = Json.decodeFromString(String(zipStream.readBytes())); + } + else + stores[entry!!.name.substring("stores/".length)] = Json.decodeFromString(String(zipStream.readBytes())); + } + catch(ex: Throwable) { + throw IllegalStateException("Failed to parse zip [${entry?.name}] due to ${ex.message}"); + } + } + return ExportStructure(exportInfo, settings, stores, plugins, pluginSettings); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateDeveloper.kt b/app/src/main/java/com/futo/platformplayer/states/StateDeveloper.kt new file mode 100644 index 00000000..8d9ceff6 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/states/StateDeveloper.kt @@ -0,0 +1,133 @@ +package com.futo.platformplayer.states + +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.http.server.ManagedHttpServer +import com.futo.platformplayer.developer.DeveloperEndpoints +import com.futo.platformplayer.engine.exceptions.ScriptExecutionException +import com.futo.platformplayer.logging.Logger +import kotlin.system.measureTimeMillis + +/*** + * Used for developer related calls + */ +class StateDeveloper { + private var _server : ManagedHttpServer? = null; + + var currentDevID: String? = null + private set; + + private var _devLogsIndex: Int = 0; + private val _devLogs: MutableList = mutableListOf(); + + fun initializeDev(id: String) { + currentDevID = id; + synchronized(_devLogs) { + _devLogs.clear(); + } + } + inline fun handleDevCall(devId: String, contextName: String, printResult: Boolean = false, handle: ()->T): T { + var resp: T? = null; + + val time = measureTimeMillis { + try { + resp = handle(); + } + catch (castEx: ClassCastException) { + Logger.e("StateDeveloper", "Wrapped Exception: " + castEx.message, castEx); + val exMsg = + "Call [${contextName}] returned incorrect type. Expected [${T::class.simpleName}].\nCastException: ${castEx.message}"; + logDevException(devId, exMsg); + throw castEx; + } + catch (ex: ScriptExecutionException) { + Logger.e("StateDeveloper", "Wrapped Exception: " + ex.message, ex); + logDevException( + devId, + "Call [${contextName}] failed due to: (${ex::class.simpleName}) ${ex.message}" + + (if(ex.stack != null) "\n" + ex.stack else "") + ); + throw ex; + } + catch (ex: Throwable) { + Logger.e("StateDeveloper", "Wrapped Exception: " + ex.message, ex); + logDevException( + devId, + "Call [${contextName}] failed due to: (${ex::class.simpleName}) ${ex.message}" + ); + throw ex; + } + } + var printValue = ""; + if(printResult) { + if(resp is Boolean) + printValue = resp.toString(); + else if(resp is List<*>) + printValue = (resp as List<*>).size.toString(); + } + + logDevInfo(devId, "Call [${contextName}] succesful [${time}ms] ${printValue}"); + return resp!!; + } + fun logDevException(devId: String, msg: String) { + currentDevID.let { + if(it == devId) + synchronized(_devLogs) { + _devLogsIndex++; + _devLogs.add(DevLog(_devLogsIndex, devId, "EXCEPTION", msg)); + } + } + } + fun logDevInfo(devId: String, msg: String) { + currentDevID.let { + if(it == devId) + synchronized(_devLogs) { + _devLogsIndex++; + _devLogs.add(DevLog(_devLogsIndex, devId, "INFO", msg)); + } + } + } + fun getLogs(startIndex: Int) : List { + synchronized(_devLogs) { + val index = _devLogs.indexOfFirst { it.id == startIndex }; + return _devLogs.subList(index + 1, _devLogs.size); + } + } + + + fun runServer() { + if(_server != null) + return; + UIDialogs.toast("DevServer Booted"); + _server = ManagedHttpServer(11337).apply { + this.addBridgeHandlers(DeveloperEndpoints(StateApp.instance.context), "dev"); + }; + _server?.start(); + } + fun stopServer() { + _server?.stop(); + _server = null; + } + + + companion object { + const val DEV_ID = "DEV"; + + private var _instance : StateDeveloper? = null; + val instance : StateDeveloper + get(){ + if(_instance == null) + _instance = StateDeveloper(); + return _instance!!; + }; + + fun finish() { + _instance?.let { + _instance = null; + it._server?.stop(); + } + } + } + + @kotlinx.serialization.Serializable + data class DevLog(val id: Int, val devId: String, val type: String, val log: String); +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt b/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt new file mode 100644 index 00000000..a689a6a8 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt @@ -0,0 +1,396 @@ +package com.futo.platformplayer.states + +import android.os.StatFs +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.exceptions.AlreadyQueuedException +import com.futo.platformplayer.api.media.models.streams.sources.* +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.downloads.PlaylistDownloadDescriptor +import com.futo.platformplayer.downloads.VideoLocal +import com.futo.platformplayer.downloads.VideoDownload +import com.futo.platformplayer.downloads.VideoExport +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.DiskUsage +import com.futo.platformplayer.models.Playlist +import com.futo.platformplayer.models.PlaylistDownloaded +import com.futo.platformplayer.services.DownloadService +import com.futo.platformplayer.services.ExportingService +import com.futo.platformplayer.stores.* +import com.futo.platformplayer.stores.v2.ManagedStore +import okhttp3.internal.platform.Platform +import java.io.File + +/*** + * Used to maintain downloads + */ +class StateDownloads { + private val _downloadsDirectory: File = FragmentedStorage.getOrCreateDirectory("downloads"); + private val _downloadsStat = StatFs(_downloadsDirectory.absolutePath); + + private val _downloaded = FragmentedStorage.storeJson("downloaded") + .load() + .apply { afterLoadingDownloaded(this) }; + private val _downloading = FragmentedStorage.storeJson("downloading") + .load().apply { + for(video in this.getItems()) + video.changeState(VideoDownload.State.QUEUED); + }; + private val _downloadPlaylists = FragmentedStorage.storeJson("playlistDownloads") + .load(); + + private val _exporting = FragmentedStorage.storeJson("exporting") + .load(); + + private lateinit var _downloadedSet: HashSet; + + val onExportsChanged = Event0(); + val onDownloadsChanged = Event0(); + val onDownloadedChanged = Event0(); + + private fun afterLoadingDownloaded(v: ManagedStore) { + _downloadedSet = HashSet(v.getItems().map { it.id }); + } + + fun getTotalUsage(reload: Boolean): DiskUsage { + if(reload) + _downloadsStat.restat(_downloadsDirectory.absolutePath); + val usage = _downloadsDirectory.listFiles()?.sumOf { it.length() } ?: 0; + val available = _downloadsStat.availableBytes; + return DiskUsage(usage, available); + } + + fun getCachedVideo(id: PlatformID): VideoLocal? { + return _downloaded.findItem { it.id.equals(id) }; + } + fun updateCachedVideo(vid: VideoLocal) { + Logger.i("StateDownloads", "Updating local video ${vid.name}"); + _downloaded.save(vid); + onDownloadedChanged.emit(); + } + fun deleteCachedVideo(id: PlatformID) { + Logger.i("StateDownloads", "Deleting local video ${id.value}"); + val downloaded = getCachedVideo(id); + if(downloaded != null) { + synchronized(_downloadedSet) { + _downloadedSet.remove(id); + } + _downloaded.delete(downloaded); + } + onDownloadedChanged.emit(); + } + + fun isDownloaded(id: PlatformID): Boolean { + synchronized(_downloadedSet) { + return _downloadedSet.contains(id); + } + } + + fun getCachedPlaylists(): List { + return _downloadPlaylists.getItems() + .map { Pair(it, StatePlaylists.instance.getPlaylist(it.id)) } + .filter { it.second != null } + .map { PlaylistDownloaded(it.first, it.second!!) } + .toList(); + } + fun hasCachedPlaylist(playlistId: String): Boolean { + return _downloadPlaylists.hasItem { it.id == playlistId }; + } + fun getCachedPlaylist(playlistId: String): PlaylistDownloaded? { + val descriptor = getPlaylistDownload(playlistId) ?: return null; + val playlist = StatePlaylists.instance.getPlaylist(playlistId) ?: return null; + return PlaylistDownloaded(descriptor, playlist); + } + fun getPlaylistDownload(playlistId: String): PlaylistDownloadDescriptor? { + return _downloadPlaylists.findItem { it.id == playlistId }; + } + fun deleteCachedPlaylist(id: String) { + val pdl = getPlaylistDownload(id); + if(pdl != null) + _downloadPlaylists.delete(pdl); + getDownloading().filter { it.groupType == VideoDownload.GROUP_PLAYLIST && it.groupID == id } + .forEach { removeDownload(it) }; + getDownloadedVideos().filter { it.groupType == VideoDownload.GROUP_PLAYLIST && it.groupID == id } + .forEach { deleteCachedVideo(it.id) }; + } + + fun getDownloadedVideos(): List { + return _downloaded.getItems(); + } + + fun getDownloadPlaylists(): List { + return _downloadPlaylists.getItems(); + } + fun isPlaylistCached(id: String): Boolean { + return getDownloadPlaylists().any{it.id == id}; + } + + fun getDownloading(): List { + return _downloading.getItems(); + } + fun updateDownloading(download: VideoDownload) { + _downloading.save(download, false, true); + } + + + fun removeDownload(download: VideoDownload) { + download.isCancelled = true; + _downloading.delete(download); + onDownloadsChanged.emit(); + } + + fun checkForDownloadsTodos() { + val hasPlaylistChanged = checkForOutdatedPlaylists(); + val hasDownloads = _downloading.hasItems(); + + if((hasPlaylistChanged || hasDownloads) && Settings.instance.downloads.shouldDownload()) + StateApp.withContext { + DownloadService.getOrCreateService(it); + } + } + fun checkForOutdatedPlaylists(): Boolean { + var hasChanged = false; + val playlistsDownloaded = getCachedPlaylists(); + for(playlist in playlistsDownloaded) { + val playlistDownload = getPlaylistDownload(playlist.playlist.id) ?: continue; + + if(playlist.playlist.videos.any{ getCachedVideo(it.id) == null }) { + Logger.i(TAG, "Found new videos on playlist [${playlist.playlist.name}]"); + continueDownload(playlistDownload, playlist.playlist); + hasChanged = true; + } + } + return hasChanged; + } + + fun continueDownload(playlistDownload: PlaylistDownloadDescriptor, playlist: Playlist) { + var hasNew = false; + for(item in playlist.videos) { + val existing = getCachedVideo(item.id); + if(existing == null) { + val ongoingDownload = getDownloading().find { it.id.value == item.id.value && it.id.value != null }; + if(ongoingDownload != null) { + Logger.i(TAG, "New playlist video (already downloading) ${item.name}"); + ongoingDownload.groupID = playlist.id; + ongoingDownload.groupType = VideoDownload.GROUP_PLAYLIST; + } + else { + Logger.i(TAG, "New playlist video ${item.name}"); + download(VideoDownload(item, playlistDownload.targetPxCount, playlistDownload.targetBitrate) + .withGroup(VideoDownload.GROUP_PLAYLIST, playlist.id), false); + hasNew = true; + } + } + else { + Logger.i(TAG, "New playlist video (already downloaded) ${item.name}"); + if(existing.groupID == null) { + existing.groupID = playlist.id; + existing.groupType = VideoDownload.GROUP_PLAYLIST; + synchronized(_downloadedSet) { + _downloadedSet.add(existing.id); + } + _downloaded.save(existing); + } + } + } + if(playlist.videos.isNotEmpty() && Settings.instance.downloads.shouldDownload()) { + if(hasNew) { + UIDialogs.toast("Downloading [${playlist.name}]") + StateApp.withContext { + DownloadService.getOrCreateService(it); + } + } + onDownloadsChanged.emit(); + } + } + fun download(playlist: Playlist, targetPixelcount: Long?, targetBitrate: Long?) { + val playlistDownload = PlaylistDownloadDescriptor(playlist.id, targetPixelcount, targetBitrate); + _downloadPlaylists.save(playlistDownload); + continueDownload(playlistDownload, playlist); + } + fun download(video: IPlatformVideo, targetPixelcount: Long?, targetBitrate: Long?) { + download(VideoDownload(video, targetPixelcount, targetBitrate)); + } + fun download(video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: SubtitleRawSource?) { + download(VideoDownload(video, videoSource, audioSource, subtitleSource)); + } + + private fun download(videoState: VideoDownload, notify: Boolean = true) { + val shortName = if(videoState.name.length > 23) + videoState.name.substring(0, 20) + "..."; + else + videoState.name; + + try { + validateDownload(videoState); + _downloading.save(videoState); + + + if(notify) { + if(Settings.instance.downloads.shouldDownload()) { + UIDialogs.toast("Downloading [${shortName}]") + StateApp.withContext { + DownloadService.getOrCreateService(it); + } + onDownloadsChanged.emit(); + } + else { + UIDialogs.toast("Registered [${shortName}]\n(Can't download now)"); + } + } + } + catch(ex: Throwable) { + Logger.e(TAG, "Failed to start download", ex); + StateApp.withContext { + UIDialogs.showDialog( + it, + R.drawable.ic_error, + "Failed to start download due to:\n${ex.message}", null, null, + 0, + UIDialogs.Action("Ok", {}, UIDialogs.ActionStyle.PRIMARY) + ); + } + } + } + private fun validateDownload(videoState: VideoDownload) { + if(_downloading.hasItem { it.videoEither.url == videoState.videoEither.url }) + throw IllegalStateException("Video [${videoState.name}] is already queued for dowload"); + + val existing = getCachedVideo(videoState.id); + if(existing != null) { + //Verify for better video + val targetPx = if(videoState.targetPixelCount != null) + videoState.targetPixelCount!!.toInt(); + else if(videoState.videoSource != null) + videoState.videoSource!!.width * videoState.videoSource!!.height; + else + null; + if(targetPx != null) { + val bestExistingVideo = existing.videoSource.maxBy { it.width * it.height }; + val bestPx = bestExistingVideo.height * bestExistingVideo.width; + if (bestPx.toFloat() / targetPx >= 0.85f) + throw IllegalStateException("A higher resolution video source is already downloaded"); + } + + //Verify for better bitrate + val targetBitrate = if(videoState.targetBitrate != null) + videoState.targetBitrate!!.toInt(); + else if(videoState.audioSource != null) + videoState.audioSource!!.bitrate; + else + null; + if(targetBitrate != null) { + val bestExistingAudio = existing.audioSource.maxBy { it.bitrate }; + if(bestExistingAudio.bitrate / targetBitrate >= 0.85f) + throw IllegalStateException("A higher bitrate audio source is already downloaded"); + } + } + } + + fun cleanupDownloads(): Pair { + val expected = getDownloadedVideos(); + val validFiles = HashSet(expected.flatMap { it.videoSource.map { it.filePath } + it.audioSource.map { it.filePath } }); + + var totalDeleted: Long = 0; + var totalDeletedCount = 0; + for(file in _downloadsDirectory.listFiles()) { + val absUrl = file.absolutePath; + if(!validFiles.contains(absUrl)) { + Logger.i("StateDownloads", "Deleting unresolved ${file.name}"); + totalDeletedCount++; + totalDeleted += file.length(); + file.delete(); + } + } + return Pair(totalDeletedCount, totalDeleted); + } + + fun getDownloadsDirectory(): File{ + return _downloadsDirectory; + } + + + + //Export + fun getExporting(): List { + return _exporting.getItems(); + } + fun checkForExportTodos() { + if(_exporting.hasItems()) { + StateApp.withContext { + ExportingService.getOrCreateService(it); + } + } + } + + fun validateExport(export: VideoExport) { + if(_exporting.hasItem { it.videoLocal.url == export.videoLocal.url }) + throw AlreadyQueuedException("Video [${export.videoLocal.name}] is already queued for export"); + } + fun export(videoLocal: VideoLocal, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, notify: Boolean = true) { + val shortName = if(videoLocal.name.length > 23) + videoLocal.name.substring(0, 20) + "..."; + else + videoLocal.name; + + val videoExport = VideoExport(videoLocal, videoSource, audioSource, subtitleSource); + + try { + validateExport(videoExport); + _exporting.save(videoExport); + + if(notify) { + if(videoSource == null) + UIDialogs.toast("Exporting [${shortName}]\nIn your music directory under Grayjay"); + else + UIDialogs.toast("Exporting [${shortName}]\nIn your movies directory under Grayjay"); + StateApp.withContext { ExportingService.getOrCreateService(it) }; + onExportsChanged.emit(); + } + } + catch (ex: AlreadyQueuedException) { + Logger.e(TAG, "File is already queued for export.", ex); + StateApp.withContext { ExportingService.getOrCreateService(it) }; + } + catch(ex: Throwable) { + StateApp.withContext { + UIDialogs.showDialog( + it, + R.drawable.ic_error, + "Failed to start export due to:\n${ex.message}", null, null, + 0, + UIDialogs.Action("Ok", {}, UIDialogs.ActionStyle.PRIMARY) + ); + } + } + } + + + fun removeExport(export: VideoExport) { + _exporting.delete(export); + export.isCancelled = true; + onExportsChanged.emit(); + } + + companion object { + const val TAG = "StateDownloads"; + + private var _instance : StateDownloads? = null; + val instance : StateDownloads + get(){ + if(_instance == null) + _instance = StateDownloads(); + return _instance!!; + }; + + fun finish() { + _instance?.let { + _instance = null; + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateMeta.kt b/app/src/main/java/com/futo/platformplayer/states/StateMeta.kt new file mode 100644 index 00000000..2b9ae9fb --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/states/StateMeta.kt @@ -0,0 +1,34 @@ +package com.futo.platformplayer.states + +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringHashSetStorage + +class StateMeta { + val hiddenVideos = FragmentedStorage.get("hiddenVideos"); + + fun isVideoHidden(videoUrl: String) : Boolean { + return hiddenVideos.contains(videoUrl); + } + fun addHiddenVideo(videoUrl: String) { + hiddenVideos.addDistinct(videoUrl); + } + fun removeHiddenVideo(videoUrl: String) { + hiddenVideos.remove(videoUrl); + } + + companion object { + private var _instance : StateMeta? = null; + val instance : StateMeta + get(){ + if(_instance == null) + _instance = StateMeta(); + return _instance!!; + }; + + fun finish() { + _instance?.let { + _instance = null; + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePayment.kt b/app/src/main/java/com/futo/platformplayer/states/StatePayment.kt new file mode 100644 index 00000000..862121aa --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/states/StatePayment.kt @@ -0,0 +1,37 @@ +package com.futo.platformplayer.states + +import com.futo.futopay.PaymentState +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringStorage + +const val isTestingPayment = false; +class StatePayment : PaymentState(if(!isTestingPayment) VERIFICATION_PUBLIC_KEY else VERIFICATION_PUBLIC_KEY_TESTING) { + override val isTesting: Boolean get() = isTestingPayment; + + override fun savePaymentKey(licenseKey: String, licenseActivation: String) { + FragmentedStorage.get("paymentLicenseKey").setAndSave(licenseKey); + FragmentedStorage.get("paymentLicenseActivation").setAndSave(licenseActivation); + } + + override fun getPaymentKey(): Pair { + return Pair(FragmentedStorage.get("paymentLicenseKey").value, FragmentedStorage.get("paymentLicenseActivation").value); + } + + companion object { + private val VERIFICATION_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzJqqETLa42xw4AfbNOLQolMdMiGgg8DAC4RXEcH4/gytLhaqp1XsjiiMkADi1C7sDtGj6kOuAuQkqXQKpZ2dJSZsO+GPyop6DmgfAM6MQgOgFUpwsb3Lt3SvskJcls8MeOC+jg+GjjcuJI8qOfYevj4/7wAOpqzAwocTYnJivlK5nrC+qNtUC2HZX93OVu69aU5yvA1SQe9GiiU7vBld+CbzHxTcABCK/THu/BpLtGx0M7W3HNMKK1Z79dopCL9ZZWbWdkGDY8Zf39Gn/WVrs5elBvPzU+AfNYty77vx2r+sKgyohlbz4KVYpnw8HfawKcwuRE/GUyD3F2hUcXy8dQIDAQAB"; + private val VERIFICATION_PUBLIC_KEY_TESTING = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqyDuxsRtD5gmBoLCNoZa" + + "XSRTwyUxgzcPHzLZkvomXVSQqzD+3aOKngcTKAZ83rm4GvoyMlBukxQMLShannSx" + + "k8GQGTCT7VStQKNc4lKVER5ASB6aEaypaFMIYI3rXN1xLF1LqY/j7cu5GgMsvAuU" + + "VYFBexYFF6xcC5JDBZW6Pw/KYoJm3rswFixjPMGESmZRFCjjdAkHk47BhRPFBlvz" + + "wv9Ez1stdHcTpa/odEXIeJWIsZk9DHtCNCZyt6B6FXojVzrXsF2TxCNHGcHhlX43" + + "ALgQikiRcof1FsxoewTQhjLwMiDqB02mHCdRxssdnW3xadqyK678kQKfoIB1KB2N" + + "/QIDAQAB"; + private var _instance : StatePayment? = null; + val instance : StatePayment + get(){ + if(_instance == null) + _instance = StatePayment(); + return _instance!!; + }; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt new file mode 100644 index 00000000..ca901261 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt @@ -0,0 +1,875 @@ +package com.futo.platformplayer.states + +import android.content.Context +import androidx.collection.LruCache +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.IPluginSourced +import com.futo.platformplayer.api.media.PlatformClientPool +import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException +import com.futo.platformplayer.api.media.models.FilterGroup +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.ResultCapabilities +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.contents.PlatformContentPlaceholder +import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor +import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent +import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.platforms.js.DevJSClient +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.structures.* +import com.futo.platformplayer.awaitFirstNotNullDeferred +import com.futo.platformplayer.constructs.BatchedTaskHandler +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.getNowDiffDays +import com.futo.platformplayer.getNowDiffSeconds +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.ImageVariable +import com.futo.platformplayer.stores.* +import kotlinx.coroutines.* +import okhttp3.internal.concat +import java.time.OffsetDateTime +import kotlin.streams.toList + +/*** + * Used to interact with sources/clients + */ +class StatePlatform { + private val TAG = "StatePlatform"; + private val VIDEO_CACHE = 1024 * 1024 * 10; + + private val _scope = CoroutineScope(Dispatchers.IO); + + //Caches + private data class CachedPlatformContent(val video: IPlatformContentDetails, val creationTime: OffsetDateTime = OffsetDateTime.now()); + private val _cacheExpirationSeconds = 60 * 5; + private val _cache : LruCache = LruCache(VIDEO_CACHE); + + //Clients + private val _enabledClientsPersistent = FragmentedStorage.get("enabledClients"); + private val _platformOrderPersistent = FragmentedStorage.get("platformOrder"); + private val _clientsLock = Object(); + private val _availableClients : ArrayList = ArrayList(); + private val _enabledClients : ArrayList = ArrayList(); + + private val _clientPools: HashMap = hashMapOf(); + + private val _primaryClientPersistent = FragmentedStorage.get("primaryClient"); + private var _primaryClientObj : IPlatformClient? = null; + val primaryClient : IPlatformClient get() = _primaryClientObj ?: throw IllegalStateException("PlatformState not yet initialized"); + + + private val _icons : HashMap = HashMap(); + + val hasClients: Boolean get() = _availableClients.size > 0; + + val onSourceDisabled = Event1(); + + val onDevSourceChanged = Event0(); + + //TODO: Remove after verifying that enabled clients are already in persistent order + val platformOrder get() = _platformOrderPersistent.values.toList(); + + //Batched Requests + private val _batchTaskGetVideoDetails: BatchedTaskHandler = BatchedTaskHandler(_scope, + { url -> + Logger.i(StatePlatform::class.java.name, "Fetching video details [${url}]"); + _enabledClients.find { it.isContentDetailsUrl(url) }?.getContentDetails(url) + ?: throw NoPlatformClientException("No client enabled that supports this url ($url)"); + }, + { + if(!Settings.instance.browsing.videoCache) + return@BatchedTaskHandler null; + else { + val cached = synchronized(_cache) { _cache.get(it); } ?: return@BatchedTaskHandler null; + if (cached.creationTime.getNowDiffSeconds() > _cacheExpirationSeconds) { + Logger.i(TAG, "Invalidated cache for [${it}]"); + synchronized(_cache) { + _cache.remove(it); + } + return@BatchedTaskHandler null; + } + return@BatchedTaskHandler cached.video; + } + }, + { para, result -> + if(!Settings.instance.browsing.videoCache || (result is IPlatformVideo && result.isLive)) + return@BatchedTaskHandler + else { + Logger.i(TAG, "Caching [${para}]"); + if (result.datetime == null || result.datetime!! < OffsetDateTime.now()) + synchronized(_cache) { + _cache.put(para, CachedPlatformContent(result)) + } + } + }); + + constructor() { + onSourceDisabled.subscribe { + synchronized(_cache) { + for(item in _cache.snapshot()) { + if(item.value.video is IPluginSourced) + if(it.id == (item.value.video as IPluginSourced).sourceConfig.id) { + Logger.i(TAG, "Removing [${item.value.video.name}] from cache because plugin disabled"); + _cache.remove(item.key); + + } + } + } + }; + } + + + suspend fun updateAvailableClients(context: Context, reloadPlugins: Boolean = false) { + if(reloadPlugins) + StatePlugins.instance.reloadPluginFile(); + withContext(Dispatchers.IO) { + var enabled: Array; + synchronized(_clientsLock) { + for(enabled in _enabledClients) { + enabled.disable(); + onSourceDisabled.emit(enabled); + } + + _enabledClients.clear(); + _availableClients.clear(); + //_availableClients.add(YoutubeClient()); + //_availableClients.add(OdyseeClient()); + + _icons.clear(); + _icons[StateDeveloper.DEV_ID] = ImageVariable(null, R.drawable.ic_security_red); + + StatePlugins.instance.updateEmbeddedPlugins(context); + StatePlugins.instance.installMissingEmbeddedPlugins(context); + + for(plugin in StatePlugins.instance.getPlugins()) { + + _icons[plugin.config.id] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?: + ImageVariable(plugin.config.absoluteIconUrl, null); + + _availableClients.add(JSClient(context, plugin)); + } + + if(_availableClients.distinctBy { it.id }.count() < _availableClients.size) + throw IllegalStateException("Attempted to add 2 clients with the same ID"); + + enabled = _enabledClientsPersistent.getAllValues() + .filter { _availableClients.any { ac -> ac.id == it } } + .toTypedArray(); + if(enabled.isEmpty()) + enabled = StatePlugins.instance.getEmbeddedSourcesDefault(context) + .filter { id -> _availableClients.any { it.id == id } } + .toTypedArray(); + + + val primary = _primaryClientPersistent.value; + if(primary.isNullOrEmpty() || primary == StateDeveloper.DEV_ID) + selectPrimaryClient(enabled.firstOrNull() ?: _availableClients.first().id); + else if(!_availableClients.any { it.id == primary }) + selectPrimaryClient(_availableClients.firstOrNull()?.id!!); + else + selectPrimaryClient(primary); + + if(!enabled.any { it == primaryClient.id }) + enabled = enabled.concat(primaryClient.id); + } + selectClients(*enabled); + }; + } + + fun isClientEnabled(id: String): Boolean { + synchronized(_clientsLock) { + return _enabledClients.any { it.id == id }; + } + } + fun isClientEnabled(client: IPlatformClient): Boolean { + synchronized (_clientsLock) { + return _enabledClients.contains(client); + } + } + + fun getAvailableClients(): List { + synchronized(_clientsLock) { + return _availableClients.toList(); + } + } + fun getEnabledClients(): List { + synchronized(_clientsLock) { + return _enabledClients.toList(); + } + } + + //TODO: getEnabledClients should already be ordered, remove after verify that that is the case + fun getSortedEnabledClient(): List { + synchronized(_clientsLock) { + val enabledClients = _enabledClients; + val orderedSources = platformOrder.mapNotNull { order -> + enabledClients.firstOrNull { it.name == order } + } + + val remainingSources = enabledClients.filter { it !in orderedSources } + return orderedSources + remainingSources; + } + } + fun getClientOrNullByUrl(url: String): IPlatformClient? { + return getChannelClientOrNull(url) ?: getPlaylistClientOrNull(url) ?: getContentClientOrNull(url); + } + fun getClientOrNull(id: String): IPlatformClient? { + synchronized(_clientsLock) { + return _availableClients.find { it.id == id }; + } + } + fun getClient(id: String): IPlatformClient { + return getClientOrNull(id) ?: throw IllegalArgumentException("Client with id $id does not exist"); + } + fun getClientPooled(parentClient: IPlatformClient, capacity: Int): IPlatformClient { + val pool = synchronized(_clientPools) { + if(!_clientPools.containsKey(parentClient)) + _clientPools[parentClient] = PlatformClientPool(parentClient).apply { + this.onDead.subscribe { client, pool -> + synchronized(_clientPools) { + if(_clientPools[parentClient] == pool) + _clientPools.remove(parentClient); + } + } + } + _clientPools[parentClient]!!; + }; + return pool.getClient(capacity); + } + fun getClientsByClaimType(claimType: Int): List { + return getEnabledClients().filter { it.isClaimTypeSupported(claimType) }; + } + fun getClientByClaimTypeOrNull(claimType: Int): IPlatformClient? { + return getEnabledClients().firstOrNull { it.isClaimTypeSupported(claimType) }; + } + fun getDevClient() : DevJSClient? { + return getClientOrNull(StateDeveloper.DEV_ID) as DevJSClient?; + } + + fun getPlatformIcon(type: String?) : ImageVariable? { + if(type == null) + return null; + if(_icons.containsKey(type)) + return _icons[type]; + return null; + } + + fun setPlatformOrder(platformOrder: List) { + _platformOrderPersistent.values.clear(); + _platformOrderPersistent.values.addAll(platformOrder); + _platformOrderPersistent.save(); + } + + suspend fun reloadClient(context: Context, id: String) : JSClient? { + return withContext(Dispatchers.IO) { + val client = getClient(id); + if (client !is JSClient) + return@withContext null; //TODO: Error? + + Logger.i(TAG, "Reloading plugin ${client.name}"); + + val newClient = if (client is DevJSClient) + client.recreate(context); + else + JSClient(context, + StatePlugins.instance.getPlugin(id) + ?: throw IllegalStateException("Client existed, but plugin config didn't") + ); + + synchronized(_clientsLock) { + if (_enabledClients.contains(client)) { + _enabledClients.remove(client); + client.disable(); + onSourceDisabled.emit(client); + newClient.initialize(); + _enabledClients.add(newClient); + } + if (_primaryClientObj == client) + _primaryClientObj = newClient; + + _availableClients.removeIf { it.id == id }; + _availableClients.add(newClient); + } + return@withContext newClient; + }; + } + + /** + * Selects the enabled clients, meaning all clients that data is actively requested from. + * If a client is disabled, NO requests are made to said client + */ + suspend fun selectClients(vararg ids: String) { + withContext(Dispatchers.IO) { + synchronized(_clientsLock) { + val removed = _enabledClients.toMutableList(); + _enabledClients.clear(); + for (id in ids) { + val client = getClient(id); + try { + if (!removed.removeIf { it == client }) + client.initialize(); + _enabledClients.add(client); + } + catch(ex: Exception) { + Logger.e(TAG, "Plugin ${client.name} failed to load\n${ex.message}", ex) + UIDialogs.toast("Plugin ${client.name} failed to load\n${ex.message}"); + } + } + _enabledClientsPersistent.set(*ids); + _enabledClientsPersistent.save(); + + for (oldClient in removed) { + oldClient.disable(); + onSourceDisabled.emit(oldClient); + } + } + }; + } + + /** + * Selects the primary client, meaning the first target for requests. + * At the moment, since multi-client requests are not yet implemented, this is the goto client. + */ + fun selectPrimaryClient(id: String) { + synchronized(_clientsLock) { + _primaryClientObj = getClient(id); + _primaryClientPersistent.setAndSave(id); + } + } + + fun getHome(): IPager { + Logger.i(TAG, "Platform - getHome"); + var clientIdsOngoing = mutableListOf(); + val clients = getSortedEnabledClient().filter { if (it is JSClient) it.enableInHome else true }; + + StateApp.instance.scopeOrNull?.let { + it.launch(Dispatchers.Default) { + try { + delay(5000); + val slowClients = synchronized(clientIdsOngoing) { + return@synchronized clients.filter { clientIdsOngoing.contains(it.id) }; + }; + for(client in slowClients) + UIDialogs.toast("${client.name} is still loading..\nConsider disabling it for Home", false); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to show toast for slow source.", e) + } + } + }; + + val pages = clients.parallelStream() + .map { + Logger.i(TAG, "getHome - ${it.name}") + synchronized(clientIdsOngoing) { + clientIdsOngoing.add(it.id); + } + val homeResult = it.getHome(); + synchronized(clientIdsOngoing) { + clientIdsOngoing.remove(it.id); + } + return@map homeResult; + } + .toList() + .associateWith { 1f }; + + val pager = MultiDistributionContentPager(pages); + pager.initialize(); + return pager; + } + suspend fun getHomeRefresh(scope: CoroutineScope): IPager { + Logger.i(TAG, "Platform - getHome (Refresh)"); + val clients = getSortedEnabledClient().filter { if (it is JSClient) it.enableInHome else true }; + + val deferred: List?>>> = clients.map { + return@map Pair(it, scope.async(Dispatchers.IO) { + try { + val searchResult = it.getHome(); + return@async searchResult; + } catch(ex: Throwable) { + Logger.e(TAG, "getHomeRefresh", ex); + return@async null; + } + }); + }.toList(); + + val finishedPager = deferred.map { it.second }.awaitFirstNotNullDeferred() ?: return EmptyPager(); + val toAwait = deferred.filter { it.second != finishedPager.first }; + return RefreshDistributionContentPager( + listOf(finishedPager.second), + toAwait.map { it.second }, + toAwait.map { PlaceholderPager(5, { PlatformContentPlaceholder(it.first.id) }) }); + } + + fun getHomePrimary(): IPager { + return primaryClient.getHome(); + } + + //Search + fun searchSuggestions(query: String): Array { + Logger.i(TAG, "Platform - searchSuggestions"); + return primaryClient.searchSuggestions(query); + } + + fun search(query: String, type: String? = null, sort: String? = null, filters: Map> = mapOf(), clientIds: List? = null): IPager { + Logger.i(TAG, "Platform - search"); + val pagers = mutableMapOf, Float>(); + val clients = if (clientIds != null) getSortedEnabledClient().filter { (if (it is JSClient) it.enableInSearch else true) && clientIds.contains(it.id) } + else getSortedEnabledClient().filter { if (it is JSClient) it.enableInSearch else true }; + + for (c in clients) { + Logger.i(TAG, "Client enabled for search: " + c.name) + } + + clients.parallelStream().forEach { + val searchCapabilities = it.getSearchCapabilities(); + val mappedFilters = filters.map { pair -> Pair(pair.key, pair.value.map { v -> searchCapabilities.filters.first { g -> g.idOrName == pair.key }.filters.first { f -> f.idOrName == v }.value }) }.toMap(); + pagers.put(it.search(query, type, sort, mappedFilters), 1f); + }; + + val pager = MultiDistributionContentPager(pagers); + pager.initialize(); + return pager; + } + fun searchPlaylist(query: String, type: String? = null, sort: String? = null, filters: Map> = mapOf(), clientIds: List? = null): IPager { + Logger.i(TAG, "Platform - search playlist"); + val pagers = mutableMapOf, Float>(); + val clients = if (clientIds != null) getSortedEnabledClient().filter { (if (it is JSClient) it.enableInSearch else true) && clientIds.contains(it.id) } + else getSortedEnabledClient().filter { if (it is JSClient) it.enableInSearch else true }; + + for (c in clients) { + Logger.i(TAG, "Client enabled for search: " + c.name) + } + + clients.filter { it.capabilities.hasSearchPlaylists }.parallelStream().forEach { + val searchCapabilities = it.getSearchCapabilities(); + val mappedFilters = filters.map { pair -> Pair(pair.key, pair.value.map { v -> searchCapabilities.filters.first { g -> g.idOrName == pair.key }.filters.first { f -> f.idOrName == v }.value }) }.toMap(); + pagers.put(it.searchPlaylists(query, type, sort, mappedFilters), 1f); + }; + + val pager = MultiDistributionContentPager(pagers); + pager.initialize(); + return pager; + } + suspend fun searchRefresh(scope: CoroutineScope, query: String, type: String? = null, sort: String? = null, filters: Map> = mapOf(), clientIds: List? = null): IPager { + Logger.i(TAG, "Platform - search (refresh)"); + val clients = + if (clientIds != null) getSortedEnabledClient().filter { (if (it is JSClient) it.enableInSearch else true) && clientIds.contains(it.id) } + else getSortedEnabledClient().filter { if (it is JSClient) it.enableInSearch else true }; + + for (c in clients) { + Logger.i(TAG, "Client enabled for search: " + c.name) + } + + val deferred: List?>>> = clients.map { + return@map Pair(it, scope.async(Dispatchers.IO) { + try { + val searchCapabilities = it.getSearchCapabilities(); + val mappedFilters = filters.map { pair -> Pair(pair.key, pair.value.map { v -> searchCapabilities.filters.first { g -> g.idOrName == pair.key }.filters.first { f -> f.idOrName == v }.value }) }.toMap(); + val searchResult = it.search(query, type, sort, mappedFilters); + return@async searchResult; + } catch(ex: Throwable) { + Logger.e(TAG, "searchRefresh", ex); + return@async null; + } + }); + }.toList(); + + val finishedPager = deferred.map { it.second }.awaitFirstNotNullDeferred() ?: return EmptyPager(); + val toAwait = deferred.filter { it.second != finishedPager.first }; + return RefreshDistributionContentPager( + listOf(finishedPager.second), + toAwait.map { it.second }, + toAwait.map { PlaceholderPager(5, { PlatformContentPlaceholder(it.first.id) }) }); + } + + fun searchChannel(channelUrl: String, query: String, type: String? = null, sort: String? = null, filters: Map> = mapOf(), clientIds: List? = null): IPager { + Logger.i(TAG, "Platform - search channel $channelUrl"); + + val pagers = mutableMapOf, Float>(); + val clients = if (clientIds != null) getSortedEnabledClient().filter { (if (it is JSClient) it.enableInSearch else true) && clientIds.contains(it.id) } + else getSortedEnabledClient().filter { if (it is JSClient) it.enableInSearch else true }; + + clients.parallelStream().forEach { + val searchCapabilities = it.getSearchCapabilities(); + val mappedFilters = filters.map { pair -> Pair(pair.key, pair.value.map { v -> searchCapabilities.filters.first { g -> g.idOrName == pair.key }.filters.first { f -> f.idOrName == v }.value }) }.toMap(); + + if (it.isChannelUrl(channelUrl)) { + pagers.put(it.searchChannelContents(channelUrl, query, type, sort, mappedFilters), 1f); + } + }; + + val pager = MultiDistributionContentPager(pagers); + pager.initialize(); + return pager; + } + + fun getCommonSearchCapabilities(clientIds: List): ResultCapabilities? { + try { + Logger.i(TAG, "Platform - getCommonSearchCapabilities"); + + val clients = getEnabledClients().filter { clientIds.contains(it.id) }; + val c = clients.firstOrNull() ?: return null; + val cap = c.getSearchCapabilities(); + + //var types = arrayListOf(); + var sorts = cap.sorts.toMutableList(); + var filters = cap.filters.toMutableList(); + + val sortsToRemove = arrayListOf(); + val filtersToRemove = arrayListOf(); + + for (i in 1 until clients.size) { + val clientSearchCapabilities = clients[i].getSearchCapabilities(); + + for (j in 0 until sorts.size) { + if (!clientSearchCapabilities.sorts.contains(sorts[j])) { + sortsToRemove.add(j); + } + } + + sorts = sorts.filterIndexed { index, _ -> index !in sortsToRemove }.toMutableList(); + + for (k in 0 until filters.size) { + val matchingFilterGroup = clientSearchCapabilities.filters.firstOrNull { f -> filters[k].idOrName == f.idOrName }; + if (matchingFilterGroup == null) { + filtersToRemove.add(k); + } else { + val currentFilterGroup = filters[k]; + filters[k] = FilterGroup(currentFilterGroup.name, currentFilterGroup.filters.filter { a -> matchingFilterGroup.filters.any { b -> a.idOrName == b.idOrName } } + , currentFilterGroup.isMultiSelect, currentFilterGroup.id); + } + } + + filters = filters.filterIndexed { index, _ -> index !in filtersToRemove }.toMutableList(); + } + + return ResultCapabilities(listOf(), sorts, filters); + } catch (e: Throwable) { + Logger.w(TAG, "Failed to get common search capabilities.", e); + return null; + } + } + + fun isSearchChannelsAvailable(): Boolean { + return getEnabledClients().any { it.capabilities.hasChannelSearch }; + } + fun searchChannels(query: String): IPager { + Logger.i(TAG, "Platform - searchChannels"); + val pagers = mutableMapOf, Float>(); + getSortedEnabledClient().parallelStream().forEach { + try { + if (it.capabilities.hasChannelSearch) + pagers.put(it.searchChannels(query), 1f); + } + catch(ex: Throwable) { + UIDialogs.toast("Failed search channels on [${it.name}]\n(${ex.message})"); + } + }; + if(pagers.isEmpty()) + return EmptyPager(); + + val pager = MultiDistributionChannelPager(pagers); + pager.initialize(); + return pager; + } + + + //Video + fun hasEnabledVideoClient(url: String) : Boolean = getEnabledClients().any { it.isContentDetailsUrl(url) }; + fun getContentClient(url: String) : IPlatformClient = getContentClientOrNull(url) + ?: throw NoPlatformClientException("No client enabled that supports this channel url (${url})"); + fun getContentClientOrNull(url: String) : IPlatformClient? = getEnabledClients().find { it.isContentDetailsUrl(url) }; + fun getContentDetails(url: String, forceRefetch: Boolean = false): Deferred { + Logger.i(TAG, "Platform - getContentDetails (${url})"); + if(forceRefetch) + clearContentDetailCache(url); + + return _batchTaskGetVideoDetails.execute(url); + } + fun clearContentDetailCache(url: String) { + if(_cache.get(url) != null) { + Logger.i(TAG, "Force clearing cache (${url})"); + _cache.remove(url); + } + } + + fun getPlaybackTracker(url: String): IPlaybackTracker? { + return getContentClientOrNull(url)?.getPlaybackTracker(url); + } + + fun hasEnabledChannelClient(url : String) : Boolean = getEnabledClients().any { it.isChannelUrl(url) }; + fun getChannelClient(url : String) : IPlatformClient = getChannelClientOrNull(url) + ?: throw NoPlatformClientException("No client enabled that supports this channel url (${url})"); + fun getChannelClientOrNull(url : String) : IPlatformClient? = getEnabledClients().find { it.isChannelUrl(url) }; + + fun getChannel(url: String, updateSubscriptions: Boolean = true): Deferred { + Logger.i(TAG, "Platform - getChannel"); + val channel = StateSubscriptions.instance.getSubscription(url); + if(channel != null) + return _scope.async { getChannelLive(url, updateSubscriptions) }; //_batchTaskGetChannel.execute(channel); + else + return _scope.async { getChannelLive(url, updateSubscriptions) }; + } + + fun getChannelContent(channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0): IPager { + Logger.i(TAG, "Platform - getChannelVideos"); + val baseClient = getChannelClient(channelUrl); + val clientCapabilities = baseClient.getChannelCapabilities(); + + val client = if(usePooledClients > 1) + getClientPooled(baseClient, usePooledClients); + else baseClient; + + var lastStream: OffsetDateTime? = null; + + val pagerResult: IPager; + if(!clientCapabilities.hasType(ResultCapabilities.TYPE_MIXED) && + clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS) && + clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS)) { + val toQuery = mutableListOf(); + if(clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS)) + toQuery.add(ResultCapabilities.TYPE_VIDEOS); + if(clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS)) + toQuery.add(ResultCapabilities.TYPE_STREAMS); + if(clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE)) + toQuery.add(ResultCapabilities.TYPE_LIVE); + + if(isSubscriptionOptimized) { + val sub = StateSubscriptions.instance.getSubscription(channelUrl); + if(sub != null) { + val daysSinceLiveStream = sub.lastLiveStream.getNowDiffDays() + if(daysSinceLiveStream > 7) { + Logger.i(TAG, "Subscription [${channelUrl}] Last livestream > 7 days, skipping live streams [${daysSinceLiveStream} days ago]"); + toQuery.remove(ResultCapabilities.TYPE_LIVE); + } + if(daysSinceLiveStream > 14) { + Logger.i(TAG, "Subscription [${channelUrl}] Last livestream > 15 days, skipping streams [${daysSinceLiveStream} days ago]"); + toQuery.remove(ResultCapabilities.TYPE_STREAMS); + } + } + } + + //Merged pager + val pagers = toQuery + .parallelStream() + .map { + val results = client.getChannelContents(channelUrl, it, ResultCapabilities.ORDER_CHONOLOGICAL) ; + + when(it) { + ResultCapabilities.TYPE_STREAMS -> { + val streamResults = results.getResults(); + if(streamResults.size == 0) + lastStream = OffsetDateTime.MIN; + else + lastStream = results.getResults().firstOrNull()?.datetime; + } + } + return@map results; + } + .toList(); + + val pager = MultiChronoContentPager(pagers.toTypedArray()); + pager.initialize(); + pagerResult = pager; + } + else + pagerResult = client.getChannelContents(channelUrl, ResultCapabilities.TYPE_MIXED, ResultCapabilities.ORDER_CHONOLOGICAL); + + //Subscription optimization + val sub = StateSubscriptions.instance.getSubscription(channelUrl); + if(sub != null) { + var hasChanges = false; + val lastVideo = pagerResult.getResults().firstOrNull(); + + if(lastVideo?.datetime != null && sub.lastVideo.getNowDiffDays() != lastVideo.datetime!!.getNowDiffDays()) { + Logger.i(TAG, "Subscription [${channelUrl}] has new last video date [${lastVideo.datetime?.getNowDiffDays()} Days]"); + sub.lastVideo = lastVideo.datetime ?: sub.lastVideo; + hasChanges = true; + } + + if(lastStream != null && sub.lastLiveStream.getNowDiffDays() != lastStream!!.getNowDiffDays()) { + Logger.i(TAG, "Subscription [${channelUrl}] has new last stream date [${lastStream!!.getNowDiffDays()} Days]"); + sub.lastLiveStream = lastStream!!; + hasChanges = true; + } + + val now = OffsetDateTime.now(); + val firstPage = pagerResult.getResults().filter { it.datetime != null && it.datetime!! < now } + if(firstPage.size > 0) { + val newestVideoDays = firstPage[0].datetime?.getNowDiffDays()?.toInt() ?: 0; + val diffs = mutableListOf() + for(i in (firstPage.size - 1) downTo 1) { + val currentVideoDays = firstPage[i].datetime?.getNowDiffDays(); + val nextVideoDays = firstPage[i - 1].datetime?.getNowDiffDays(); + + if(currentVideoDays != null && nextVideoDays != null) { + val diff = nextVideoDays - currentVideoDays; + diffs.add(diff.toInt()); + } + } + val averageDiff = if(diffs.size > 0) + newestVideoDays.coerceAtLeast(diffs.average().toInt()) + else + newestVideoDays; + + if(sub.uploadInterval != averageDiff) { + Logger.i(TAG, "Subscription [${channelUrl}] has new upload interval [${averageDiff} Days]"); + sub.uploadInterval = averageDiff; + hasChanges = true; + } + } + + if(hasChanges) + StateSubscriptions.instance.saveSubscription(sub); + } + + return pagerResult; + } + + fun getChannelLive(url: String, updateSubscriptions: Boolean = true): IPlatformChannel { + val channel = getChannelClient(url).getChannel(url); + + if(updateSubscriptions && StateSubscriptions.instance.isSubscribed(channel)) + StateSubscriptions.instance.updateSubscriptionChannel(channel); + + return channel + } + + fun getChannelUrlByClaim(claimType: Int, claimValues: Map): String? { + val client = getClientByClaimTypeOrNull(claimType) ?: return null; + return client.getChannelUrlByClaim(claimType, claimValues); + } + fun resolveChannelUrlByClaimTemplates(claimType: Int, claimValues: Map): String? { + for(client in getClientsByClaimType(claimType).filter { it is JSClient }) { + val url = (client as JSClient).resolveChannelUrlByClaimTemplates(claimType, claimValues); + if(!url.isNullOrEmpty()) + return url; + } + return null; + } + + fun getPlaylistClientOrNull(url: String): IPlatformClient? = getEnabledClients().find { it.isPlaylistUrl(url) } + fun getPlaylistClient(url: String): IPlatformClient = getEnabledClients().find { it.isPlaylistUrl(url) } + ?: throw NoPlatformClientException("No client enabled that supports this playlist url (${url})"); + fun getPlaylist(url: String): IPlatformPlaylistDetails { + return getPlaylistClient(url).getPlaylist(url); + } + + //Comments + fun getComments(content: IPlatformContentDetails): IPager { + val client = getContentClient(content.url); + val pager = content.getComments(client); + + return pager ?: getComments(content.url); + } + fun getComments(url: String): IPager { + Logger.i(TAG, "Platform - getComments"); + val client = getContentClient(url); + if(!client.capabilities.hasGetComments) + return EmptyPager(); + + return client.getComments(url); + } + fun getSubComments(comment: IPlatformComment): IPager { + Logger.i(TAG, "Platform - getSubComments"); + val client = getContentClient(comment.contextUrl); + return client.getSubComments(comment); + } + + fun getLiveEvents(url: String): IPager? { + Logger.i(TAG, "Platform - getLiveChat"); + var client = getContentClient(url); + return client.getLiveEvents(url); + } + fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor? { + Logger.i(TAG, "Platform - getLiveChat"); + var client = getContentClient(url); + return client.getLiveChatWindow(url); + } + + + fun injectDevPlugin(source: SourcePluginConfig, script: String): String? { + var devId: String? = null; + synchronized(_clientsLock) { + val enabledExisting = _enabledClients.filter { it is DevJSClient }; + val isEnabled = !enabledExisting.isEmpty() + val isPrimary = _primaryClientObj is DevJSClient; + + for (enabled in enabledExisting) { + enabled.disable(); + } + + //Remove existing dev clients + _enabledClients.removeIf { it is DevJSClient }; + _availableClients.removeIf { it is DevJSClient }; + + source.id = StateDeveloper.DEV_ID; + val newClient = DevJSClient(StateApp.instance.context, source, script); + devId = newClient.devID; + try { + StateDeveloper.instance.initializeDev(devId!!); + var didEnable = false; + if (isPrimary) { + _primaryClientObj = newClient; + _enabledClients.add(0, newClient); + newClient.initialize(); + didEnable = true; + } else if (isEnabled) { + _enabledClients.add(newClient); + if(!didEnable) { + newClient.initialize(); + didEnable = true; + } + } + _availableClients.add(newClient); + } catch (ex: Exception) { + Logger.e("StatePlatform", "Failed to initialize DevPlugin: ${ex.message}", ex); + StateDeveloper.instance.logDevException(devId!!, "Failed to initialize due to: ${ex.message}"); + } + } + onDevSourceChanged.emit(); + return devId; + } + + //TODO: Be careful with calling this unless you know for sure you're not gonna need the current app state anymore + fun disableAllClients() { + synchronized(_clientsLock) { + val enabledClients = _enabledClients; + _enabledClients.clear(); + for(enabled in enabledClients) { + Logger.i(TAG, "Disabling plugin [${enabled.name}]"); + try { + enabled.disable(); + } + catch (ex: Throwable) {} + } + } + } + + companion object { + private var _instance : StatePlatform? = null; + val instance : StatePlatform + get(){ + if(_instance == null) + _instance = StatePlatform(); + return _instance!!; + }; + + fun finish() { + _instance?.let { + _instance = null; + it._scope.cancel("PlatformState finished"); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt new file mode 100644 index 00000000..a2dfd6f9 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt @@ -0,0 +1,505 @@ +package com.futo.platformplayer.states + +import android.content.Context +import android.util.Log +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.models.Playlist +import com.futo.platformplayer.services.MediaPlaybackService +import com.futo.platformplayer.video.PlayerManager +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.DefaultLoadControl +import com.google.android.exoplayer2.ExoPlayer +import com.google.android.exoplayer2.upstream.DefaultAllocator +import kotlin.random.Random + +/*** + * Used to keep track of queue and other player related stuff + */ +class StatePlayer { + private val MIN_BUFFER_DURATION = 10000; + private val MAX_BUFFER_DURATION = 60000; + private val MIN_PLAYBACK_START_BUFFER = 500; + private val MIN_PLAYBACK_RESUME_BUFFER = 1000; + private val BUFFER_SIZE = 1024 * 64; + + var isOpen : Boolean = false + private set; + + //Players + private var _exoplayer : PlayerManager? = null; + private var _thumbnailExoPlayer : PlayerManager? = null; + + //Video Status + var rotationLock : Boolean = false; + + val isPlaying: Boolean get() = _exoplayer?.player?.playWhenReady ?: false; + + //Queue + private val _queue = ArrayList(); + private var _queueShuffled: MutableList? = null; + private var _queueType = TYPE_QUEUE; + private var _queueName: String? = null; + private var _queuePosition = -1; + private var _queueRemoveOnFinish = false; + var queueFocused : Boolean = false + private set; + var queueRepeat: Boolean = false + private set; + var queueShuffle: Boolean = false + private set; + + val queueName: String get() = _queueName ?: _queueType; + + //Events + val onVideoChanging = Event1(); + val onQueueChanged = Event1(); + val onPlayerOpened = Event0(); + val onPlayerClosed = Event0(); + + var currentVideo: IPlatformVideoDetails? = null + private set; + + fun setCurrentlyPlaying(video: IPlatformVideoDetails?) { + currentVideo = video; + } + + + //Player Status + fun setPlayerOpen() { + isOpen = true; + onPlayerOpened.emit(); + } + fun setPlayerClosed() { + setCurrentlyPlaying(null); + isOpen = false; + clearQueue(); + onPlayerClosed.emit(); + closeMediaSession(); + } + + //Notifications + fun hasMediaSession() : Boolean { + return MediaPlaybackService.getService() != null; + } + fun startOrUpdateMediaSession(context: Context, videoUpdated: IPlatformVideoDetails?) { + MediaPlaybackService.getOrCreateService(context) { + it.updateMediaSession(videoUpdated); + }; + } + fun updateMediaSession(videoUpdated: IPlatformVideoDetails?) { + MediaPlaybackService.getService()?.updateMediaSession(videoUpdated); + } + fun updateMediaSessionPlaybackState(state: Int, pos: Long) { + MediaPlaybackService.getService()?.updateMediaSessionPlaybackState(state, pos); + } + fun closeMediaSession() { + MediaPlaybackService.getService()?.closeMediaSession(); + } + + //Queue Status + fun getQueueProgress(): Int { + synchronized(_queue) { + return _queuePosition; + } + } + fun getQueueLength() : Int { + synchronized(_queue) { + return _queue.size; + } + } + fun isInQueue(id : String) : Boolean { + synchronized(_queue) { + return _queue.any { it.id.value == id }; + } + } + + fun getQueueType() : String { + return _queueType; + } + fun getQueue() : List { + synchronized(_queue) { + val queueShuffled = _queueShuffled; + if (queueShuffle && queueShuffled != null) { + return queueShuffled.toList() + } else { + return _queue.toList() + } + } + } + + fun setQueueType(queueType : String) { + when(queueType) { + TYPE_QUEUE -> { + _queueRemoveOnFinish = true; + } + TYPE_WATCHLATER -> { + _queueRemoveOnFinish = true; + } + TYPE_PLAYLIST -> { + _queueRemoveOnFinish = false; + } + } + _queueType = queueType; + } + + fun setQueueRepeat(enabled: Boolean) { + synchronized(_queue) { + queueRepeat = enabled; + } + } + fun setQueueShuffle(shuffle: Boolean, excludeCurrent: Boolean = true) { + synchronized(_queue) { + queueShuffle = shuffle; + if (shuffle) { + createShuffledQueue(); + } else { + _queueShuffled = null; + } + + onQueueChanged.emit(false); + } + } + + private fun createShuffledQueue() { + val currentItem = getCurrentQueueItem(); + if (_queuePosition == -1 || currentItem == null) { + _queueShuffled = _queue.shuffled().toMutableList() + return; + } + + val nextItems = _queue.subList(Math.min(_queuePosition + 1, _queue.size - 1), _queue.size).shuffled(); + val previousItems = _queue.subList(0, _queuePosition).shuffled(); + _queueShuffled = (previousItems + currentItem + nextItems).toMutableList(); + } + + private fun addToShuffledQueue(video: IPlatformVideo) { + val isLastVideo = _queuePosition + 1 >= _queue.size; + if (isLastVideo) { + _queueShuffled?.add(video) + } else { + val indexToInsert = Random.nextInt(_queuePosition + 1, _queue.size) + _queueShuffled?.add(indexToInsert, video) + } + } + private fun removeFromShuffledQueue(video: IPlatformVideo) { + _queueShuffled?.remove(video); + } + + //Modify Queue + fun setQueue(videos: List, type: String, queueName: String? = null, focus: Boolean = false, shuffle: Boolean = false) { + synchronized(_queue) { + _queue.clear(); + setQueueType(type); + _queueName = queueName; + queueRepeat = false; + _queue.addAll(videos); + _queuePosition = 0; + queueFocused = focus; + queueShuffle = shuffle; + if (shuffle) { + createShuffledQueue(); + } + } + onQueueChanged.emit(true); + } + fun setPlaylist(playlist: IPlatformPlaylistDetails, toPlayIndex: Int = 0, focus: Boolean = false, shuffle: Boolean = false) { + synchronized(_queue) { + _queue.clear(); + setQueueType(TYPE_PLAYLIST); + _queueName = playlist.name; + _queue.addAll(playlist.contents.getResults()); + queueFocused = focus; + queueShuffle = shuffle; + if (shuffle) { + createShuffledQueue(); + } + _queuePosition = toPlayIndex; + } + playlist.id.value?.let { StatePlaylists.instance.didPlay(it); }; + + onQueueChanged.emit(true); + } + fun setPlaylist(playlist: Playlist, toPlayIndex: Int = 0, focus: Boolean = false, shuffle: Boolean = false) { + synchronized(_queue) { + _queue.clear(); + setQueueType(TYPE_PLAYLIST); + _queueName = playlist.name; + _queue.addAll(playlist.videos); + queueFocused = focus; + queueShuffle = shuffle; + if (shuffle) { + createShuffledQueue(); + } + _queuePosition = toPlayIndex; + } + StatePlaylists.instance.didPlay(playlist.id); + + onQueueChanged.emit(true); + } + fun setQueueWithPosition(videos: List, type: String, pos: Int, focus: Boolean = false) { + //TODO: Implement support for pagination + val index = if(videos.size <= pos) 0 else pos; + synchronized(_queue) { + _queue.clear(); + setQueueType(type); + _queue.addAll(videos); + queueShuffle = false; + _queuePosition = index; + queueFocused = focus; + } + onQueueChanged.emit(true); + } + fun setQueueWithExisting(videos: List, withFocus: Boolean = false) { + val currentItem = getCurrentQueueItem(); + val index = videos.indexOf(currentItem); + setQueueWithPosition(videos, _queueType, index, withFocus); + } + fun addToQueue(video: IPlatformVideo) { + synchronized(_queue) { + if(_queue.isEmpty()) { + setQueueType(TYPE_QUEUE); + currentVideo?.let { + _queue.add(it); + } + } + + _queue.add(video); + if (queueShuffle) { + addToShuffledQueue(video); + } + + if (_queuePosition < 0) { + _queuePosition = 0; + } + } + onQueueChanged.emit(true); + } + fun setQueuePosition(video: IPlatformVideo) { + synchronized(_queue) { + if (getCurrentQueueItem() == video) { + return; + } + + _queuePosition = getQueuePosition(video); + onVideoChanging.emit(video); + } + } + fun getQueuePosition(video: IPlatformVideo): Int { + synchronized(_queue) { + val queueShuffled = _queueShuffled; + return if (queueRepeat && queueShuffled != null) + queueShuffled.indexOf(video); + else + _queue.indexOf(video); + } + } + fun removeFromQueue(video: IPlatformVideo, shouldSwapCurrentItem: Boolean = false) { + synchronized(_queue) { + _queue.remove(video); + if (queueShuffle) { + removeFromShuffledQueue(video); + } + } + + onQueueChanged.emit(shouldSwapCurrentItem); + } + fun clearQueue() { + synchronized(_queue) { + _queue.clear(); + _queueShuffled = null; + queueShuffle = false; + _queuePosition = -1; + } + onQueueChanged.emit(false); + } + + //Queue Nav + fun getCurrentQueueItem(adjustIfNegative: Boolean = true) : IPlatformVideo? { + synchronized(_queue) { + val shuffledQueue = _queueShuffled; + val queue = if (queueShuffle && shuffledQueue != null) { + shuffledQueue; + } else { + _queue; + } + + if(adjustIfNegative && queue.isNotEmpty()) { + if(_queuePosition == -1) + return queue[0]; + else if(_queuePosition < queue.size) + return queue[_queuePosition]; + } else if(_queuePosition >= 0 && _queuePosition < queue.size) { + return queue[_queuePosition]; + } + } + return null; + } + + fun getNextQueueItem() : IPlatformVideo? { + synchronized(_queue) { + val shuffledQueue = _queueShuffled; + val queue = if (queueShuffle && shuffledQueue != null) { + shuffledQueue; + } else { + _queue; + } + + //Init Behavior + if(_queuePosition == -1 && queue.isNotEmpty()) + return queue[0]; + //Standard Behavior + if(_queuePosition + 1 < queue.size) + return queue[_queuePosition + 1]; + //Repeat Behavior (End of queue) + if(_queuePosition + 1 == queue.size && queue.isNotEmpty() && queueRepeat) + return queue[0]; + } + return null; + } + fun restartQueue() : IPlatformVideo? { + synchronized(_queue) { + _queuePosition = -1; + return nextQueueItem(); + } + }; + fun nextQueueItem(withoutRemoval: Boolean = false) : IPlatformVideo? { + synchronized(_queue) { + if (_queue.isEmpty()) + return null; + + val nextPosition: Int; + var isRepeat = false; + val lastItem = getCurrentQueueItem(false); + if(_queueRemoveOnFinish && !withoutRemoval && lastItem != null) { + _queue.remove(lastItem); + removeFromShuffledQueue(lastItem); + nextPosition = _queuePosition; + } else { + if (_queuePosition + 1 >= _queue.size) { + isRepeat = true; + nextPosition = 0; + } else { + nextPosition = _queuePosition + 1; + } + } + + if (_queue.isEmpty()) { + return null; + } + + if (isRepeat && !queueRepeat || isRepeat && _queue.size == 1) { + return null; + } + + _queuePosition = nextPosition + return getCurrentQueueItem(); + } + } + + fun prevQueueItem(withoutRemoval: Boolean = false) : IPlatformVideo? { + synchronized(_queue) { + if (_queue.size == 0) { + return null; + } + + val currentPos = _queuePosition; + + if(_queueRemoveOnFinish && !withoutRemoval) { + _queue.removeAt(currentPos); + _queuePosition = (_queuePosition - 1); + } + else + _queuePosition = (_queuePosition - 1); + if(_queuePosition < 0) + _queuePosition += _queue.size; + if(_queuePosition < _queue.size) + return _queue[_queuePosition]; + } + return null; + } + + fun setQueueItem(video: IPlatformVideo) : IPlatformVideo? { + synchronized(_queue) { + val index = _queue.indexOf(video); + if(index >= 0) { + _queuePosition = index; + return video; + } + else { + _queue.add(_queuePosition, video); + return video; + } + } + } + + //Player Initialization + fun getPlayerOrCreate(context : Context) : PlayerManager { + if(_exoplayer == null) { + val player = createExoPlayer(context); + _exoplayer = PlayerManager(player); + } + return _exoplayer!!; + } + fun getThumbnailPlayerOrCreate(context : Context) : PlayerManager { + if(_thumbnailExoPlayer == null) { + val player = createExoPlayer(context); + _thumbnailExoPlayer = PlayerManager(player); + } + return _thumbnailExoPlayer!!; + } + private fun createExoPlayer(context : Context) : ExoPlayer { + return ExoPlayer.Builder(context) + .setLoadControl( + DefaultLoadControl.Builder() + .setAllocator(DefaultAllocator(true, BUFFER_SIZE)) + .setBufferDurationsMs( + MIN_BUFFER_DURATION, + MAX_BUFFER_DURATION, + MIN_PLAYBACK_START_BUFFER, + MIN_PLAYBACK_RESUME_BUFFER + ) + .setTargetBufferBytes(-1) + .setPrioritizeTimeOverSizeThresholds(true) + .build()) + .setVideoScalingMode(C.VIDEO_SCALING_MODE_SCALE_TO_FIT) + .build(); + } + + + fun dispose(){ + val player = _exoplayer; + val thumbPlayer = _thumbnailExoPlayer; + _exoplayer = null; + _thumbnailExoPlayer = null; + player?.release(); + thumbPlayer?.release(); + } + + + companion object { + val TAG = "PlayerState"; + val TYPE_QUEUE = "Queue"; + val TYPE_PLAYLIST = "Playlist"; + val TYPE_WATCHLATER = "Watch Later"; + + private var _instance : StatePlayer? = null; + val instance : StatePlayer + get(){ + if(_instance == null) + _instance = StatePlayer(); + return _instance!!; + }; + + fun dispose(){ + val instance = _instance; + _instance = null; + instance?.dispose(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt new file mode 100644 index 00000000..b5f6258c --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt @@ -0,0 +1,264 @@ +package com.futo.platformplayer.states + +import android.content.Context +import android.net.Uri +import androidx.core.content.FileProvider +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException +import com.futo.platformplayer.exceptions.ReconstructionException +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.HistoryVideo +import com.futo.platformplayer.models.Playlist +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.v2.ManagedStore +import com.futo.platformplayer.stores.v2.ReconstructStore +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.File +import java.time.OffsetDateTime +import java.time.temporal.ChronoUnit + +/*** + * Used to maintain playlists + */ +class StatePlaylists { + private val _watchlistStore = FragmentedStorage.storeJson("watch_later") + .withUnique { it.url } + .withRestore(object: ReconstructStore() { + override fun toReconstruction(obj: SerializedPlatformVideo): String = obj.url; + override suspend fun toObject(id: String, backup: String, builder: Builder): SerializedPlatformVideo + = SerializedPlatformVideo.fromVideo(StatePlatform.instance.getContentDetails(backup).await() as IPlatformVideoDetails); + }) + .load(); + private val _historyStore = FragmentedStorage.storeJson("history") + .load(); + val playlistStore = FragmentedStorage.storeJson("playlists") + .withRestore(PlaylistBackup()) + .load(); + + val playlistShareDir = FragmentedStorage.getOrCreateDirectory("shares"); + + var onHistoricVideoChanged = Event2(); + val onWatchLaterChanged = Event0(); + + fun toMigrateCheck(): List> { + return listOf(playlistStore, _watchlistStore); + } + + fun getWatchLater() : List { + synchronized(_watchlistStore) { + return _watchlistStore.getItems(); + } + } + fun updateWatchLater(updated: List) { + synchronized(_watchlistStore) { + _watchlistStore.deleteAll(); + _watchlistStore.saveAllAsync(updated); + } + onWatchLaterChanged.emit(); + } + fun removeFromWatchLater(video: SerializedPlatformVideo) { + synchronized(_watchlistStore) { + _watchlistStore.delete(video); + } + + onWatchLaterChanged.emit(); + } + fun addToWatchLater(video: SerializedPlatformVideo) { + synchronized(_watchlistStore) { + _watchlistStore.saveAsync(video); + } + onWatchLaterChanged.emit(); + } + + fun getLastPlayedPlaylist() : Playlist? { + return playlistStore.queryItem { it.maxByOrNull { x -> x.datePlayed } }; + } + fun getLastUpdatedPlaylist() : Playlist? { + return playlistStore.queryItem { it.maxByOrNull { x -> x.dateUpdate } }; + } + + fun getPlaylists() : List { + return playlistStore.getItems(); + } + fun getPlaylist(id: String): Playlist? { + return playlistStore.findItem { it.id == id }; + } + + fun didPlay(playlistId: String) { + val playlist = getPlaylist(playlistId); + if(playlist != null) { + playlist.datePlayed = OffsetDateTime.now(); + playlistStore.saveAsync(playlist); + } + } + + fun getHistoryPosition(url: String): Long { + val histVideo = _historyStore.findItem { it.video.url == url }; + if(histVideo != null) + return histVideo.position; + return 0; + } + fun updateHistoryPosition(video: IPlatformVideo, updateExisting: Boolean, position: Long = -1L): Long { + val pos = if(position < 0) 0 else position; + val historyVideo = _historyStore.findItem { it.video.url == video.url }; + if (historyVideo != null) { + val positionBefore = historyVideo.position; + if (updateExisting) { + var shouldUpdate = false; + if (positionBefore < 30) { + shouldUpdate = true; + } else { + if (position > 30) { + shouldUpdate = true; + } + } + + if (shouldUpdate) { + historyVideo.position = pos; + historyVideo.date = OffsetDateTime.now(); + _historyStore.saveAsync(historyVideo); + onHistoricVideoChanged.emit(video, pos); + } + } + + return positionBefore; + } else { + val newHistItem = HistoryVideo(SerializedPlatformVideo.fromVideo(video), pos, OffsetDateTime.now()); + _historyStore.saveAsync(newHistItem); + return 0; + } + } + + fun getHistory() : List { + return _historyStore.getItems().sortedByDescending { it.date }; + } + + fun removeHistory(url: String) { + val hist = _historyStore.findItem { it.video.url == url }; + if(hist != null) + _historyStore.delete(hist); + } + + fun removeHistoryRange(minutesToDelete: Long) { + val now = OffsetDateTime.now(); + val toDelete = _historyStore.findItems { minutesToDelete == -1L || ChronoUnit.MINUTES.between(it.date, now) < minutesToDelete }; + + for(item in toDelete) + _historyStore.delete(item); + } + + suspend fun createPlaylistFromChannel(channelUrl: String, onPage: (Int) -> Unit): Playlist { + val channel = StatePlatform.instance.getChannel(channelUrl).await(); + return createPlaylistFromChannel(channel, onPage); + } + fun createPlaylistFromChannel(channel: IPlatformChannel, onPage: (Int) -> Unit): Playlist { + val contents = StatePlatform.instance.getChannelContent(channel.url); + val allContents = mutableListOf(); + allContents.addAll(contents.getResults()); + var page = 1; + while(contents.hasMorePages()) { + Logger.i("StatePlaylists", "Fetching channel video page ${page} from ${channel.url}"); + onPage(page); + contents.nextPage(); + allContents.addAll(contents.getResults()); + page++; + } + val allVideos = allContents.filter { it is IPlatformVideo }.map { it as IPlatformVideo }; + val newPlaylist = Playlist(channel.name, allVideos.map { SerializedPlatformVideo.fromVideo(it) }); + createOrUpdatePlaylist(newPlaylist); + return newPlaylist; + } + fun createOrUpdatePlaylist(playlist: Playlist) { + playlist.dateUpdate = OffsetDateTime.now(); + playlistStore.saveAsync(playlist, true); + } + fun addToPlaylist(id: String, video: IPlatformVideo) { + synchronized(playlistStore) { + val playlist = getPlaylist(id) ?: return; + playlist.videos.add(SerializedPlatformVideo.fromVideo(video)); + playlist.dateUpdate = OffsetDateTime.now(); + playlistStore.saveAsync(playlist, true); + } + } + + fun removePlaylist(playlist: Playlist) { + playlistStore.delete(playlist); + } + + fun createPlaylistShareUri(context: Context, playlist: Playlist): Uri { + val reconstruction = playlistStore.getReconstructionString(playlist, true); + + val newFile = File(playlistShareDir, playlist.name + ".recp"); + newFile.writeText(reconstruction, Charsets.UTF_8); + + return FileProvider.getUriForFile(context, context.resources.getString(R.string.authority), newFile); + } + fun createPlaylistShareJsonUri(context: Context, playlist: Playlist): Uri { + val reconstruction = playlistStore.getReconstructionString(playlist, true); + + val newFile = File(playlistShareDir, playlist.name + ".json"); + newFile.writeText(Json.encodeToString(reconstruction.split("\n")), Charsets.UTF_8); + + return FileProvider.getUriForFile(context, context.resources.getString(R.string.authority), newFile); + } + + companion object { + val TAG = "StatePlaylists"; + private var _instance : StatePlaylists? = null; + val instance : StatePlaylists + get(){ + if(_instance == null) + _instance = StatePlaylists(); + return _instance!!; + }; + + fun finish() { + _instance?.let { + _instance = null; + } + } + } + + + + class PlaylistBackup: ReconstructStore() { + override fun toReconstruction(obj: Playlist): String { + val items = ArrayList(); + items.add(obj.name); + items.addAll(obj.videos.map { it.url }); + return items.map { it.replace("\n","") }.joinToString("\n"); + } + override suspend fun toObject(id: String, backup: String, builder: Builder): Playlist { + val items = backup.split("\n"); + if(items.size <= 0) + throw IllegalStateException("Cannot reconstructor playlist ${id}"); + + val name = items[0]; + val videos = items.drop(1).filter { it.isNotEmpty() }.map { + try { + val video = StatePlatform.instance.getContentDetails(it).await(); + if (video is IPlatformVideoDetails) + return@map SerializedPlatformVideo.fromVideo(video); + else return@map null; + } + catch(ex: ScriptUnavailableException) { + Logger.w(TAG, "${name}:[${it}] is no longer available"); + builder.messages.add("${name}:[${it}] is no longer available"); + return@map null; + } + catch(ex: Throwable) { + throw ReconstructionException(name, "${name}:[${it}] ${ex.message}", ex); + } + }.filter { it != null }.map { it!! } + return Playlist(id, name, videos); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt new file mode 100644 index 00000000..e804a31a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt @@ -0,0 +1,426 @@ +package com.futo.platformplayer.states + +import android.content.Context +import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.api.media.platforms.js.SourceAuth +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.ImageVariable +import com.futo.platformplayer.stores.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json + +/*** + * Used to maintain plugin settings and configs + */ +class StatePlugins { + private val TAG = "StatePlugins"; + + private val FORCE_REINSTALL_EMBEDDED = false; + + private val _pluginScripts = FragmentedStorage.getDirectory(); + private var _plugins = FragmentedStorage.storeJson("plugins") + .load(); + private val iconsDir = FragmentedStorage.getDirectory(); + + fun getPluginIconOrNull(id: String): ImageVariable? { + if(iconsDir.hasIcon(id)) + return iconsDir.getIconBinary(id); + return null; + } + + fun reloadPluginFile(){ + _plugins = FragmentedStorage.storeJson("plugins") + .load(); + } + + private fun getResourceIdFromString(resourceName: String, c: Class<*> = R.drawable::class.java): Int? { + return try { + val idField = c.getDeclaredField(resourceName) + idField.getInt(idField) + } catch (exception: Exception) { + null + } + } + + @Serializable + private data class PluginConfig( + val SOURCES_EMBEDDED: Map, + val SOURCES_EMBEDDED_DEFAULT: List, + val SOURCES_UNDER_CONSTRUCTION: Map + ) + + private val _syncObject = Object() + private var _embeddedSources: Map? = null + private var _embeddedSourcesDefault: List? = null + private var _sourcesUnderConstruction: Map? = null + + private fun ensureSourcesConfigLoaded(context: Context) { + if (_embeddedSources != null && _embeddedSourcesDefault != null && _sourcesUnderConstruction != null) { + return + } + + val inputStream = context.resources.openRawResource(R.raw.plugin_config) + val jsonString = inputStream.bufferedReader().use { it.readText() } + val config = Json.decodeFromString(jsonString) + + _embeddedSources = config.SOURCES_EMBEDDED + _embeddedSourcesDefault = config.SOURCES_EMBEDDED_DEFAULT + _sourcesUnderConstruction = config.SOURCES_UNDER_CONSTRUCTION.mapNotNull { + val imageVariable = getResourceIdFromString(it.value)?.let { ImageVariable.fromResource(it) } ?: return@mapNotNull null + Pair(it.key, imageVariable) + }.toMap() + + Logger.i(TAG, "ensureSourcesConfigLoaded _embeddedSources:\n${_embeddedSources!!.map { " { ${it.key}: ${it.value} }" }.joinToString("\n")}") + Logger.i(TAG, "ensureSourcesConfigLoaded _embeddedSourcesDefault:\n${_embeddedSourcesDefault!!.map { " ${it}" }.joinToString("\n")}") + Logger.i(TAG, "ensureSourcesConfigLoaded _sourcesUnderConstruction:\n${_sourcesUnderConstruction!!.map { " { ${it.key}: ${it.value} }" }.joinToString("\n")}") + } + + fun getEmbeddedSources(context: Context): Map { + synchronized(_syncObject) { + ensureSourcesConfigLoaded(context) + return _embeddedSources!! + } + } + fun getEmbeddedSourcesDefault(context: Context): List { + synchronized(_syncObject) { + ensureSourcesConfigLoaded(context) + return _embeddedSourcesDefault!! + } + } + fun getSourcesUnderConstruction(context: Context): Map { + synchronized(_syncObject) { + ensureSourcesConfigLoaded(context) + return _sourcesUnderConstruction!! + } + } + + + suspend fun reinstallEmbeddedPlugins(context: Context) { + for(embedded in getEmbeddedSources(context)) + instance.deletePlugin(embedded.key); + StatePlatform.instance.updateAvailableClients(context); + } + fun updateEmbeddedPlugins(context: Context) { + for(embedded in getEmbeddedSources(context)) { + val embeddedConfig = getEmbeddedPluginConfig(context, embedded.value); + if(FORCE_REINSTALL_EMBEDDED) + deletePlugin(embedded.key); + else if(embeddedConfig != null) { + val existing = getPlugin(embedded.key); + if(existing != null && existing.config.version < embeddedConfig.version ) { + Logger.i(TAG, "Found outdated embedded plugin [${existing.config.id}] ${existing.config.name}, deleting and reinstalling"); + deletePlugin(embedded.key); + } + } + } + } + fun installMissingEmbeddedPlugins(context: Context) { + val plugins = getPlugins(); + for(embedded in getEmbeddedSources(context)) { + if(!plugins.any { it.config.id == embedded.key }) { + Logger.i(TAG, "Installing missing embedded plugin [${embedded.key}] ${embedded.value}, deleting and reinstalling"); + val success = instance.installEmbeddedPlugin(context, embedded.value, embedded.key); + if(!success) + Logger.i(TAG, "Failed to install embedded plugin [${embedded.key}]: ${embedded.value}"); + } + } + } + fun getEmbeddedPluginConfig(context: Context, assetConfigPath: String): SourcePluginConfig? { + val configJson = StateAssets.readAsset(context, assetConfigPath, false) ?: null; + if(configJson == null) + return null; + return SourcePluginConfig.fromJson(configJson, ""); + } + fun installEmbeddedPlugin(context: Context, assetConfigPath: String, id: String? = null): Boolean { + try { + val configJson = StateAssets.readAsset(context, assetConfigPath, false) ?: + throw IllegalStateException("Plugin config asset [${assetConfigPath}] not found"); + val config = SourcePluginConfig.fromJson(configJson, ""); + if(id != null && config.id != id) + throw IllegalStateException("Attempted to install embedded plugin with different id [${config.id}]"); + + val script = StateAssets.readAssetRelative(context, assetConfigPath, config.scriptUrl, false); + if(script.isNullOrEmpty()) + throw IllegalStateException("Plugin script asset [${config.scriptUrl}] could not be found in assets"); + + val icon = if(!config.iconUrl.isNullOrEmpty()) + StateAssets.readAssetBinRelative(context, assetConfigPath, config.iconUrl); + else null; + + createPlugin(config, script, icon, true); + return true; + } + catch(ex: Throwable) { + Logger.e(TAG, "Exception installing embedded plugin", ex); + return false; + } + } + fun installPlugins(context: Context, scope: CoroutineScope, sourceUrls: List, handler: ((Boolean) -> Unit)? = null) { + if(sourceUrls.isEmpty()) { + handler?.invoke(true); + return; + } + installPlugin(context, scope, sourceUrls[0]) { + installPlugins(context, scope, sourceUrls.drop(1), handler); + } + } + fun installPlugin(context: Context, scope: CoroutineScope, sourceUrl: String, handler: ((Boolean) -> Unit)? = null) { + scope.launch(Dispatchers.IO) { + try { + val configResp = ManagedHttpClient().get(sourceUrl); + if(!configResp.isOk) + throw IllegalStateException("Failed request with ${configResp.code}"); + val configJson = configResp.body?.string(); + if(configJson.isNullOrEmpty()) + throw IllegalStateException("No response"); + val config = SourcePluginConfig.fromJson(configJson, sourceUrl); + + withContext(Dispatchers.Main) { + installPlugin(context, scope, config, handler); + } + } + catch(ex: SerializationException) { + Logger.e(TAG, "Failed decode config", ex); + withContext(Dispatchers.Main) { + UIDialogs.showDialog(context, R.drawable.ic_error, + "Invalid Config Format", null, null, + 0, UIDialogs.Action("Ok", { + finish(); + handler?.invoke(false); + }, UIDialogs.ActionStyle.PRIMARY)); + + }; + } + catch(ex: Exception) { + Logger.e(TAG, "Failed fetch config", ex); + withContext(Dispatchers.Main) { + UIDialogs.showGeneralErrorDialog(context, "Failed to install plugin\n(${sourceUrl})", ex, "Ok", { + handler?.invoke(false); + }); + }; + } + } + } + fun installPlugin(context: Context, scope: CoroutineScope, config: SourcePluginConfig, handler: ((Boolean)->Unit)? = null) { + val client = ManagedHttpClient(); + val warnings = config.getWarnings(); + + + fun doInstall(reinstall: Boolean) { + UIDialogs.showDialogProgress(context) { + it.setText("Downloading script..."); + it.setProgress(0f); + + scope.launch(Dispatchers.IO) { + try { + val scriptResp = client.get(config.absoluteScriptUrl); + if (!scriptResp.isOk) + throw IllegalStateException("script not available [${scriptResp.code}]"); + val script = scriptResp.body?.string(); + if (script.isNullOrEmpty()) + throw IllegalStateException("script empty"); + + withContext(Dispatchers.Main) { + it.setText("Validating script..."); + it.setProgress(0.25); + } + + val tempDescriptor = SourcePluginDescriptor(config); + val plugin = JSClient(context, tempDescriptor, null, script); + plugin.validate(); + + withContext(Dispatchers.Main) { + it.setText("Downloading Icon..."); + it.setProgress(0.5); + } + + val icon = config.absoluteIconUrl?.let { absIconUrl -> + withContext(Dispatchers.Main) { + it.setText("Saving plugin..."); + it.setProgress(0.75); + } + val iconResp = client.get(absIconUrl); + if(iconResp.isOk) + return@let iconResp.body?.byteStream()?.use { it.readBytes() }; + return@let null; + } + val installEx = StatePlugins.instance.createPlugin(config, script, icon, reinstall); + if(installEx != null) + throw installEx; + StatePlatform.instance.updateAvailableClients(context); + + withContext(Dispatchers.Main) { + it.setText("Plugin created!"); + it.setProgress(1.0); + it.dismiss(); + + UIDialogs.toast(context, "Plugin ${config.name} installed"); + handler?.invoke(true); + } + } catch (ex: Exception) { + Logger.e(TAG, ex.message ?: "null", ex); + withContext(Dispatchers.Main) { + it.dismiss(); + UIDialogs.showDialogOk( + context, + R.drawable.ic_error, + "Failed to install due to:\n${ex.message}" + ) { + handler?.invoke(false); + } + } + } + }; + }; + } + fun verifyCanInstall() { + val installed = StatePlatform.instance.getClientOrNull(config.id); + if(installed != null) + UIDialogs.showDialog(context, R.drawable.ic_security_pred, + "A plugin with this id already exists named:\n" + + "${installed.name}\n[${config.id}]\n\n" + + "Would you like to reinstall it?", null, null, + 1, + UIDialogs.Action("Reinstall", { doInstall(true) }, UIDialogs.ActionStyle.DANGEROUS_TEXT), + UIDialogs.Action("Cancel", { handler?.invoke(false); }, UIDialogs.ActionStyle.DANGEROUS) + ); + else + doInstall(false); + } + + if(!warnings.isEmpty()) { + UIDialogs.showDialog(context, R.drawable.ic_security_pred, + "You are trying to install a plugin (${config.name}) with security vunerabilities.\n" + + "Are you sure you want to install it", null, + warnings.map { "${it.first}:\n${it.second}\n" }.joinToString("\n"), + 1, + UIDialogs.Action("Install Anyway", { verifyCanInstall() }, UIDialogs.ActionStyle.DANGEROUS_TEXT), + UIDialogs.Action("Cancel", { }, UIDialogs.ActionStyle.DANGEROUS)); + } + else verifyCanInstall(); + } + + fun getPlugin(id: String): SourcePluginDescriptor? { + if(id == StateDeveloper.DEV_ID) + throw IllegalStateException("Attempted to retrieve a persistent developer plugin, this is not allowed"); + + synchronized(_plugins) { + return _plugins.findItem { it.config.id == id }; + } + } + fun getPlugins(): List { + return _plugins.getItems(); + } + fun hasPlugin(id: String): Boolean = _plugins.findItem { it.config.id == id } != null; + + fun deletePlugin(id: String) { + synchronized(_pluginScripts) { + synchronized(_plugins) { + _pluginScripts.deleteFile(id); + val plugins = _plugins.findItems { it.config.id == id }; + for(plugin in plugins) + _plugins.delete(plugin); + } + } + } + fun createPlugin(config: SourcePluginConfig, script: String, icon: ByteArray? = null, reinstall: Boolean = false, flags: List = listOf()) : Throwable? { + try { + if(config.id == StateDeveloper.DEV_ID) + throw IllegalStateException("Attempted to make developer plugin persistent, this is not allowed"); + + if(!config.scriptSignature.isNullOrBlank()) { + val isValid = config.validate(script); + if(!isValid) + throw SecurityException("Script signature is invalid. Possible tampering"); + } + + val existing = getPlugin(config.id) + if (existing != null) { + if(!reinstall) + throw IllegalStateException("Plugin with id ${config.id} already exists"); + else deletePlugin(config.id); + } + _pluginScripts.setScript(config.id, script); + + if(_pluginScripts.getScript(config.id).isNullOrEmpty()) + throw IllegalStateException("Plugin script corrupted?"); + + if(icon != null) + iconsDir.saveIconBinary(config.id, icon); + + _plugins.save(SourcePluginDescriptor(config, null, flags)); + return null; + } + catch(ex: Throwable) { + deletePlugin(config.id); + return ex; + } + } + + fun getScript(pluginId: String) : String? { + return _pluginScripts.getScript(pluginId); + } + + fun setPluginSettings(id: String, map: Map) { + val newSettings = HashMap(map); + val plugin = getPlugin(id); + + if(plugin != null) { + for(setting in plugin.config.settings) { + if(!newSettings.containsKey(setting.variableOrName) || newSettings[setting.variableOrName] == null) + newSettings[setting.variableOrName] = setting.default; + } + + plugin.settings = newSettings; + _plugins.save(plugin, false, true); + } + } + fun savePlugin(id: String) { + val plugin = getPlugin(id); + + if(plugin != null) { + _plugins.save(plugin, false, true); + } + } + + fun setPluginAuth(id: String, auth: SourceAuth?) { + if(id == StateDeveloper.DEV_ID) { + StatePlatform.instance.getDevClient()?.let { + it.setAuth(auth); + }; + return; + } + + val descriptor = getPlugin(id) ?: throw IllegalArgumentException("Plugin [${id}] does not exist"); + descriptor.updateAuth(auth); + _plugins.save(descriptor); + } + + + companion object { + private var _instance : StatePlugins? = null; + val instance : StatePlugins + get(){ + if(_instance == null) + _instance = StatePlugins(); + return _instance!!; + }; + + fun finish() { + _instance?.let { + _instance = null; + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt b/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt new file mode 100644 index 00000000..dd6b70fa --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt @@ -0,0 +1,328 @@ +package com.futo.platformplayer.states + +import android.content.Context +import android.content.Intent +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.activities.PolycentricHomeActivity +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.PlatformContentPlaceholder +import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes +import com.futo.platformplayer.api.media.structures.DedupContentPager +import com.futo.platformplayer.api.media.structures.EmptyPager +import com.futo.platformplayer.api.media.structures.IAsyncPager +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.api.media.structures.MultiChronoContentPager +import com.futo.platformplayer.api.media.structures.PlaceholderPager +import com.futo.platformplayer.api.media.structures.RefreshDedupContentPager +import com.futo.platformplayer.api.media.structures.RefreshDistributionContentPager +import com.futo.platformplayer.awaitFirstDeferred +import com.futo.platformplayer.dp +import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.polycentric.PolycentricCache +import com.futo.platformplayer.resolveChannelUrl +import com.futo.platformplayer.selectBestImage +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringStorage +import com.futo.polycentric.core.* +import com.google.protobuf.ByteString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import userpackage.Protocol +import java.time.Instant +import java.time.OffsetDateTime +import java.time.ZoneOffset + +class StatePolycentric { + private data class LikeDislikeEntry(val unixMilliseconds: Long, val hasLiked: Boolean, val hasDisliked: Boolean); + + var processHandle: ProcessHandle? = null; private set; + private var _likeDislikeMap = hashMapOf() + private val _activeProcessHandle = FragmentedStorage.get("activeProcessHandle"); + + fun load(context: Context) { + val db = SqlLiteDbHelper(context); + Store.initializeSqlLiteStore(db); + + val activeProcessHandleString = _activeProcessHandle.value; + if (activeProcessHandleString.isNotEmpty()) { + val system = PublicKey.fromProto(Protocol.PublicKey.parseFrom(activeProcessHandleString.base64ToByteArray())); + setProcessHandle(Store.instance.getProcessSecret(system)?.toProcessHandle()); + } + } + + fun getProcessHandles(): List { + return Store.instance.getProcessSecrets().map { it.toProcessHandle(); }; + } + + fun setProcessHandle(processHandle: ProcessHandle?) { + this.processHandle = processHandle; + + if (processHandle != null) { + _activeProcessHandle.setAndSave(processHandle.system.toProto().toByteArray().toBase64()); + + val newMap = hashMapOf() + Store.instance.enumerateSignedEvents(processHandle.system, ContentType.OPINION) { + try { + for (ref in it.event.references) { + val refd = ref.toByteArray().toBase64(); + val e = newMap[refd]; + if (e == null || it.event.unixMilliseconds!! > e.unixMilliseconds) { + val data = it.event.lwwElement?.value ?: continue; + newMap[refd] = LikeDislikeEntry(it.event.unixMilliseconds!!, Opinion(data) == Opinion.like, Opinion(data) == Opinion.dislike); + } + } + } catch (e: Throwable) { + Logger.w(TAG, "Failed to get opinion, skipped.") + } + } + + _likeDislikeMap = newMap + } else { + _activeProcessHandle.setAndSave(""); + _likeDislikeMap = hashMapOf() + } + } + + fun updateLikeMap(ref: Protocol.Reference, hasLiked: Boolean, hasDisliked: Boolean) { + _likeDislikeMap[ref.toByteArray().toBase64()] = LikeDislikeEntry(System.currentTimeMillis(), hasLiked, hasDisliked); + } + + fun hasDisliked(ref: Protocol.Reference): Boolean { + val entry = _likeDislikeMap[ref.toByteArray().toBase64()] ?: return false; + return entry.hasDisliked; + } + + fun hasLiked(ref: Protocol.Reference): Boolean { + val entry = _likeDislikeMap[ref.toByteArray().toBase64()] ?: return false; + return entry.hasLiked; + } + + fun requireLogin(context: Context, text: String, action: (processHandle: ProcessHandle) -> Unit) { + val p = processHandle; + if (p == null) { + Logger.i(TAG, "requireLogin preventPictureInPicture.emit()"); + StateApp.instance.preventPictureInPicture.emit(); + UIDialogs.showDialog(context, R.drawable.ic_login, + text, null, null, + 1, + UIDialogs.Action("Cancel", { }, UIDialogs.ActionStyle.ACCENT), + UIDialogs.Action("OK", { + context.startActivity(Intent(context, PolycentricHomeActivity::class.java)); + }, UIDialogs.ActionStyle.PRIMARY) + ); + } else { + action(p); + } + } + + fun getChannelContent(profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1): IPager { + //TODO: Currently abusing subscription concurrency for parallelism + val concurrency = if (channelConcurrency == -1) Settings.instance.subscriptions.getSubscriptionsConcurrency() else channelConcurrency; + val pagers = profile.ownedClaims.groupBy { it.claim.claimType }.mapNotNull { + //TODO: Deduplicate once multiple urls in single claim is supported + return@mapNotNull it.value.firstOrNull(); + }.mapNotNull { + val url = it.claim.resolveChannelUrl() ?: return@mapNotNull null; + if (!StatePlatform.instance.hasEnabledChannelClient(url)) { + return@mapNotNull null; + } + + return@mapNotNull StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency); + }.toTypedArray(); + + val pager = MultiChronoContentPager(pagers); + pager.initialize(); + return DedupContentPager(pager, StatePlatform.instance.getEnabledClients().map { it.id }); + } + + fun getChannelContent(scope: CoroutineScope, profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1): IPager? { + //TODO: Currently abusing subscription concurrency for parallelism + val concurrency = if (channelConcurrency == -1) Settings.instance.subscriptions.getSubscriptionsConcurrency() else channelConcurrency; + val deferred = profile.ownedClaims.groupBy { it.claim.claimType } + .mapNotNull { + //TODO: Deduplicate once multiple urls in single claim is supported + return@mapNotNull it.value.firstOrNull(); + }.mapNotNull { + val url = it.claim.resolveChannelUrl() ?: return@mapNotNull null; + val client = StatePlatform.instance.getChannelClientOrNull(url) ?: return@mapNotNull null; + + return@mapNotNull Pair(client, scope.async(Dispatchers.IO) { + try { + return@async StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency); + } catch (ex: Throwable) { + Logger.e(TAG, "getChannelContent", ex); + return@async null; + } + }) + } + .groupBy { it.first.name } + .map { it.value.first() }; + val finishedPager: Pair?>, IPager?> = (if(deferred.isEmpty()) null else runBlocking { + deferred.map { it.second }.awaitFirstDeferred(); + }) ?: return null; + + val toAwait = deferred.filter { it.second != finishedPager.first }; + return RefreshDedupContentPager(RefreshDistributionContentPager( + listOf(finishedPager.second!!), + toAwait.map { it.second }, + toAwait.map { PlaceholderPager(5) { PlatformContentPlaceholder(it.first.id) } }), + StatePlatform.instance.getEnabledClients().map { it.id } + ); + } + suspend fun getChannelContent(profile: PolycentricProfile): IPager { + return withContext(Dispatchers.IO) { + getChannelContent(this, profile) ?: EmptyPager(); + } + } + + suspend fun getCommentPager(contextUrl: String, reference: Protocol.Reference): IPager { + val response = ApiMethods.getQueryReferences(PolycentricCache.SERVER, reference, null, + Protocol.QueryReferencesRequestEvents.newBuilder() + .setFromType(ContentType.POST.value) + .addAllCountLwwElementReferences(arrayListOf( + Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() + .setFromType(ContentType.OPINION.value) + .setValue(ByteString.copyFrom(Opinion.like.data)) + .build(), + Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() + .setFromType(ContentType.OPINION.value) + .setValue(ByteString.copyFrom(Opinion.dislike.data)) + .build() + )) + .addCountReferences( + Protocol.QueryReferencesRequestCountReferences.newBuilder() + .setFromType(ContentType.POST.value) + .build()) + .build() + ); + + val results = mapQueryReferences(contextUrl, response); + val nextCursor = if (response.hasCursor()) response.cursor.toByteArray() else null + return object : IAsyncPager, IPager { + private var _results: List = results + private var _cursor: ByteArray? = nextCursor + + override fun hasMorePages(): Boolean { + return _cursor != null; + } + + override fun nextPage() { + runBlocking { nextPageAsync() } + } + + override suspend fun nextPageAsync() { + val nextPageResponse = ApiMethods.getQueryReferences(PolycentricCache.SERVER, reference, _cursor, + Protocol.QueryReferencesRequestEvents.newBuilder() + .setFromType(ContentType.POST.value) + .addAllCountLwwElementReferences(arrayListOf( + Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() + .setFromType(ContentType.OPINION.value) + .setValue(ByteString.copyFrom(Opinion.like.data)) + .build(), + Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() + .setFromType(ContentType.OPINION.value) + .setValue(ByteString.copyFrom(Opinion.dislike.data)) + .build() + )) + .addCountReferences( + Protocol.QueryReferencesRequestCountReferences.newBuilder() + .setFromType(ContentType.POST.value) + .build()) + .build() + ); + + _cursor = if (nextPageResponse.hasCursor()) nextPageResponse.cursor.toByteArray() else null + _results = mapQueryReferences(contextUrl, nextPageResponse) + } + + override fun getResults(): List { + return _results; + } + }; + } + + private suspend fun mapQueryReferences(contextUrl: String, response: Protocol.QueryReferencesResponse): List { + return response.itemsList.mapNotNull { + val sev = SignedEvent.fromProto(it.event); + val ev = sev.event; + if (ev.contentType != ContentType.POST.value) { + return@mapNotNull null; + } + + try { + val post = Protocol.Post.parseFrom(ev.content); + val id = ev.system.toProto().key.toByteArray().toBase64(); + val likes = it.countsList[0]; + val dislikes = it.countsList[1]; + val replies = it.countsList[2]; + + val profileEvents = ApiMethods.getQueryLatest( + PolycentricCache.SERVER, + ev.system.toProto(), + listOf( + ContentType.AVATAR.value, + ContentType.USERNAME.value + ) + ).eventsList.map { e -> SignedEvent.fromProto(e) }; + + val nameEvent = profileEvents.firstOrNull { e -> e.event.contentType == ContentType.USERNAME.value }; + val avatarEvent = profileEvents.firstOrNull { e -> e.event.contentType == ContentType.AVATAR.value }; + val imageBundle = if (avatarEvent != null) { + val lwwElementValue = avatarEvent.event.lwwElement?.value; + if (lwwElementValue != null) { + Protocol.ImageBundle.parseFrom(lwwElementValue) + } else { + null + } + } else { + null + } + + val unixMilliseconds = ev.unixMilliseconds + //TODO: Don't use single hardcoded sderver here + val systemLinkUrl = ev.system.systemToURLInfoSystemLinkUrl(listOf(PolycentricCache.SERVER)); + val dp_25 = 25.dp(StateApp.instance.context.resources) + return@mapNotNull PolycentricPlatformComment( + contextUrl = contextUrl, + author = PlatformAuthorLink( + id = PlatformID("polycentric", systemLinkUrl, null, ClaimType.POLYCENTRIC.value.toInt()), + name = nameEvent?.event?.lwwElement?.value?.decodeToString() ?: "Unknown", + url = systemLinkUrl, + thumbnail = imageBundle?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(ev.system.toProto(), img.process, listOf(PolycentricCache.SERVER)) }, + subscribers = null + ), + msg = post.content, + rating = RatingLikeDislikes(likes, dislikes), + date = if (unixMilliseconds != null) Instant.ofEpochMilli(unixMilliseconds).atOffset(ZoneOffset.UTC) else OffsetDateTime.MIN, + replyCount = replies.toInt(), + reference = sev.toPointer().toReference() + ); + } catch (e: Throwable) { + return@mapNotNull null; + } + }; + } + + companion object { + private const val TAG = "StatePolycentric"; + + private var _instance: StatePolycentric? = null; + val instance: StatePolycentric + get(){ + if(_instance == null) + _instance = StatePolycentric(); + return _instance!!; + }; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSaved.kt b/app/src/main/java/com/futo/platformplayer/states/StateSaved.kt new file mode 100644 index 00000000..1bd4df34 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/states/StateSaved.kt @@ -0,0 +1,52 @@ +package com.futo.platformplayer.states + +import com.futo.platformplayer.api.media.Serializer +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringStorage +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString + +@kotlinx.serialization.Serializable +data class VideoToOpen(val url: String, val timeSeconds: Long); + +class StateSaved { + var videoToOpen: VideoToOpen? = null; + + private val _videoToOpen = FragmentedStorage.get("videoToOpen") + + fun load() { + val videoToOpenString = _videoToOpen.value; + if (videoToOpenString.isNotEmpty()) { + try { + val v = Serializer.json.decodeFromString(videoToOpenString); + videoToOpen = v; + } catch (e: Throwable) { + Logger.w(TAG, "Failed to load video to open", e) + } + } + + Logger.i(TAG, "loaded videoToOpen=$videoToOpen"); + } + + fun setVideoToOpenNonBlocking(v: VideoToOpen? = null) { + Logger.i(TAG, "set videoToOpen=$v"); + + videoToOpen = v; + _videoToOpen.setAndSave(if (v != null) Serializer.json.encodeToString(v) else ""); + } + + + fun setVideoToOpenBlocking(v: VideoToOpen? = null) { + Logger.i(TAG, "set videoToOpen=$v"); + + videoToOpen = v; + _videoToOpen.setAndSaveBlocking(if (v != null) Serializer.json.encodeToString(v) else ""); + } + + companion object { + const val TAG = "StateSaved" + + val instance: StateSaved = StateSaved() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt new file mode 100644 index 00000000..ba940c79 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt @@ -0,0 +1,375 @@ +package com.futo.platformplayer.states + +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.api.media.models.channels.SerializedChannel +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.structures.* +import com.futo.platformplayer.api.media.structures.ReusablePager.Companion.asReusable +import com.futo.platformplayer.cache.ChannelContentCache +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.engine.exceptions.PluginException +import com.futo.platformplayer.exceptions.ChannelException +import com.futo.platformplayer.findNonRuntimeException +import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.Subscription +import com.futo.platformplayer.polycentric.PolycentricCache +import com.futo.platformplayer.resolveChannelUrl +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.SubscriptionStorage +import com.futo.platformplayer.stores.v2.ReconstructStore +import com.futo.platformplayer.stores.v2.ManagedStore +import kotlinx.coroutines.* +import java.util.concurrent.ExecutionException +import java.util.concurrent.ForkJoinPool +import java.util.concurrent.ForkJoinTask +import kotlin.collections.ArrayList +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine +import kotlin.system.measureTimeMillis + +/*** + * Used to maintain subscriptions + */ +class StateSubscriptions { + private val _subscriptions = FragmentedStorage.storeJson("subscriptions") + .withUnique { it.channel.url } + .withRestore(object: ReconstructStore(){ + override fun toReconstruction(obj: Subscription): String = + obj.channel.url; + override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder): Subscription = + Subscription(SerializedChannel.fromChannel(StatePlatform.instance.getChannelLive(backup, false))); + }).load(); + private val _subscriptionsPool = ForkJoinPool(Settings.instance.subscriptions.getSubscriptionsConcurrency()); + private val _legacySubscriptions = FragmentedStorage.get(); + + val onSubscriptionsChanged = Event2, Boolean>(); + + private var _globalSubscriptionsLock = Object(); + private var _globalSubscriptionFeed: ReusablePager? = null; + var isGlobalUpdating: Boolean = false + private set; + var globalSubscriptionExceptions: List = listOf() + private set; + + private var _lastGlobalSubscriptionProgress: Int = 0; + private var _lastGlobalSubscriptionTotal: Int = 0; + val onGlobalSubscriptionsUpdateProgress = Event2(); + val onGlobalSubscriptionsUpdated = Event0(); + val onGlobalSubscriptionsUpdatedOnce = Event1(); + val onGlobalSubscriptionsException = Event1>(); + + fun getGlobalSubscriptionProgress(): Pair { + return Pair(_lastGlobalSubscriptionProgress, _lastGlobalSubscriptionTotal); + } + fun updateSubscriptionFeed(scope: CoroutineScope, onlyIfNull: Boolean = false, onProgress: ((Int, Int)->Unit)? = null) { + Logger.i(TAG, "updateSubscriptionFeed"); + scope.launch(Dispatchers.IO) { + synchronized(_globalSubscriptionsLock) { + if (isGlobalUpdating || (onlyIfNull && _globalSubscriptionFeed != null)) { + Logger.i(TAG, "Already updating subscriptions or not required") + return@launch; + } + isGlobalUpdating = true; + } + try { + val subsResult = getSubscriptionsFeedWithExceptions(true, true, scope, { progress, total -> + _lastGlobalSubscriptionProgress = progress; + _lastGlobalSubscriptionTotal = total; + onGlobalSubscriptionsUpdateProgress.emit(progress, total); + onProgress?.invoke(progress, total); + }); + if (subsResult.second.any()) { + globalSubscriptionExceptions = subsResult.second; + onGlobalSubscriptionsException.emit(subsResult.second); + } + _globalSubscriptionFeed = subsResult.first.asReusable(); + synchronized(_globalSubscriptionsLock) { + onGlobalSubscriptionsUpdated.emit(); + onGlobalSubscriptionsUpdatedOnce.emit(null); + onGlobalSubscriptionsUpdatedOnce.clear(); + } + } + catch (e: Throwable) { + synchronized(_globalSubscriptionsLock) { + onGlobalSubscriptionsUpdatedOnce.emit(e); + onGlobalSubscriptionsUpdatedOnce.clear(); + } + Logger.e(TAG, "Failed to update subscription feed.", e); + } + finally { + isGlobalUpdating = false; + } + }; + } + fun clearSubscriptionFeed() { + synchronized(_globalSubscriptionsLock) { + _globalSubscriptionFeed = null; + } + } + + private var loadIndex = 0; + suspend fun getGlobalSubscriptionFeed(scope: CoroutineScope, updated: Boolean): IPager { + //Get Subscriptions only if null + updateSubscriptionFeed(scope, !updated); + + val evRef = Object(); + val result = suspendCoroutine { + synchronized(_globalSubscriptionsLock) { + if (_globalSubscriptionFeed != null && !updated) { + Logger.i(TAG, "Subscriptions got feed preloaded"); + it.resumeWith(Result.success(_globalSubscriptionFeed!!.getWindow())); + } else { + val loadIndex = loadIndex++; + Logger.i(TAG, "[${loadIndex}] Starting await update"); + onGlobalSubscriptionsUpdatedOnce.subscribe(evRef) {ex -> + Logger.i(TAG, "[${loadIndex}] Subscriptions got feed after update"); + if(ex != null) + it.resumeWithException(ex); + else if (_globalSubscriptionFeed != null) + it.resumeWith(Result.success(_globalSubscriptionFeed!!.getWindow())); + else + it.resumeWithException(IllegalStateException("No subscription pager after change? Illegal null set on global subscriptions")) + } + } + } + }; + return result; + } + + suspend fun updateSubscriptions(doSave: Boolean = true) { + for (sub in _subscriptions.getItems()) { + Logger.i(TAG, "Updating channel ${sub.channel.name} with url ${sub.channel.url}"); + val updatedSub = StatePlatform.instance.getChannel(sub.channel.url, false).await(); + sub.updateChannel(updatedSub); + if(doSave) + _subscriptions.save(sub); + } + } + + fun getSubscription(url: String) : Subscription? { + synchronized(_subscriptions) { + return _subscriptions.findItem { it.channel.url == url || it.channel.urlAlternatives.contains(url) }; + } + } + fun saveSubscription(sub: Subscription) { + _subscriptions.save(sub, false, true); + } + fun getSubscriptionCount(): Int { + synchronized(_subscriptions) { + return _subscriptions.getItems().size; + } + } + fun getSubscriptions(): List { + return _subscriptions.getItems(); + } + + fun addSubscription(channel : IPlatformChannel) : Subscription { + val subObj = Subscription(SerializedChannel.fromChannel(channel)); + _subscriptions.save(subObj); + onSubscriptionsChanged.emit(getSubscriptions(), true); + return subObj; + } + fun removeSubscription(url: String) : Subscription? { + var sub : Subscription? = getSubscription(url); + if(sub != null) { + _subscriptions.delete(sub); + onSubscriptionsChanged.emit(getSubscriptions(), false); + } + return sub; + } + + fun isSubscribed(channel: IPlatformChannel): Boolean { + val urls = (listOf(channel.url) + channel.urlAlternatives).distinct(); + return isSubscribed(urls); + } + fun isSubscribed(url: String) : Boolean { + return isSubscribed(listOf(url)); + } + fun isSubscribed(urls: List) : Boolean { + if(urls.isEmpty()) + return false; + synchronized(_subscriptions) { + if (_subscriptions.hasItem { urls.contains(it.channel.url) }) { + return true; + } + + //TODO: This causes issues, because what if the profile is not cached yet when the susbcribe button is loaded for example? + val cachedProfile = PolycentricCache.instance.getCachedProfile(urls.first(), true)?.profile; + if (cachedProfile != null) { + return cachedProfile.ownedClaims.any { c -> _subscriptions.hasItem { s -> c.claim.resolveChannelUrl() == s.channel.url } }; + } + + return false; + } + } + fun updateSubscriptionChannel(channel: IPlatformChannel, doSave: Boolean = true) { + val sub = getSubscription(channel.url) ?: channel.urlAlternatives.firstNotNullOfOrNull { getSubscription(it) }; + if(sub != null) { + sub.updateChannel(channel); + if(doSave) + _subscriptions.save(sub); + } + } + + fun getSubscriptionsFeed(allowFailure: Boolean = false): MultiChronoContentPager { + val result = getSubscriptionsFeedWithExceptions(allowFailure, true); + if(result.second.any()) + throw result.second.first(); + return result.first; + } + fun getSubscriptionsFeedWithExceptions(allowFailure: Boolean = false, withCacheFallback: Boolean = false, cacheScope: CoroutineScope? = null, onProgress: ((Int, Int)->Unit)? = null, onNewCacheHit: ((Subscription, IPlatformContent)->Unit)? = null): Pair> { + val subsPager: Array>; + val exs: ArrayList = arrayListOf(); + + val tasks = mutableListOf?>>>(); + var finished = 0; + val exceptionMap: HashMap = hashMapOf(); + val concurrency = Settings.instance.subscriptions.getSubscriptionsConcurrency(); + for (sub in getSubscriptions().filter { StatePlatform.instance.hasEnabledChannelClient(it.channel.url) }) { + tasks.add(_subscriptionsPool.submit?>> { + var polycentricProfile : PolycentricCache.CachedPolycentricProfile? = null; + val getProfileTime = measureTimeMillis { + try { + polycentricProfile = PolycentricCache.instance.getCachedProfile(sub.channel.url); + if (polycentricProfile == null) { + Logger.i("StateSubscriptions", "Get polycentric profile not cached"); + polycentricProfile = runBlocking { PolycentricCache.instance.getProfileAsync(sub.channel.id) }; + } else { + Logger.i("StateSubscriptions", "Get polycentric profile cached"); + } + } + catch(ex: Throwable) { + Logger.w(TAG, "Polycentric getCachedProfile failed for subscriptions", ex); + //TODO: Some way to communicate polycentric failing without blocking here + //UIDialogs.toast("Polycentric failed\n" + ex.message, false); + //UIDialogs.showGeneralErrorDialog(it, "Polycentric getCachedProfile failed for subscriptions", ex); + } + } + + Logger.i("StateSubscriptions", "Get polycentric profile time ${getProfileTime}ms"); + + var pager: IPager; + try { + val time = measureTimeMillis { + val profile = polycentricProfile?.profile + pager = if (profile != null) + StatePolycentric.instance.getChannelContent(profile, true, concurrency) + else + StatePlatform.instance.getChannelContent(sub.channel.url, true, concurrency); + + if (cacheScope != null) + pager = ChannelContentCache.cachePagerResults(cacheScope, pager) { + onNewCacheHit?.invoke(sub, it); + }; + + finished++; + onProgress?.invoke(finished, tasks.size); + } + Logger.i( + "StateSubscriptions", + "Subscription [${sub.channel.name}] results in ${time}ms" + ); + } + catch(ex: Throwable) { + finished++; + onProgress?.invoke(finished, tasks.size); + val channelEx = ChannelException(sub.channel, ex); + synchronized(exceptionMap) { + exceptionMap.put(sub, channelEx); + } + if(!withCacheFallback) + throw channelEx; + else { + Logger.i(TAG, "Channel ${sub.channel.name} failed, substituting with cache"); + pager = ChannelContentCache.instance.getChannelCachePager(sub.channel.url); + } + } + return@submit Pair(sub, pager); + }); + } + val timeTotal = measureTimeMillis { + val taskResults = arrayListOf>(); + for(task in tasks) { + try { + val result = task.get(); + if(result != null) { + if(result.second != null) + taskResults.add(result.second!!); + if(exceptionMap.containsKey(result.first)) { + val ex = exceptionMap[result.first]; + if(ex != null) { + val nonRuntimeEx = findNonRuntimeException(ex); + if (nonRuntimeEx != null && (nonRuntimeEx is PluginException || nonRuntimeEx is ChannelException)) + exs.add(nonRuntimeEx); + else + throw ex.cause ?: ex; + } + } + } + } catch (ex: ExecutionException) { + val nonRuntimeEx = findNonRuntimeException(ex.cause); + if(nonRuntimeEx != null && (nonRuntimeEx is PluginException || nonRuntimeEx is ChannelException)) + exs.add(nonRuntimeEx); + else + throw ex.cause ?: ex; + }; + } + subsPager = taskResults.toTypedArray(); + } + Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms") + + if(subsPager.size <= 0 && exs.any()) + throw exs.first(); + + Logger.i(TAG, "Subscription pager with ${subsPager.size} channels"); + val pager = MultiChronoContentPager(subsPager, allowFailure); + pager.initialize(); + return Pair(pager, exs); + } + + //New Migration + fun toMigrateCheck(): List> { + return listOf(_subscriptions); + } + + //Old migrate + fun shouldMigrate(): Boolean { + return _legacySubscriptions.subscriptions.any(); + } + fun tryMigrateIfNecessary() { + Logger.i(TAG, "MIGRATING SUBS"); + val oldSubs = _legacySubscriptions.subscriptions.toList(); + + for(sub in oldSubs) { + if(!this.isSubscribed(sub.channel.url)) { + Logger.i(TAG, "MIGRATING ${sub.channel.url}"); + addSubscription(sub.channel); + } + } + _legacySubscriptions.delete(); + } + + companion object { + const val TAG = "StateSubscriptions"; + const val VERSION = 1; + + private var _instance : StateSubscriptions? = null; + val instance : StateSubscriptions + get(){ + if(_instance == null) + _instance = StateSubscriptions(); + return _instance!!; + }; + + fun finish() { + _instance?.let { + _instance = null; + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateTelemetry.kt b/app/src/main/java/com/futo/platformplayer/states/StateTelemetry.kt new file mode 100644 index 00000000..61e07910 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/states/StateTelemetry.kt @@ -0,0 +1,77 @@ +package com.futo.platformplayer.states + +import android.content.Context +import android.os.Build +import com.futo.platformplayer.BuildConfig +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.Telemetry +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringStorage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.time.Instant +import java.util.UUID + +class StateTelemetry { + private val _id = FragmentedStorage.get("id"); + + fun initialize() { + if (_id.value.isEmpty()) { + _id.setAndSave(UUID.randomUUID().toString()); + } + } + + fun upload() { + GlobalScope.launch(Dispatchers.IO) { + try { + val telemetry = Telemetry( + _id.value, + BuildConfig.APPLICATION_ID, + BuildConfig.VERSION_CODE.toString(), + BuildConfig.VERSION_NAME, + BuildConfig.BUILD_TYPE, + BuildConfig.DEBUG, + BuildConfig.IS_UNSTABLE_BUILD, + Instant.now().epochSecond, + Build.BRAND, + Build.MANUFACTURER, + Build.MODEL + ); + + val headers = hashMapOf( + "Content-Type" to "text/plain" + ); + + val json = Json.encodeToString(telemetry); + val url = "https://logs.grayjay.app/telemetry"; + //val url = "http://10.0.0.5:5413/telemetry"; + val client = ManagedHttpClient(); + val response = client.post(url, json, headers); + if (response.isOk) { + Logger.i(TAG, "Launch telemetry submitted."); + } else { + Logger.w(TAG, "Failed to submit launch telemetry (${response.code}): '${response.body?.string()}'."); + } + } catch (e: Throwable) { + Logger.w(TAG, "Failed to submit launch telemetry.", e); + } + } + } + + companion object { + private var _instance: StateTelemetry? = null; + val instance: StateTelemetry + get(){ + if(_instance == null) + _instance = StateTelemetry(); + return _instance!!; + }; + + private const val TAG = "StateTelemetry"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateUpdate.kt b/app/src/main/java/com/futo/platformplayer/states/StateUpdate.kt new file mode 100644 index 00000000..1a5e4c80 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/states/StateUpdate.kt @@ -0,0 +1,278 @@ +package com.futo.platformplayer.states + +import android.content.Context +import android.os.Build +import android.os.Environment +import com.futo.platformplayer.* +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.logging.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileNotFoundException +import java.io.InputStream +import java.io.OutputStream + +class StateUpdate { + private var _backgroundUpdateFinished = false; + private var _gettingOrDownloadingLastApk = false; + private var _shouldBackgroundUpdate = false; + private val _lockObject = Object(); + + private fun getOrDownloadLastApkFile(filesDir: File): File? { + try { + Logger.i(TAG, "Started getting or downloading latest APK file."); + + if (!_shouldBackgroundUpdate) { + Logger.i(TAG, "Update download cancelled 1."); + return null; + } + + Logger.i(TAG, "Started background update download."); + val client = ManagedHttpClient(); + val latestVersion = downloadVersionCode(client); + if (!_shouldBackgroundUpdate) { + Logger.i(TAG, "Update download cancelled 2."); + return null; + } + + if (latestVersion != null) { + val currentVersion = BuildConfig.VERSION_CODE; + Logger.i(TAG, "Current version ${currentVersion} latest version ${latestVersion}."); + + if (latestVersion <= currentVersion) { + Logger.i(TAG, "Already up to date."); + _backgroundUpdateFinished = true; + return null; + } + + val outputDirectory = File(filesDir, "autoupdate"); + if (!outputDirectory.exists()) { + outputDirectory.mkdirs(); + } + + if (!_shouldBackgroundUpdate) { + Logger.i(TAG, "Update download cancelled 3."); + return null; + } + + val apkOutputFile = File(outputDirectory, "last_version.apk"); + val versionOutputFile = File(outputDirectory, "last_version.txt"); + + var cachedVersionInvalid = false; + if (!versionOutputFile.exists() || !apkOutputFile.exists()) { + Logger.i(TAG, "No downloaded version exists."); + cachedVersionInvalid = true; + } else { + try { + val downloadedVersion = versionOutputFile.readText().toInt(); + Logger.i(TAG, "Downloaded version is $downloadedVersion."); + if (downloadedVersion != latestVersion) { + Logger.i(TAG, "Downloaded version is not newest version."); + cachedVersionInvalid = true; + } + } + catch(ex: Throwable) { + Logger.w(TAG, "Deleted version file as it was inaccessible"); + versionOutputFile.delete(); + cachedVersionInvalid = true; + } + } + + if (!_shouldBackgroundUpdate) { + Logger.i(TAG, "Update download cancelled 4."); + return null; + } + + if (cachedVersionInvalid) { + Logger.i(TAG, "Downloading new APK to '${apkOutputFile.path}'..."); + downloadApkToFile(client, apkOutputFile) { !_shouldBackgroundUpdate }; + versionOutputFile.writeText(latestVersion.toString()); + + Logger.i(TAG, "Downloaded APK to '${apkOutputFile.path}'."); + } else { + Logger.i(TAG, "Latest APK is already downloaded in '${apkOutputFile.path}'..."); + } + + if (!_shouldBackgroundUpdate) { + Logger.i(TAG, "Update download cancelled 5."); + return null; + } + + return apkOutputFile; + } else { + Logger.w(TAG, "Failed to retrieve version from version URL."); + return null; + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to download APK.", e); + return null; + } finally { + _gettingOrDownloadingLastApk = false; + } + } + + fun setShouldBackgroundUpdate(shouldBackgroundUpdate: Boolean) { + synchronized (_lockObject) { + if (_backgroundUpdateFinished) { + _shouldBackgroundUpdate = false; + return; + } + + _shouldBackgroundUpdate = shouldBackgroundUpdate; + if (shouldBackgroundUpdate && !_gettingOrDownloadingLastApk) { + Logger.i(TAG, "Auto Updating in Background"); + + _gettingOrDownloadingLastApk = true; + StateApp.withContext { context -> + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + try { + val file = getOrDownloadLastApkFile(context.filesDir); + if (file == null) { + Logger.i(TAG, "Failed to get or download update."); + return@launch; + } + + withContext(Dispatchers.Main) { + try { + context.let { c -> + _backgroundUpdateFinished = true; + UIDialogs.showInstallDownloadedUpdateDialog(c, file); + }; + Logger.i(TAG, "Showing install dialog for '${file.path}'."); + } catch (e: Throwable) { + context.let { c -> UIDialogs.toast(c, "Failed to show update dialog"); }; + Logger.w(TAG, "Error occurred in update dialog.", e); + } + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to get last downloaded APK file.", e) + } + } + } + } + } + } + + fun checkForUpdates(context: Context, showUpToDateToast: Boolean) { + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + try { + val client = ManagedHttpClient(); + val latestVersion = downloadVersionCode(client); + + if (latestVersion != null) { + val currentVersion = BuildConfig.VERSION_CODE; + Logger.i(TAG, "Current version ${currentVersion} latest version ${latestVersion}."); + + if (latestVersion > currentVersion) { + withContext(Dispatchers.Main) { + try { + UIDialogs.showUpdateAvailableDialog(context, latestVersion); + } catch (e: Throwable) { + UIDialogs.toast(context, "Failed to show update dialog"); + Logger.w(TAG, "Error occurred in update dialog."); + } + } + } else { + if (showUpToDateToast) { + withContext(Dispatchers.Main) { + UIDialogs.toast(context, "Already on latest version"); + } + } + } + } else { + Logger.w(TAG, "Failed to retrieve version from version URL."); + + withContext(Dispatchers.Main) { + UIDialogs.toast(context, "Failed to retrieve version"); + } + } + } catch (e: Throwable) { + Logger.w(TAG, "Failed to check for updates.", e); + + withContext(Dispatchers.Main) { + UIDialogs.toast(context, "Failed to check for updates"); + } + } + }; + } + + private fun downloadApkToFile(client: ManagedHttpClient, destinationFile: File, isCancelled: (() -> Boolean)? = null) { + var apkStream: InputStream? = null; + var outputStream: OutputStream? = null; + + try { + val response = client.get(APK_URL); + if (response.isOk && response.body != null) { + apkStream = response.body.byteStream(); + outputStream = destinationFile.outputStream(); + apkStream.copyToOutputStream(outputStream, isCancelled); + apkStream.close(); + outputStream.close(); + } + } finally { + apkStream?.close(); + outputStream?.close(); + } + } + + fun downloadVersionCode(client: ManagedHttpClient): Int? { + val response = client.get(VERSION_URL); + if (!response.isOk || response.body == null) { + return null; + } + + return response.body.string().trim().toInt(); + } + + fun downloadChangelog(client: ManagedHttpClient, version: Int): String? { + val response = client.get("${CHANGELOG_BASE_URL}/${version}"); + if (!response.isOk || response.body == null) { + return null; + } + + return response.body.string().trim(); + } + + companion object { + private val TAG = "StateUpdate"; + + private var _instance : StateUpdate? = null; + val instance : StateUpdate + get(){ + if(_instance == null) + _instance = StateUpdate(); + return _instance!!; + }; + + val APP_SUPPORTED_ABIS = arrayOf("x86", "x86_64", "arm64-v8a", "armeabi-v7a"); + val DESIRED_ABI: String get() { + for (i in 0 until Build.SUPPORTED_ABIS.size) { + val abi = Build.SUPPORTED_ABIS[i]; + if (APP_SUPPORTED_ABIS.contains(abi)) { + return abi; + } + } + + throw Exception("App is not compatible. Supported ABIS: ${Build.SUPPORTED_ABIS.joinToString()}}."); + }; + val VERSION_URL = if (BuildConfig.IS_UNSTABLE_BUILD) { + "https://releases.grayjay.app/version-unstable.txt" + } else { + "https://releases.grayjay.app/version.txt" + } + val APK_URL = if (BuildConfig.IS_UNSTABLE_BUILD) { + "https://releases.grayjay.app/app-$DESIRED_ABI-release-unstable.apk" + } else { + "https://releases.grayjay.app/app-$DESIRED_ABI-release.apk" + } + val CHANGELOG_BASE_URL = "https://releases.grayjay.app/changelogs"; + + fun finish() { + _instance?.let { + _instance = null; + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/CastingDeviceInfoStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/CastingDeviceInfoStorage.kt new file mode 100644 index 00000000..40d375b2 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/CastingDeviceInfoStorage.kt @@ -0,0 +1,47 @@ +package com.futo.platformplayer.stores + +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.CastingDeviceInfo +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@kotlinx.serialization.Serializable +class CastingDeviceInfoStorage : FragmentedStorageFileJson() { + var deviceInfos = mutableListOf(); + + @Synchronized + fun getDevicesCount(): Int { + return deviceInfos.size; + } + + @Synchronized + fun getDevices() : List { + return deviceInfos.toList(); + } + + @Synchronized + fun addDevice(castingDeviceInfo: CastingDeviceInfo): Boolean { + if (deviceInfos.any { d -> d.name == castingDeviceInfo.name }) { + Logger.i("CastingDeviceInfoStorage", "Device '${castingDeviceInfo.name}' already existed in device storage.") + return false; + } + + if (deviceInfos.size >= 5) { + deviceInfos.removeAt(0); + } + + deviceInfos.add(castingDeviceInfo); + save(); + return true; + } + + @Synchronized + fun removeDevice(name: String) { + deviceInfos.removeIf { d -> d.name == name }; + save(); + } + + override fun encode(): String { + return Json.encodeToString(this); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt new file mode 100644 index 00000000..a996a9c8 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt @@ -0,0 +1,229 @@ +package com.futo.platformplayer.stores + +import com.futo.platformplayer.Settings +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.stores.v2.JsonStoreSerializer +import com.futo.platformplayer.stores.v2.ManagedStore +import com.futo.platformplayer.stores.v2.StoreSerializer +import kotlinx.serialization.* +import kotlinx.serialization.json.* +import kotlinx.serialization.modules.SerializersModule +import java.io.File +import java.util.UUID +import kotlin.reflect.KType +import kotlin.reflect.full.createInstance +import kotlin.reflect.full.createType + +@Serializable() +class FragmentedStorage { + companion object { + val TAG = "LocalStorage"; + + @kotlin.jvm.Transient + val jsonSerializer = Json { ignoreUnknownKeys = true; }; + + var _cachedFiles = hashMapOf(); + var _cachedDirectories = hashMapOf(); + + var _filesDir: File? = null; + val isInitialized: Boolean get() = _filesDir != null; + + fun initialize(filesDir: File) { + _filesDir = filesDir; + } + + inline fun storeJson(parentDir: File, name: String, serializer: KSerializer? = null): ManagedStore = store(name, JsonStoreSerializer.create(serializer), null, parentDir); + inline fun storeJson(name: String, prettyName: String? = null, parentDir: File? = null): ManagedStore = store(name, JsonStoreSerializer.create(), prettyName, parentDir); + inline fun store(name: String, serializer: StoreSerializer, prettyName: String? = null, parentDir: File? = null): ManagedStore { + return ManagedStore(name, parentDir ?: _filesDir!!, kotlin.reflect.typeOf() , serializer); + } + + inline fun replace(text: String, verify: Boolean = true): T where T: FragmentedStorageFile { + if(verify) { + val dir = getOrCreateDirectory("temp"); + val tempFile = File(dir, UUID.randomUUID().toString()); + tempFile.writeText(text); + + val parsed = if (FragmentedStorageFileJson::class.java.isAssignableFrom(T::class.java)) + loadJsonFile(tempFile, null) + else if (FragmentedStorageFileString::class.java.isAssignableFrom(T::class.java)) + loadTextFile(tempFile, null) + else + throw NotImplementedError("Unknown file type for ${T::class.java.name}"); + + if (parsed == null) + throw IllegalStateException("Failed to import type [${T::class.java.name}]"); + } + + val name = T::class.java.name; + synchronized(_cachedFiles) { + val cachedFile = _cachedFiles[name]; + val file = cachedFile?.getUnderlyingFile() ?: File(_filesDir, "${T::class.java.name}.json"); + file.writeText(text); + _cachedFiles.remove(name); + } + return load(); + } + + inline fun get(reload: Boolean = false): T where T : FragmentedStorageFile { + val name = T::class.java.name; + + synchronized(_cachedFiles) { + if(reload) + _cachedFiles.remove(name); + var instance = _cachedFiles.get(name); + if (instance == null) { + instance = load(); + _cachedFiles[name] = instance; + return instance; + } + + return instance as T; + } + } + inline fun get(name: String): T where T : FragmentedStorageFile { + synchronized(_cachedFiles) { + var instance = _cachedFiles.get(name); + if (instance == null) { + instance = load(name); + _cachedFiles[name] = instance; + return instance; + } + + return instance as T; + } + } + inline fun getDirectory(): T { + val name = T::class.java.name; + + synchronized(_cachedDirectories) { + var instance = _cachedDirectories.get(name); + if (instance == null) { + instance = loadDirectory(getOrCreateDirectory(name)); + _cachedDirectories[name] = instance; + return instance; + } + + return instance as T; + } + } + + inline fun load(): T where T : FragmentedStorageFile { + if (_filesDir == null) { + throw Exception("Files dir should be initialized before loading a file.") + } + + val storageFile = File(_filesDir, "${T::class.java.name}.json"); + val storageBakFile = File(_filesDir, "${T::class.java.name}.json.bak"); + return loadFile(storageFile, storageBakFile); + } + inline fun load(dir: File, fileName: String): T where T : FragmentedStorageFile { + val storageFile = File(dir, "${fileName}"); + val storageBakFile = File(dir, "${fileName}.bak"); + return loadFile(storageFile, storageBakFile); + } + inline fun load(fileName: String): T where T : FragmentedStorageFile { + if (_filesDir == null) { + throw Exception("Files dir should be initialized before loading a file.") + } + + val storageFile = File(_filesDir, "${fileName}.json"); + val storageBakFile = File(_filesDir, "${fileName}.json.bak"); + return loadFile(storageFile, storageBakFile); + } + + fun loadFile(dir: File, fileName: String): File { + return File(dir, fileName); + } + + fun deleteFile(dir: File, fileName: String) { + val storageFile = File(dir, "${fileName}"); + val storageBakFile = File(dir, "${fileName}.bak"); + + if(storageFile.exists()) + storageFile.delete(); + if(storageBakFile.exists()) + storageBakFile.delete(); + } + + + fun getOrCreateDirectory(dirName: String) : File { + if (_filesDir == null) { + throw Exception("Files dir should be initialized before loading a file.") + } + + val dirFile = File(_filesDir, dirName); + if(!dirFile.exists()) + dirFile.mkdir(); + return dirFile; + } + inline fun loadFile(file: File, bakFile: File?): T where T : FragmentedStorageFile { + val typeName = T::class.java.name; + if (file.exists()) { + var resp = + if(FragmentedStorageFileJson::class.java.isAssignableFrom(T::class.java)) + loadJsonFile(file, bakFile) + else if(FragmentedStorageFileString::class.java.isAssignableFrom(T::class.java)) + loadTextFile(file, bakFile) + else + throw NotImplementedError("Unknown file type for ${typeName}"); + if(resp != null) + return resp; + } else { + Logger.w(TAG, "Failed to fragment storage because the file does not exist. Attempting backup. [${typeName}]"); + } + + if (bakFile?.exists() ?: false) { + var resp = + if(FragmentedStorageFileJson::class.java.isAssignableFrom(T::class.java)) + loadJsonFile(file, bakFile, true) + else if(FragmentedStorageFileString::class.java.isAssignableFrom(T::class.java)) + loadTextFile(file, bakFile, true) + else + throw NotImplementedError("Unknown file type"); + if(resp != null) + return resp; + } else { + Logger.w(TAG, "Failed to fragment storage because the backup file does not exist. Using default instance. [${typeName}]"); + } + + return (T::class.java.newInstance() as T).withFile(file, bakFile) as T; + } + inline fun loadDirectory(file: File): T where T : IFragmentedStorageDirectory { + return (T::class.java.newInstance() as T).withDirectory(file) as T; + } + + inline fun loadJsonFile(file: File, bakFile: File?, fromBak: Boolean = false) : T? { + try { + val json = (if(!fromBak) file else bakFile)?.readText() ?: return null; + val fileObj = jsonSerializer.decodeFromString(json); + if(fromBak && bakFile != null) + bakFile.copyTo(file, true); + return fileObj.withFile(file, bakFile) as T; + } catch (e: Throwable) { + if(!fromBak) + Logger.e(TAG, "Failed to load fragment storage. Attempting backup.", e); + else + Logger.e(TAG, "Failed to load fragment storage. Using default instance.", e); + } + return null; + } + inline fun loadTextFile(file: File, bakFile: File?, fromBak: Boolean = false) : T? { + try { + val text = (if(!fromBak) file else bakFile)?.readText() ?: return null; + val fileObj = (T::class.java.newInstance() as T).withFile(file, bakFile) as T + (fileObj as FragmentedStorageFileString).decode(text); + if(fromBak && bakFile != null) + bakFile.copyTo(file, true); + return fileObj.withFile(file, bakFile) as T; + } catch (e: Throwable) { + if(!fromBak) + Logger.w(TAG, "Failed to load fragment storage. Attempting backup.", e); + else + Logger.w(TAG, "Failed to load fragment storage. Using default instance.", e); + } + return null; + } + } + //endregion +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorageDirectory.kt b/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorageDirectory.kt new file mode 100644 index 00000000..08b2ec10 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorageDirectory.kt @@ -0,0 +1,40 @@ +package com.futo.platformplayer.stores + +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import java.io.File + +interface IFragmentedStorageDirectory { + fun withDirectory(dir: File): IFragmentedStorageDirectory; +} + +@Serializable +open class FragmentedStorageDirectory : IFragmentedStorageDirectory { + private val TAG = "FragmentedStorageDirectory"; + + @Transient + var directory: File? = null; + + fun getFiles() : List { + return directory!!.listFiles() + .filter { it.extension != ".bak" } + .map { it.name }; + } + fun hasFile(name: String) : Boolean { + return File(directory, name).exists(); + } + fun getFileReference(name: String): File { + return FragmentedStorage.loadFile(directory!!, name); + } + inline fun getFileOrCreate(name : String) : T{ + return FragmentedStorage.load(directory!!, name); + } + fun deleteFile(name: String) { + FragmentedStorage.deleteFile(directory!!, name); + } + + override fun withDirectory(dir: File): IFragmentedStorageDirectory { + directory = dir; + return this; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorageFile.kt b/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorageFile.kt new file mode 100644 index 00000000..7733fcfc --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorageFile.kt @@ -0,0 +1,73 @@ +package com.futo.platformplayer.stores + +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.constructs.BackgroundTaskHandler +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import java.io.File + + +@Serializable +open class FragmentedStorageFile() { + @kotlinx.serialization.Transient + private val _lock = Object(); + + private val TAG = "FragmentedStorageFile"; + + @Transient + private var _file: File? = null; + + @Transient + private var _bakFile: File? = null; + + + @Transient + private val _backgroundSave = BackgroundTaskHandler(StateApp.instance.scope, { + saveBlocking(); + }); + + fun getUnderlyingFile(): File? { + return _file; + } + + fun withFile(file: File, bakFile: File?): FragmentedStorageFile { + _file = file; + _bakFile = bakFile; + return this; + } + + fun save() { + _backgroundSave.run(); + } + fun saveBlocking() { + synchronized(_lock) { + val file = _file; + if (file == null) { + Logger.w(TAG, "Failed to flush settings because file was null.") + return; + } + + if (file.exists()) { + val bakFile = _bakFile; + if (bakFile != null) { + file.copyTo(bakFile, true); + } + } + + val json = encode(); + file.writeText(json); + } + } + + fun delete() { + if(_file?.exists() ?: false) + _file?.delete(); + if(_bakFile?.exists() ?: false) + _bakFile?.delete(); + } + + open fun encode(): String { + return "{}"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorageFileJson.kt b/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorageFileJson.kt new file mode 100644 index 00000000..5132e070 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorageFileJson.kt @@ -0,0 +1,11 @@ +package com.futo.platformplayer.stores + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +abstract class FragmentedStorageFileJson : FragmentedStorageFile() { + + override fun encode(): String { + return Json.encodeToString(this); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorageFileString.kt b/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorageFileString.kt new file mode 100644 index 00000000..f03c85ea --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorageFileString.kt @@ -0,0 +1,12 @@ +package com.futo.platformplayer.stores + +open class FragmentedStorageFileString : FragmentedStorageFile() { + var value : String? = null; + + override fun encode(): String { + return value ?: ""; + } + open fun decode(str: String) { + value = str; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/MapStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/MapStorage.kt new file mode 100644 index 00000000..b9c036ac --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/MapStorage.kt @@ -0,0 +1,31 @@ +package com.futo.platformplayer.stores + +import com.futo.platformplayer.polycentric.PolycentricCache +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@kotlinx.serialization.Serializable +class CachedPolycentricProfileStorage : FragmentedStorageFileJson() { + var map: HashMap = hashMapOf(); + + override fun encode(): String { + val encoded = Json.encodeToString(this); + return encoded; + } + + fun get(key: String) : PolycentricCache.CachedPolycentricProfile? { + return map[key]; + } + + fun setAndSave(key: String, value: PolycentricCache.CachedPolycentricProfile) : PolycentricCache.CachedPolycentricProfile { + map[key] = value; + save(); + return value; + } + + fun setAndSaveBlocking(key: String, value: PolycentricCache.CachedPolycentricProfile) : PolycentricCache.CachedPolycentricProfile { + map[key] = value; + saveBlocking(); + return value; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/PluginIconStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/PluginIconStorage.kt new file mode 100644 index 00000000..e14ce3ad --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/PluginIconStorage.kt @@ -0,0 +1,42 @@ +package com.futo.platformplayer.stores + +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.ImageVariable +import java.io.File + +class PluginIconStorage : FragmentedStorageDirectory() { + + fun hasIcon(name: String) : Boolean { + val ref = getFileReference(name); + return ref.exists(); + } + + fun getIconBinary(name: String) : ImageVariable { + return ImageVariable.fromFile(getFileOrThrow(name)); + } + fun saveIconBinary(name: String, binary: ByteArray) { + val file = getFileReference(name); + try { + file.writeBytes(binary); + } + catch(ex: Throwable) { + Logger.e("Failed to save icon", ex.message, ex); + file.delete(); + } + finally { + } + } + fun deleteIconBinary(name: String) { + val file = getFileReference(name); + if(file.exists()) + file.delete(); + } + + + fun getFileOrThrow(name: String) : File { + val ref = getFileReference(name); + if(!ref.exists()) + throw IllegalArgumentException("File does not exist [${name}]"); + return ref; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/PluginScriptsDirectory.kt b/app/src/main/java/com/futo/platformplayer/stores/PluginScriptsDirectory.kt new file mode 100644 index 00000000..56d41bff --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/PluginScriptsDirectory.kt @@ -0,0 +1,17 @@ +package com.futo.platformplayer.stores + +class PluginScriptsDirectory : FragmentedStorageDirectory() { + fun getScript(id: String) : String? { + if(hasFile(id)) + return getFileOrCreate(id).value; + return null; + } + fun setScript(id: String, script: String) { + val file = getFileOrCreate(id); + file.value = script; + file.saveBlocking(); + } + fun removeScript(id: String) { + deleteFile(id); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/PluginStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/PluginStorage.kt new file mode 100644 index 00000000..b9fcfc57 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/PluginStorage.kt @@ -0,0 +1,15 @@ +package com.futo.platformplayer.stores + +import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@kotlinx.serialization.Serializable +class PluginStorage : FragmentedStorageFileJson() { + + var sourcePlugins = mutableListOf(); + + override fun encode(): String { + return Json.encodeToString(this); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/SearchHistoryStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/SearchHistoryStorage.kt new file mode 100644 index 00000000..2bd43968 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/SearchHistoryStorage.kt @@ -0,0 +1,26 @@ +package com.futo.platformplayer.stores + +import kotlinx.serialization.* +import kotlinx.serialization.json.Json + +@Serializable() +class SearchHistoryStorage : FragmentedStorageFileJson() { + var lastQueries = arrayListOf(); + + fun add(text: String) { + if (!lastQueries.contains(text)) { + lastQueries.add(0, text); + if (lastQueries.size > 10) + lastQueries.removeLast(); + } + else { + lastQueries.remove(text); + lastQueries.add(0, text); + } + save(); + } + + override fun encode(): String { + return Json.encodeToString(this); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/StringArrayStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/StringArrayStorage.kt new file mode 100644 index 00000000..be1e69e3 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/StringArrayStorage.kt @@ -0,0 +1,44 @@ +package com.futo.platformplayer.stores + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@kotlinx.serialization.Serializable +class StringArrayStorage : FragmentedStorageFileJson() { + + var values = mutableListOf(); + + override fun encode(): String { + return Json.encodeToString(this); + } + + fun add(obj: String) { + synchronized(values) { + values.add(obj) + } + } + fun addDistinct(obj: String) { + synchronized(values) { + if(!values.any { it == obj }) + values.add(obj); + } + } + + fun remove(obj: String) { + synchronized(values) { + values.removeIf { it == obj }; + } + } + fun set(vararg objs: String) { + synchronized(values) { + values.clear(); + values.addAll(objs); + } + } + + fun getAllValues(): List { + synchronized(values){ + return values.toList(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/StringHashSetStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/StringHashSetStorage.kt new file mode 100644 index 00000000..0f397782 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/StringHashSetStorage.kt @@ -0,0 +1,50 @@ +package com.futo.platformplayer.stores + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@kotlinx.serialization.Serializable +class StringHashSetStorage : FragmentedStorageFileJson() { + + var values = HashSet(); + + override fun encode(): String { + return Json.encodeToString(this); + } + + fun contains(obj: String): Boolean { + synchronized(values) { + return values.contains(obj); + } + } + + fun add(obj: String) { + synchronized(values) { + values.add(obj) + } + } + fun addDistinct(obj: String) { + synchronized(values) { + if(!values.contains(obj)) + values.add(obj); + } + } + + fun remove(obj: String) { + synchronized(values) { + values.remove(obj); + } + } + fun set(vararg objs: String) { + synchronized(values) { + values.clear(); + values.addAll(objs); + } + } + + fun getAllValues(): List { + synchronized(values){ + return values.toList(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/StringStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/StringStorage.kt new file mode 100644 index 00000000..e029adf9 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/StringStorage.kt @@ -0,0 +1,26 @@ +package com.futo.platformplayer.stores + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@kotlinx.serialization.Serializable +class StringStorage : FragmentedStorageFileJson() { + + var value : String = ""; + + override fun encode(): String { + return Json.encodeToString(this); + } + + fun setAndSave(str: String) : String { + value = str; + save(); + return value; + } + + fun setAndSaveBlocking(str: String) : String { + value = str; + saveBlocking(); + return value; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/SubscriptionStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/SubscriptionStorage.kt new file mode 100644 index 00000000..1e047dca --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/SubscriptionStorage.kt @@ -0,0 +1,32 @@ +package com.futo.platformplayer.stores + +import com.futo.platformplayer.states.StateSubscriptions +import com.futo.platformplayer.models.Subscription +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import kotlinx.serialization.* +import kotlinx.serialization.json.Json + +@Serializable() +class SubscriptionStorage : FragmentedStorageFileJson() { + var version = StateSubscriptions.VERSION; + var subscriptions = arrayListOf(); + + fun addSubscription(channel: Subscription) : Subscription { + subscriptions.add(channel); + return channel; + } + + fun removeSubscription(url : String) { + val toRemove = subscriptions.firstOrNull { it.channel.url == url }; + subscriptions.removeIf { it.channel.url == url }; + } + + fun isSubscribedTo(channel: IPlatformChannel): Boolean = isSubscribedTo(channel.url); + fun isSubscribedTo(channel: PlatformAuthorLink): Boolean = isSubscribedTo(channel.url); + fun isSubscribedTo(url: String) : Boolean = subscriptions.any { u -> u.channel.url == url }; + + override fun encode(): String { + return Json.encodeToString(this); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/WatchLaterStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/WatchLaterStorage.kt new file mode 100644 index 00000000..0c1b77ea --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/WatchLaterStorage.kt @@ -0,0 +1,15 @@ +package com.futo.platformplayer.stores + +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@kotlinx.serialization.Serializable +class WatchLaterStorage : FragmentedStorageFileJson() { + + var playlist = listOf(); + + override fun encode(): String { + return Json.encodeToString(this); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/v2/IStoreItem.kt b/app/src/main/java/com/futo/platformplayer/stores/v2/IStoreItem.kt new file mode 100644 index 00000000..5273ea3c --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/v2/IStoreItem.kt @@ -0,0 +1,5 @@ +package com.futo.platformplayer.stores.v2 + +interface IStoreItem { + fun onDelete(); +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/v2/ManagedStore.kt b/app/src/main/java/com/futo/platformplayer/stores/v2/ManagedStore.kt new file mode 100644 index 00000000..a34aa49d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/v2/ManagedStore.kt @@ -0,0 +1,500 @@ +package com.futo.platformplayer.stores.v2 + +import com.futo.platformplayer.assume +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer +import java.io.File +import java.io.FileNotFoundException +import java.lang.Exception +import java.util.* +import kotlin.collections.ArrayList +import kotlin.collections.HashMap +import kotlin.reflect.KClass +import kotlin.reflect.KType +import kotlin.reflect.javaType + +class ManagedStore{ + private val _class: KType; + private val _name: String; + private val _dir: File; + private val _files: HashMap = hashMapOf(); + private val _serializer: StoreSerializer; + + private val _toReconstruct: ArrayList = ArrayList(); + + private var _isLoaded = false; + + private var _withBackup: Boolean = true; + private var _reconstructStore: ReconstructStore? = null; + + private var _withUnique: ((T) -> Any)? = null; + + val className: String? get() = _class.classifier?.assume>()?.simpleName; + + val name: String; + + constructor(name: String, dir: File, clazz: KType, serializer: StoreSerializer, niceName: String? = null) { + _name = name; + this.name = niceName ?: name.let { + if(it.length > 0) + return@let it[0].uppercase() + it.substring(1); + return@let name; + }; + _serializer = serializer; + _class = clazz; + _dir = File(dir, name); + if(!_dir.exists()) + _dir.mkdir(); + } + + fun withUnique(handler: (T) -> Any): ManagedStore { + _withUnique = handler; + return this; + } + fun withRestore(backup: ReconstructStore): ManagedStore { + _reconstructStore = backup; + return this; + } + fun withoutBackup(): ManagedStore{ + _withBackup = false; + return this; + } + + fun load(): ManagedStore { + synchronized(_files) { + _files.clear(); + val newObjs = _dir.listFiles().map { it.nameWithoutExtension }.distinct().toList().map { fileId -> + //Logger.i(TAG, "FILE:" + it.name); + val mfile = ManagedFile(fileId, _dir); + val obj = mfile.load(this, _withBackup); + if(obj == null) { + Logger.w(TAG, "Deleting ${logName(mfile.id)}"); + mfile.delete(false); + if(mfile.reconstructFile.exists()) { + _toReconstruct.add(mfile); + Logger.i(TAG, "Reconstruction required: ${logName(fileId)}"); + } + } + + return@map Pair(obj, mfile); + }.filter { it.first != null }; + + for (obj in newObjs) + _files.put(obj.first!!, obj.second); + } + _isLoaded = true; + return this; + } + fun getMissingReconstructionCount(): Int { + synchronized(_toReconstruct) { + return _toReconstruct.size; + } + } + fun hasMissingReconstructions(): Boolean { + synchronized(_toReconstruct) { + return _toReconstruct.any(); + } + } + + fun deleteMissing() { + synchronized(_toReconstruct) { + for(file in _toReconstruct) + file.delete(true); + _toReconstruct.clear(); + } + } + suspend fun importReconstructions(items: List, onProgress: ((Int, Int)->Unit)? = null): ReconstructionResult { + var successes = 0; + val exs = ArrayList(); + + val total = items.size; + var finished = 0; + + val builder = ReconstructStore.Builder(); + + for (recon in items) { + //Retry once + for (i in 0 .. 1) { + try { + Logger.i(TAG, "Importing ${logName(recon)}"); + val reconId = createFromReconstruction(recon, builder); + successes++; + Logger.i(TAG, "Imported ${logName(reconId)}"); + break; + } catch (ex: Throwable) { + Logger.e(TAG, "Failed to reconstruct import", ex); + if (i == 1) { + exs.add(ex); + } + } + } + finished++; + onProgress?.invoke(finished, total); + } + return ReconstructionResult(successes, exs, builder.messages); + } + + suspend fun reconstructMissing(onProgress: ((Int, Int)->Unit)? = null): ReconstructionResult { + var successes = 0; + val exs = ArrayList(); + val missings = synchronized(_toReconstruct) { _toReconstruct.toList(); } + + val total = missings.size; + var finished = 0; + + val builder = ReconstructStore.Builder(); + + for (missing in missings) { + //Retry once + for (i in 0 .. 1) { + try { + Logger.i(TAG, "Started reconstructing ${logName(missing.id)}"); + val reconstructed = missing.reconstruct(this, builder); + + missing.write(_serializer.serialize(_class, reconstructed), _withBackup); + synchronized(_files) { + _files.put(reconstructed, missing); + } + synchronized(_toReconstruct) { + _toReconstruct.remove(missing); + } + successes++; + Logger.i(TAG, "Reconstructed ${logName(missing.id)}"); + break; + } catch (ex: Throwable) { + Logger.e(TAG, "Failed to reconstruct ${logName(missing.id)}", ex); + + if (i == 1) { + exs.add(ex); + } + } + finished++; + onProgress?.invoke(finished, total); + } + } + return ReconstructionResult(successes, exs, builder.messages); + } + + fun getItems(): List { + synchronized(_files) { + return _files.map { it.key }; + } + } + fun queryItem(query: (Iterable)->T?) : T? { + synchronized(_files) { + return query(_files.keys.asIterable()); + } + } + fun hasItems(): Boolean { + synchronized(_files) { + return _files.any(); + } + } + fun hasItem(query: (T)-> Boolean): Boolean { + synchronized(_files) { + return _files.keys.any { query(it) }; + } + } + fun findItem(query: (T)->Boolean): T? { + synchronized(_files) { + return _files.keys.find(query); + } + } + fun findItems(query: (T)->Boolean): List { + synchronized(_files) { + return _files.keys.filter(query); + } + } + + fun getFile(obj: T): ManagedFile? { + synchronized(_files) { + if(_files.containsKey(obj)) + return _files[obj]; + return null; + } + } + + + fun saveAsync(obj: T, withReconstruction: Boolean = false) { + val scope = StateApp.instance.scopeOrNull; + if(scope != null) + scope.launch(Dispatchers.IO) { + try { + save(obj, withReconstruction); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to save.", e); + } + }; + else + save(obj, withReconstruction); + } + fun saveAllAsync(objs: List, withReconstruction: Boolean = false) { + val scope = StateApp.instance.scopeOrNull; + if(scope != null) + scope.launch(Dispatchers.IO) { + saveAll(objs, withReconstruction); + }; + else + saveAll(objs, withReconstruction); + } + fun save(obj: T, withReconstruction: Boolean = false, onlyExisting: Boolean = false) { + synchronized(_files) { + val uniqueVal = if(_withUnique != null) + _withUnique!!(obj); + else null; + + var file = getFile(obj); + if (file != null) { + Logger.v(TAG, "Saving file ${logName(file.id)}"); + val encoded = _serializer.serialize(_class, obj); + file.write(encoded, _withBackup); + if(_reconstructStore != null && (_reconstructStore!!.backupOnSave || withReconstruction)) + saveReconstruction(file, obj); + } + else if(!onlyExisting && (uniqueVal == null || !_files.any { _withUnique!!(it.key) == uniqueVal })) { + file = saveNew(obj); + if(_reconstructStore != null && (_reconstructStore!!.backupOnCreate || withReconstruction)) + saveReconstruction(file, obj); + } + } + } + fun saveAll(items: List, withReconstruction: Boolean = false, onlyExisting: Boolean = false) { + for(obj in items) + save(obj, withReconstruction, onlyExisting); + } + + suspend fun createFromReconstruction(reconstruction: String, builder: ReconstructStore.Builder): String { + if(_reconstructStore == null) + throw IllegalStateException("Can't reconstruct as no reconstruction is implemented for this type"); + + val id = UUID.randomUUID().toString(); + val reconstruct = _reconstructStore!!.toObjectWithHeader(id, reconstruction, builder); + save(reconstruct); + return id; + } + + fun delete(item: T) { + synchronized(_files) { + val file = _files[item]; + if(file != null) { + if(item is IStoreItem) + item.onDelete(); + _files.remove(item); + Logger.v(TAG, "Deleting file ${logName(file.id)}"); + file.delete(); + } + } + } + fun deleteAll() { + synchronized(_files) { + val keys = _files.keys.toList(); + for(key in keys) + delete(key); + } + } + + private fun saveNew(obj: T): ManagedFile { + synchronized(_files) { + val id = UUID.randomUUID().toString(); + Logger.v(TAG, "New file ${logName(id)}"); + val encoded = _serializer.serialize(_class, obj); + + val mfile = ManagedFile(id, _dir); + mfile.write(encoded, _withBackup); + + _files.put(obj, mfile); + return mfile; + } + } + + fun getAllReconstructionStrings(withHeader: Boolean = false): List { + if(_reconstructStore == null) + throw IllegalStateException("Can't reconstruct as no reconstruction is implemented for this type"); + + return getItems().map { + getReconstructionString(it, withHeader) + }; + } + fun getReconstructionString(obj: T, withHeader: Boolean = false): String { + if(_reconstructStore == null) + throw IllegalStateException("Can't reconstruct as no reconstruction is implemented for this type"); + + if(withHeader) + return _reconstructStore!!.toReconstructionWithHeader(obj, className ?: ""); + else + return _reconstructStore!!.toReconstruction(obj); + } + private fun saveReconstruction(file: ManagedFile, obj: T) { + if(_reconstructStore == null) + return; + val reconstruction = getReconstructionString(obj, true); + file.writeReconstruction(reconstruction); + } + + fun isReconstructionIdentifier(identifier: String): Boolean { + if(_reconstructStore == null) + throw IllegalStateException("Can't reconstruct as no reconstruction is implemented for this type"); + + return identifier == (_reconstructStore?.identifierName ?: className) + } + fun isReconstructionHeader(recon: String): Boolean { + val identifier = getReconstructionIdentifier(recon); + return identifier != null && isReconstructionIdentifier(identifier); + } + + class ManagedFile( + val id: String, + val dir: File + ) { + val file: File = File(dir, id); + val bakFile: File = File(dir, id + ".bak"); + val reconstructFile: File = File(dir, id + ".rec"); + + fun load(store: ManagedStore, withBackup: Boolean = true): T? { + synchronized(this) { + try { + if(!file.exists()) + throw FileNotFoundException(); + val data = read(); + + //Uncomment to test migration + //if(className == "Subscription") throw IllegalStateException("Test Exception"); + + return store._serializer.deserialize(store._class, data); + } + catch(ex: Throwable) { + if(ex !is FileNotFoundException) + Logger.w(TAG, "Failed to parse ${store.logName(id)}", ex); + + if(withBackup) { + val backData = readBackup(); + try { + if (backData != null) { + Logger.i(TAG, "Loading from backup ${store.logName(id)}"); + return store._serializer.deserialize(store._class, backData); + } else Logger.i(TAG, "No backup exists for ${store.logName(id)}") + } catch (bakEx: Throwable) { + Logger.w(TAG, "Failed to bakfile parse ${store.logName(id)}", bakEx); + } + } + } + + Logger.w(TAG, "No object from ${store.logName(id)}"); + return null; + } + } + + suspend fun reconstruct(store: ManagedStore, builder: ReconstructStore.Builder): T { + if(store._reconstructStore == null) + throw IllegalStateException("No reconstruction logic exists?"); + + val reconstruction = readReconstruction() + ?: throw FileNotFoundException("No reconstruction found"); + + val reconstructed: T; + try { + reconstructed = store._reconstructStore!!.toObjectWithHeader(id, reconstruction, builder); + } + catch(ex: Throwable) { + throw ex; + } + return reconstructed; + } + + + fun write(data: ByteArray, withBackup: Boolean = true) { + if(withBackup && file.exists()) + file.copyTo(bakFile, true); + file.writeBytes(data); + } + fun writeReconstruction(str: String) { + reconstructFile.writeText(str, Charsets.UTF_8); + } + + fun read(): ByteArray { + return file.readBytes(); + } + fun readBackup(): ByteArray? { + if(bakFile.exists()) + return bakFile.readBytes(); + return null; + } + fun readReconstruction(): String? { + if(reconstructFile.exists()) + return reconstructFile.readText(Charsets.UTF_8); + return null; + } + + fun delete(deleteReconstruction: Boolean = true) { + if(file.exists()) + file.delete(); + if(bakFile.exists()) + bakFile.delete(); + if(deleteReconstruction && reconstructFile.exists()) + reconstructFile.delete(); + } + } + + data class ReconstructionResult( + val success: Int = 0, + val exceptions: List, + val messages: List + ); + + private fun logName(id: String?): String { + return "${_name}:[${(_class.classifier as KClass<*>).simpleName}] ${id ?: ""}"; + } + + companion object { + val TAG = "ManagedStore"; + val RECONSTRUCTION_HEADER_OPERATOR = "@/"; + + fun getReconstructionIdentifier(recon: String): String? { + if(!recon.startsWith(RECONSTRUCTION_HEADER_OPERATOR) || !recon.contains("\n")) + return null; + else + return recon.substring(2, recon.indexOf("\n")); + } + } +} + +interface StoreSerializer { + fun serialize(clazz: KType, obj: T): ByteArray; + fun deserialize(clazz: KType, obj: ByteArray): T; +} + +class JsonStoreSerializer: StoreSerializer { + private val _serializer: KSerializer + val jsonSer = Json { ignoreUnknownKeys = true; encodeDefaults = true; } + + constructor(serializer: KSerializer) { + _serializer = serializer; + } + + override fun serialize(clazz: KType, obj: T): ByteArray { + val json = jsonSer.encodeToString(_serializer,obj)//gson.toJson(obj); + return json.toByteArray(Charsets.UTF_8); + } + + override fun deserialize(clazz: KType, obj: ByteArray): T { + val json = String(obj, Charsets.UTF_8); + try { + return jsonSer.decodeFromString(_serializer, json); + } + catch(ex: Throwable) { + Logger.e(ManagedStore.TAG, "Json for ${(clazz.classifier as KClass<*>).simpleName}:\n" + json, ex); + throw ex; + } + } + + companion object { + inline fun create(serializer: KSerializer? = null): JsonStoreSerializer { + return JsonStoreSerializer(if(serializer != null) serializer else serializer()); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/v2/ReconstructStore.kt b/app/src/main/java/com/futo/platformplayer/stores/v2/ReconstructStore.kt new file mode 100644 index 00000000..78004a29 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/v2/ReconstructStore.kt @@ -0,0 +1,33 @@ +package com.futo.platformplayer.stores.v2 + +abstract class ReconstructStore { + open val backupOnSave: Boolean = false; + open val backupOnCreate: Boolean = true; + + val identifierName: String?; + + constructor(identifierName: String? = null) { + this.identifierName = identifierName; + } + + abstract fun toReconstruction(obj: T): String; + abstract suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder): T; + + fun toReconstructionWithHeader(obj: T, fallbackName: String): String { + val identifier = identifierName ?: fallbackName; + return "@/${identifier}\n${toReconstruction(obj)}"; + } + + suspend fun toObjectWithHeader(id: String, backup: String, builder: Builder): T { + if(backup.startsWith("@/") && backup.contains("\n")) + return toObject(id, backup.substring(backup.indexOf("\n") + 1), builder); + else + return toObject(id, backup, builder); + } + + + + class Builder { + val messages: ArrayList = arrayListOf(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt b/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt new file mode 100644 index 00000000..f13442a0 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt @@ -0,0 +1,108 @@ +package com.futo.platformplayer.video + +import android.media.MediaPlayer +import android.media.session.PlaybackState +import android.support.v4.media.session.PlaybackStateCompat +import com.futo.platformplayer.constructs.Event1 +import com.google.android.exoplayer2.ExoPlayer +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout +import com.google.android.exoplayer2.ui.StyledPlayerView + +class PlayerManager { + private var _currentView: StyledPlayerView? = null; + private val _stateMap = HashMap(); + private var _currentState: PlayerState? = null; + val currentState: PlayerState get() { + if(_currentState == null) + throw java.lang.IllegalStateException("Attempted to access CurrentState while no state is set"); + else + return _currentState!!; + }; + + val player: ExoPlayer; + + constructor(exoPlayer: ExoPlayer) { + this.player = exoPlayer; + } + + fun getPlaybackStateCompat() : Int { + return when(player.playbackState) { + ExoPlayer.STATE_READY -> if(player.playWhenReady) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED; + ExoPlayer.STATE_BUFFERING -> PlaybackState.STATE_BUFFERING; + else -> PlaybackState.STATE_NONE + } + } + + @Synchronized + fun attach(view: StyledPlayerView, stateName: String) { + if(view != _currentView) { + _currentView?.player = null; + switchState(stateName); + view.player = player; + _currentView = view; + } + } + fun detach() { + _currentView?.player = null; + } + + fun getState(name: String): PlayerState { + if(!_stateMap.containsKey(name)) + _stateMap[name] = PlayerState(); + return _stateMap[name]!!; + } + fun modifyState(name: String, cb: (PlayerState) -> Unit) { + val state = getState(name); + cb(state); + if(_currentState == state) + applyState(state); + } + fun switchState(name: String) { + val newState = getState(name); + applyState(newState); + + if(_currentState != newState) { + + if(_currentState?.listener != null) + player.removeListener(_currentState!!.listener!!); + if(newState.listener != null) + player.addListener(newState.listener!!); + + _currentState = newState; + } + } + fun applyState(state: PlayerState) { + player.volume = if(state.muted) 0f else state.volume; + } + + fun setMuted(muted: Boolean) { + currentState.muted = muted; + applyState(currentState); + } + fun setVolume(volume: Float) { + currentState.volume = volume; + applyState(currentState); + } + fun setListener(listener: Player.Listener) { + if(currentState.listener == listener) + return; + if(currentState.listener != null) + player.removeListener(currentState.listener!!); + currentState.listener = listener; + player.addListener(listener); + } + + fun release(){ + player.release(); + } + + class PlayerState { + var muted: Boolean = false; + var volume: Float = 1f; + + var listener: Player.Listener? = null; + + var resizMode: Int = AspectRatioFrameLayout.RESIZE_MODE_FIT; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/AnyAdapterView.kt b/app/src/main/java/com/futo/platformplayer/views/AnyAdapterView.kt new file mode 100644 index 00000000..2ff1e01f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/AnyAdapterView.kt @@ -0,0 +1,87 @@ +package com.futo.platformplayer.views + +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.views.adapters.* + +open class BaseAnyAdapterView + where T : AnyAdapter.AnyViewHolder, IT: RecyclerView.ViewHolder{ + val view: RecyclerView; + val adapter: BaseAnyAdapter; + + constructor(view: RecyclerView, adapter: BaseAnyAdapter, orientation: Int, reversed: Boolean) { + this.view = view; + this.adapter = adapter; + view.adapter = adapter.adapter; + view.layoutManager = LinearLayoutManager(view.context, orientation, reversed); + } + + fun setData(items: Iterable) { + adapter.setData(items); + } + fun add(item: I) { + adapter.add(item); + } + + fun all(cb: (I) -> Unit) { + adapter.all(cb); + } + fun notifyItemRangeInserted(i: Int, itemCount: Int) { + adapter.notifyItemRangeInserted(i, itemCount) + } + fun notifyContentChanged(i: Int) { + adapter.notifyContentChanged(i); + } + fun notifyContentChanged() { + adapter.notifyContentChanged(); + } + fun notifyContentChange(item: I) { + adapter.notifyContentChange(item); + } +} +class AnyAdapterView(view: RecyclerView, adapter: BaseAnyAdapter, orientation: Int, reversed: Boolean) + : BaseAnyAdapterView(view, adapter, orientation, reversed) + where T : AnyAdapter.AnyViewHolder{ + + companion object { + inline fun > RecyclerView.asAny(list: List, orientation: Int = RecyclerView.VERTICAL, reversed: Boolean = false, noinline onCreate: ((T)->Unit)? = null): AnyAdapterView { + return asAny(ArrayList(list), orientation, reversed, onCreate); + } + inline fun > RecyclerView.asAny(list: ArrayList, orientation: Int = RecyclerView.VERTICAL, reversed: Boolean = false, noinline onCreate: ((T)->Unit)? = null): AnyAdapterView { + return AnyAdapterView(this, AnyAdapter.create(list, onCreate), orientation, reversed); + } + + inline fun > RecyclerView.asAny(orientation: Int = RecyclerView.VERTICAL, reversed: Boolean = false, noinline onCreate: ((T)->Unit)? = null): AnyAdapterView { + return AnyAdapterView(this, AnyAdapter.create(onCreate), orientation, reversed); + } + } +} + +class AnyInsertedAdapterView(view: RecyclerView, adapter: BaseAnyAdapter>, orientation: Int, reversed: Boolean) + : BaseAnyAdapterView>(view, adapter, orientation, reversed) + where T : AnyAdapter.AnyViewHolder { + + companion object { + inline fun> RecyclerView.asAnyWithTop(list: ArrayList, view: View, orientation: Int = RecyclerView.VERTICAL, reversed: Boolean = false, noinline onCreate: ((T)->Unit)? = null) : AnyInsertedAdapterView + = this.asAnyWithViews(list, arrayListOf(view), arrayListOf(), orientation, reversed, onCreate); + + inline fun> RecyclerView.asAnyWithTop(view: View, orientation: Int = RecyclerView.VERTICAL, reversed: Boolean = false, noinline onCreate: ((T)->Unit)? = null) : AnyInsertedAdapterView + = this.asAnyWithViews(arrayListOf(view), arrayListOf(), orientation, reversed, onCreate); + inline fun> RecyclerView.asAnyWithViews(prepend: ArrayList = arrayListOf(), append: ArrayList = arrayListOf(), orientation: Int = RecyclerView.VERTICAL, reversed: Boolean = false, noinline onCreate: ((T)->Unit)? = null) : AnyInsertedAdapterView { + for(view in prepend) + (view.parent as ViewGroup).removeView(view); + for(view in append) + (view.parent as ViewGroup).removeView(view); + return AnyInsertedAdapterView(this, AnyInsertedAdapter.create(prepend, append, onCreate), orientation, reversed); + } + inline fun> RecyclerView.asAnyWithViews(list: ArrayList, prepend: ArrayList = arrayListOf(), append: ArrayList = arrayListOf(), orientation: Int = RecyclerView.VERTICAL, reversed: Boolean = false, noinline onCreate: ((T)->Unit)? = null) : AnyInsertedAdapterView { + for(view in prepend) + (view.parent as ViewGroup).removeView(view); + for(view in append) + (view.parent as ViewGroup).removeView(view); + return AnyInsertedAdapterView(this, AnyInsertedAdapter.create(list, prepend, append, onCreate), orientation, reversed); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/FeedStyle.kt b/app/src/main/java/com/futo/platformplayer/views/FeedStyle.kt new file mode 100644 index 00000000..c261b755 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/FeedStyle.kt @@ -0,0 +1,25 @@ +package com.futo.platformplayer.views + +import com.futo.platformplayer.api.media.exceptions.UnknownPlatformException +import com.futo.platformplayer.api.media.models.contents.ContentType + +enum class FeedStyle(val value: Int) { + UNKNOWN(-1), + THUMBNAIL(1), + PREVIEW(2); + + + + companion object { + val THUMBNAIL_HEIGHT = 115; + val PREVIEW_HEIGHT = 310; + + fun fromInt(value: Int): FeedStyle + { + val result = FeedStyle.values().firstOrNull { it.value == value }; + if(result == null) + throw UnknownPlatformException(value.toString()); + return result; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/Loader.kt b/app/src/main/java/com/futo/platformplayer/views/Loader.kt new file mode 100644 index 00000000..644d6047 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/Loader.kt @@ -0,0 +1,58 @@ +package com.futo.platformplayer.views + +import android.content.Context +import android.graphics.drawable.Animatable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import com.futo.platformplayer.R + +class Loader : LinearLayout { + private val _imageLoader: ImageView; + private val _automatic: Boolean; + private val _animatable: Animatable; + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + LayoutInflater.from(context).inflate(R.layout.view_loader, this, true); + _imageLoader = findViewById(R.id.image_loader); + _animatable = _imageLoader.drawable as Animatable; + + if (attrs != null) { + val attrArr = context.obtainStyledAttributes(attrs, R.styleable.LoaderView, 0, 0); + _automatic = attrArr.getBoolean(R.styleable.LoaderView_automatic, false); + attrArr.recycle(); + } else { + _automatic = false; + } + + visibility = View.GONE; + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + + if (_automatic) { + start(); + } + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + + if (_automatic) { + stop(); + } + } + + fun start() { + _animatable.start(); + visibility = View.VISIBLE; + } + + fun stop() { + _animatable.stop(); + visibility = View.GONE; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/AnyAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/AnyAdapter.kt new file mode 100644 index 00000000..310b4c7e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/AnyAdapter.kt @@ -0,0 +1,181 @@ +package com.futo.platformplayer.views.adapters + +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.Recycler +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import java.lang.reflect.Constructor + +open class BaseAnyAdapter, IT : ViewHolder> { + protected var _items: ArrayList; + protected val _holderClass: Class; + + protected val _constructor: Constructor; + + protected val _onCreate: ((T)->Unit)?; + + lateinit var adapter: RecyclerView.Adapter + protected set; + + constructor(items: ArrayList, holderClass: Class, onCreate: ((T)->Unit)? = null) : super() { + _items = items; + _holderClass = holderClass; + _constructor = _holderClass.constructors.firstOrNull { it.parameterTypes.size == 1 && it.parameterTypes[0] == ViewGroup::class.java } as Constructor? + ?: throw IllegalStateException("Viewholder [${_holderClass.name}] missing constructor (Context, ViewGroup)"); + _onCreate = onCreate; + } + constructor(holderClass: Class, onCreate: ((T)->Unit)? = null) : super() { + _items = arrayListOf(); + _holderClass = holderClass; + _constructor = _holderClass.constructors.firstOrNull { it.parameterTypes.size == 1 && it.parameterTypes[0] == ViewGroup::class.java } as Constructor? + ?: throw IllegalStateException("Viewholder [${_holderClass.name}] missing constructor (Context, ViewGroup)"); + _onCreate = onCreate; + } + + fun setData(newItems: Iterable) { + _items.clear(); + _items.addAll(newItems); + adapter.notifyDataSetChanged(); + } + fun add(item: I) { + _items.add(item); + notifyItemInserted(_items.size - 1); + } + + fun all(cb: (I)->Unit) { + for(item in _items) + cb(item); + } + + fun notifyContentChanged() { + adapter.notifyDataSetChanged(); + } + + fun notifyContentChanged(position: Int) { + adapter.notifyItemChanged(position); + } + + fun notifyItemInserted(position: Int) { + adapter.notifyItemInserted(position); + } + + fun notifyItemMoved(fromPosition: Int, toPosition: Int) { + adapter.notifyItemMoved(fromPosition, toPosition); + } + + fun notifyItemRangeInserted(positionStart: Int, itemCount: Int) { + adapter.notifyItemRangeInserted(positionStart, itemCount); + } + fun notifyItemRangeChanged(positionStart: Int, itemCount: Int) { + adapter.notifyItemRangeChanged(positionStart, itemCount); + } + fun notifyItemRangeRemoved(positionStart: Int, itemCount: Int) { + adapter.notifyItemRangeRemoved(positionStart, itemCount); + } + + fun notifyItemRangeRemoved(position: Int) { + adapter.notifyItemRemoved(position); + } + + + fun notifyContentChange(item: I) { + val index = _items.indexOf(item); + if(index >= 0) + notifyContentChanged(index); + } + + companion object { + inline fun > create(prepend: ArrayList = arrayListOf(), append: ArrayList = arrayListOf()) : AnyInsertedAdapter { + return AnyInsertedAdapter(T::class.java, prepend, append); + } + } +} + +class AnyAdapter> : BaseAnyAdapter { + + constructor(items: ArrayList, holderClass: Class, onCreate: ((T)->Unit)? = null) : super(items, holderClass, onCreate) { + adapter = Adapter(this); + } + constructor(holderClass: Class, onCreate: ((T)->Unit)? = null) : super(holderClass, onCreate) { + adapter = Adapter(this); + } + + abstract class AnyViewHolder(protected val _view: View) : ViewHolder(_view) { + abstract fun bind(i: I); + } + + companion object { + inline fun > create(list: ArrayList, noinline onCreate: ((T)->Unit)? = null) : AnyAdapter { + return AnyAdapter(list, T::class.java, onCreate); + } + inline fun > create(noinline onCreate: ((T)->Unit)? = null) : AnyAdapter { + return AnyAdapter(T::class.java, onCreate); + } + } + + private class Adapter> : RecyclerView.Adapter { + private val _parent: AnyAdapter; + + + constructor(parentAdapter: AnyAdapter) { + _parent = parentAdapter; + } + + override fun getItemCount() = _parent._items.size; + + override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): T { + val item = _parent._constructor.newInstance(viewGroup) as T; + _parent._onCreate?.invoke(item); + return item; + } + + override fun onBindViewHolder(viewHolder: T, position: Int) { + viewHolder.bind(_parent._items[position]); + } + } +} + +class AnyInsertedAdapter> : BaseAnyAdapter>{ + constructor(items: ArrayList, holderClass: Class, prepend: ArrayList = arrayListOf(), append: ArrayList = arrayListOf(), onCreate: ((T)->Unit)? = null) + : super(items, holderClass, onCreate) { + adapter = InsertedViewAdapter(prepend, append, + this::getChildCount, + this::createChild, + this::bindChild) + } + constructor(holderClass: Class, prepend: ArrayList = arrayListOf(), append: ArrayList = arrayListOf(), onCreate: ((T)->Unit)? = null) + : super(holderClass, onCreate) { + adapter = InsertedViewAdapter(prepend, append, + this::getChildCount, + this::createChild, + this::bindChild) + } + + fun getChildCount(): Int { + return _items.size; + } + + fun createChild(viewGroup: ViewGroup, viewType: Int): T { + val view = _constructor.newInstance(viewGroup) as T; + _onCreate?.invoke(view); + return view; + } + + fun bindChild(holder: T, pos: Int) { + holder.bind(_items[pos]); + } + + companion object { + inline fun > create(list: ArrayList, prepend: ArrayList = arrayListOf(), append: ArrayList = arrayListOf(), noinline onCreate: ((T)->Unit)? = null) : AnyInsertedAdapter { + return AnyInsertedAdapter(list, T::class.java, prepend, append, onCreate); + } + + inline fun > create(prepend: ArrayList = arrayListOf(), noinline onCreate: ((T)->Unit)? = null) : AnyInsertedAdapter { + return AnyInsertedAdapter(T::class.java, prepend, arrayListOf(), onCreate); + } + inline fun > create(prepend: ArrayList = arrayListOf(), append: ArrayList = arrayListOf(), noinline onCreate: ((T)->Unit)? = null) : AnyInsertedAdapter { + return AnyInsertedAdapter(T::class.java, prepend, append, onCreate); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt new file mode 100644 index 00000000..7fe21e19 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt @@ -0,0 +1,65 @@ +package com.futo.platformplayer.views.adapters + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Lifecycle +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.fragment.channel.tab.* + +class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifecycle) : FragmentStateAdapter(fragmentManager, lifecycle) { + private val _cache: Array = arrayOfNulls(4); + + val onContentUrlClicked = Event2(); + val onContentClicked = Event2(); + val onChannelClicked = Event1(); + val onAddToClicked = Event1(); + + override fun getItemCount(): Int { + return _cache.size; + } + inline fun getFragment(): T { + + //TODO: I have a feeling this can somehow be synced with createFragment so only 1 mapping exists (without a Map<>) + if(T::class == ChannelContentsFragment::class) + return createFragment(0) as T; + else if(T::class == ChannelListFragment::class) + return createFragment(1) as T; + //else if(T::class == ChannelStoreFragment::class) + // return createFragment(2) as T; + else if(T::class == ChannelMonetizationFragment::class) + return createFragment(2) as T; + else if(T::class == ChannelAboutFragment::class) + return createFragment(3) as T; + else + throw NotImplementedError("Implement other types"); + } + + override fun createFragment(position: Int): Fragment { + val cachedFragment = _cache[position]; + if (cachedFragment != null) { + return cachedFragment; + } + + val fragment = when (position) { + 0 -> ChannelContentsFragment.newInstance().apply { + onContentClicked.subscribe(this@ChannelViewPagerAdapter.onContentClicked::emit); + onContentUrlClicked.subscribe(this@ChannelViewPagerAdapter.onContentUrlClicked::emit); + onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit); + onAddToClicked.subscribe(this@ChannelViewPagerAdapter.onAddToClicked::emit); + }; + 1 -> ChannelListFragment.newInstance().apply { onClickChannel.subscribe(onChannelClicked::emit) }; + //2 -> ChannelStoreFragment.newInstance(); + 2 -> ChannelMonetizationFragment.newInstance(); + 3 -> ChannelAboutFragment.newInstance(); + else -> throw IllegalStateException("Invalid tab position $position") + }; + + _cache[position]= fragment; + return fragment; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/CommentViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/CommentViewHolder.kt new file mode 100644 index 00000000..a946a937 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/CommentViewHolder.kt @@ -0,0 +1,161 @@ +package com.futo.platformplayer.views.adapters + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.futo.platformplayer.* +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.pills.PillButton +import com.futo.platformplayer.views.pills.PillRatingLikesDislikes +import com.futo.polycentric.core.Opinion +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class CommentViewHolder : ViewHolder { + private val _creatorThumbnail: CreatorThumbnail; + private val _textAuthor: TextView; + private val _textMetadata: TextView; + private val _textBody: TextView; + private val _imageLikeIcon: ImageView; + private val _textLikes: TextView; + private val _imageDislikeIcon: ImageView; + private val _textDislikes: TextView; + private val _buttonReplies: PillButton; + private val _layoutRating: LinearLayout; + private val _pillRatingLikesDislikes: PillRatingLikesDislikes; + + var onClick = Event1(); + var comment: IPlatformComment? = null + private set; + + constructor(viewGroup: ViewGroup) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_comment, viewGroup, false)) { + _creatorThumbnail = itemView.findViewById(R.id.image_thumbnail); + _textAuthor = itemView.findViewById(R.id.text_author); + _textMetadata = itemView.findViewById(R.id.text_metadata); + _textBody = itemView.findViewById(R.id.text_body); + _imageLikeIcon = itemView.findViewById(R.id.image_like_icon); + _textLikes = itemView.findViewById(R.id.text_likes); + _imageDislikeIcon = itemView.findViewById(R.id.image_dislike_icon); + _textDislikes = itemView.findViewById(R.id.text_dislikes); + _buttonReplies = itemView.findViewById(R.id.button_replies); + _layoutRating = itemView.findViewById(R.id.layout_rating); + _pillRatingLikesDislikes = itemView.findViewById(R.id.rating); + + _pillRatingLikesDislikes.onLikeDislikeUpdated.subscribe { processHandle, hasLiked, hasDisliked -> + val c = comment + if (c !is PolycentricPlatformComment) { + throw Exception("Not implemented for non polycentric comments") + } + + if (hasLiked) { + processHandle.opinion(c.reference, Opinion.like); + } else if (hasDisliked) { + processHandle.opinion(c.reference, Opinion.dislike); + } else { + processHandle.opinion(c.reference, Opinion.neutral); + } + + StateApp.instance.scopeGetter().launch(Dispatchers.IO) { + try { + processHandle.fullyBackfillServers(); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to backfill servers.", e) + } + } + + StatePolycentric.instance.updateLikeMap(c.reference, hasLiked, hasDisliked) + }; + + _buttonReplies.onClick.subscribe { + val c = comment ?: return@subscribe; + onClick.emit(c); + } + + _textBody.setPlatformPlayerLinkMovementMethod(viewGroup.context); + } + + fun bind(comment: IPlatformComment, readonly: Boolean) { + _creatorThumbnail.setThumbnail(comment.author.thumbnail, false); + _textAuthor.text = comment.author.name; + + val date = comment.date; + if (date != null) { + _textMetadata.visibility = View.VISIBLE; + _textMetadata.text = " • ${date.toHumanNowDiffString()} ago"; + } else { + _textMetadata.visibility = View.GONE; + } + + _textBody.text = comment.message.fixHtmlLinks(); + + if (readonly) { + _layoutRating.visibility = View.VISIBLE; + _pillRatingLikesDislikes.visibility = View.GONE; + + when (comment.rating) { + is RatingLikeDislikes -> { + val r = comment.rating as RatingLikeDislikes; + _textLikes.visibility = View.VISIBLE; + _imageLikeIcon.visibility = View.VISIBLE; + _textLikes.text = r.likes.toHumanNumber(); + + _imageDislikeIcon.visibility = View.VISIBLE; + _textDislikes.visibility = View.VISIBLE; + _textDislikes.text = r.dislikes.toHumanNumber(); + } + is RatingLikes -> { + val r = comment.rating as RatingLikes; + _textLikes.visibility = View.VISIBLE; + _imageLikeIcon.visibility = View.VISIBLE; + _textLikes.text = r.likes.toHumanNumber(); + + _imageDislikeIcon.visibility = View.GONE; + _textDislikes.visibility = View.GONE; + } + else -> { + _textLikes.visibility = View.GONE; + _imageLikeIcon.visibility = View.GONE; + _imageDislikeIcon.visibility = View.GONE; + _textDislikes.visibility = View.GONE; + } + } + } else { + _layoutRating.visibility = View.GONE; + _pillRatingLikesDislikes.visibility = View.VISIBLE; + + if (comment is PolycentricPlatformComment) { + val hasLiked = StatePolycentric.instance.hasLiked(comment.reference); + val hasDisliked = StatePolycentric.instance.hasDisliked(comment.reference); + _pillRatingLikesDislikes.setRating(comment.rating, hasLiked, hasDisliked); + } else { + _pillRatingLikesDislikes.setRating(comment.rating); + } + } + + val replies = comment.replyCount ?: 0; + if (!readonly || replies > 0) { + _buttonReplies.visibility = View.VISIBLE; + _buttonReplies.text.text = "$replies replies"; + } else { + _buttonReplies.visibility = View.GONE; + } + + this.comment = comment; + } + + companion object { + private const val TAG = "CommentViewHolder"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/ContentPreviewViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/ContentPreviewViewHolder.kt new file mode 100644 index 00000000..67e946e0 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/ContentPreviewViewHolder.kt @@ -0,0 +1,18 @@ +package com.futo.platformplayer.views.adapters + +import android.view.View +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails + +abstract class ContentPreviewViewHolder(itemView: View) : ViewHolder(itemView) { + abstract val content: IPlatformContent?; + + abstract fun bind(content: IPlatformContent); + + abstract fun preview(details: IPlatformContentDetails?, paused: Boolean); + abstract fun stopPreview(); + abstract fun pausePreview(); + abstract fun resumePreview(); + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceAdapter.kt new file mode 100644 index 00000000..e318c3a7 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceAdapter.kt @@ -0,0 +1,35 @@ +package com.futo.platformplayer.views.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.R +import com.futo.platformplayer.casting.CastingDevice +import com.futo.platformplayer.casting.StateCasting +import com.futo.platformplayer.constructs.Event1 + +class DeviceAdapter : RecyclerView.Adapter { + private val _devices: ArrayList; + private val _isRememberedDevice: Boolean; + + var onRemove = Event1(); + + constructor(devices: ArrayList, isRememberedDevice: Boolean) : super() { + _devices = devices; + _isRememberedDevice = isRememberedDevice; + } + + override fun getItemCount() = _devices.size; + + override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): DeviceViewHolder { + val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.list_device, viewGroup, false); + val holder = DeviceViewHolder(view); + holder.setIsRememberedDevice(_isRememberedDevice); + holder.onRemove.subscribe { d -> onRemove.emit(d); }; + return holder; + } + + override fun onBindViewHolder(viewHolder: DeviceViewHolder, position: Int) { + viewHolder.bind(_devices[position]); + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt new file mode 100644 index 00000000..6eddcc98 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt @@ -0,0 +1,133 @@ +package com.futo.platformplayer.views.adapters + +import android.graphics.drawable.Animatable +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.futo.platformplayer.R +import com.futo.platformplayer.casting.* +import com.futo.platformplayer.constructs.Event1 + +class DeviceViewHolder : ViewHolder { + private val _imageDevice: ImageView; + private val _textName: TextView; + private val _textType: TextView; + private val _textNotReady: TextView; + private val _buttonDisconnect: LinearLayout; + private val _buttonConnect: LinearLayout; + private val _buttonRemove: LinearLayout; + private val _imageLoader: ImageView; + private var _animatableLoader: Animatable? = null; + private var _isRememberedDevice: Boolean = false; + + var device: CastingDevice? = null + private set + + var onRemove = Event1(); + + constructor(view: View) : super(view) { + _imageDevice = view.findViewById(R.id.image_device); + _textName = view.findViewById(R.id.text_name); + _textType = view.findViewById(R.id.text_type); + _textNotReady = view.findViewById(R.id.text_not_ready); + _buttonDisconnect = view.findViewById(R.id.button_disconnect); + _buttonConnect = view.findViewById(R.id.button_connect); + _buttonRemove = view.findViewById(R.id.button_remove); + _imageLoader = view.findViewById(R.id.image_loader); + + val d = _imageLoader.drawable; + if (d is Animatable) { + _animatableLoader = d; + } + + _buttonDisconnect.setOnClickListener { + StateCasting.instance.activeDevice?.stopCasting(); + updateButton(); + }; + + _buttonConnect.setOnClickListener { + val d = device ?: return@setOnClickListener; + StateCasting.instance.activeDevice?.stopCasting(); + StateCasting.instance.connectDevice(d); + updateButton(); + }; + + _buttonRemove.setOnClickListener { + val d = device ?: return@setOnClickListener; + onRemove.emit(d); + }; + + setIsRememberedDevice(false); + } + + fun setIsRememberedDevice(isRememberedDevice: Boolean) { + _isRememberedDevice = isRememberedDevice; + _buttonRemove.visibility = if (isRememberedDevice) View.VISIBLE else View.GONE; + } + + fun bind(d: CastingDevice) { + if (d is ChromecastCastingDevice) { + _imageDevice.setImageResource(R.drawable.ic_chromecast); + _textType.text = "Chromecast"; + } else if (d is AirPlayCastingDevice) { + _imageDevice.setImageResource(R.drawable.ic_airplay); + _textType.text = "AirPlay"; + } else if (d is FastCastCastingDevice) { + _imageDevice.setImageResource(R.drawable.ic_fc); + _textType.text = "FastCast"; + } + + _textName.text = d.name; + device = d; + updateButton(); + } + + private fun updateButton() { + val d = device ?: return; + + if (!d.isReady) { + _buttonConnect.visibility = View.GONE; + _buttonDisconnect.visibility = View.GONE; + _imageLoader.visibility = View.GONE; + _textNotReady.visibility = View.VISIBLE; + return; + } + + _textNotReady.visibility = View.GONE; + + val dev = StateCasting.instance.activeDevice; + if (dev == d) { + if (dev.connectionState == CastConnectionState.CONNECTED) { + _buttonConnect.visibility = View.GONE; + _buttonDisconnect.visibility = View.VISIBLE; + _imageLoader.visibility = View.GONE; + _textNotReady.visibility = View.GONE; + } else { + _buttonConnect.visibility = View.GONE; + _buttonDisconnect.visibility = View.VISIBLE; + _imageLoader.visibility = View.VISIBLE; + _textNotReady.visibility = View.GONE; + } + } else { + if (d.isReady) { + _buttonConnect.visibility = View.VISIBLE; + _buttonDisconnect.visibility = View.GONE; + _imageLoader.visibility = View.GONE; + _textNotReady.visibility = View.GONE; + } else { + _buttonConnect.visibility = View.GONE; + _buttonDisconnect.visibility = View.GONE; + _imageLoader.visibility = View.GONE; + _textNotReady.visibility = View.VISIBLE; + } + } + + if (_imageLoader.visibility == View.VISIBLE) { + _animatableLoader?.start(); + } else { + _animatableLoader?.stop(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DisabledSourceAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DisabledSourceAdapter.kt new file mode 100644 index 00000000..4ce52646 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DisabledSourceAdapter.kt @@ -0,0 +1,42 @@ +package com.futo.platformplayer.views.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.constructs.Event1 + +class DisabledSourceAdapter : RecyclerView.Adapter { + private val _sources: MutableList; + + var onClick = Event1(); + var onAdd = Event1(); + + constructor(sources: MutableList) : super() { + _sources = sources; + } + + override fun getItemCount() = _sources.size + + override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): DisabledSourceViewHolder { + val holder = DisabledSourceViewHolder(viewGroup); + holder.onAdd.subscribe { + val source = holder.source; + if (source != null) { + onAdd.emit(source); + } + } + holder.onClick.subscribe { + val source = holder.source; + if (source != null) { + onClick.emit(source); + } + }; + return holder; + } + + override fun onBindViewHolder(viewHolder: DisabledSourceViewHolder, position: Int) { + viewHolder.bind(_sources[position]) + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DisabledSourceView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DisabledSourceView.kt new file mode 100644 index 00000000..63348033 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DisabledSourceView.kt @@ -0,0 +1,46 @@ +package com.futo.platformplayer.views.adapters + +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 + +class DisabledSourceView : LinearLayout { + private val _root: LinearLayout; + private val _imageSource: ImageView; + private val _textSource: TextView; + private val _textSourceSubtitle: TextView; + + private val _buttonAdd: LinearLayout; + + val onClick = Event0(); + val onAdd = Event1(); + val source: IPlatformClient; + + constructor(context: Context, client: IPlatformClient) : super(context) { + inflate(context, R.layout.list_source_disabled, this); + source = client; + + _root = findViewById(R.id.root); + _imageSource = findViewById(R.id.image_source); + _textSource = findViewById(R.id.text_source); + _textSourceSubtitle = findViewById(R.id.text_source_subtitle); + _buttonAdd = findViewById(R.id.button_add); + + client.icon?.setImageView(_imageSource); + + _textSource.text = client.name; + _textSourceSubtitle.text = "Tap to open"; + + _buttonAdd.setOnClickListener { onAdd.emit(source) } + _root.setOnClickListener { onClick.emit(); }; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DisabledSourceViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DisabledSourceViewHolder.kt new file mode 100644 index 00000000..c721b553 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DisabledSourceViewHolder.kt @@ -0,0 +1,44 @@ +package com.futo.platformplayer.views.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.constructs.Event0 + +class DisabledSourceViewHolder : ViewHolder { + private val _imageSource: ImageView; + private val _textSource: TextView; + private val _textSourceSubtitle: TextView; + + private val _buttonAdd: LinearLayout; + + var onClick = Event0(); + var onAdd = Event0(); + var source: IPlatformClient? = null + private set + + constructor(viewGroup: ViewGroup) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_source_disabled, viewGroup, false)) { + _imageSource = itemView.findViewById(R.id.image_source); + _textSource = itemView.findViewById(R.id.text_source); + _textSourceSubtitle = itemView.findViewById(R.id.text_source_subtitle); + _buttonAdd = itemView.findViewById(R.id.button_add); + + val root = itemView.findViewById(R.id.root); + _buttonAdd.setOnClickListener { onAdd.emit() } + root.setOnClickListener { onClick.emit(); }; + } + + fun bind(client: IPlatformClient) { + client.icon?.setImageView(_imageSource); + + _textSource.text = client.name; + _textSourceSubtitle.text = "Tap to open"; + source = client; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/EmptyPreviewViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/EmptyPreviewViewHolder.kt new file mode 100644 index 00000000..bfd86f56 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/EmptyPreviewViewHolder.kt @@ -0,0 +1,23 @@ +package com.futo.platformplayer.views.adapters + +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails + +class EmptyPreviewViewHolder(viewGroup: ViewGroup) : ContentPreviewViewHolder(View(viewGroup.context)) { + override val content: IPlatformContent? + get() = null; + + override fun bind(content: IPlatformContent) {} + + override fun preview(details: IPlatformContentDetails?, paused: Boolean) {} + + override fun stopPreview() {} + + override fun pausePreview() {} + + override fun resumePreview() {} + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/EnabledSourceAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/EnabledSourceAdapter.kt new file mode 100644 index 00000000..6426e90a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/EnabledSourceAdapter.kt @@ -0,0 +1,40 @@ +package com.futo.platformplayer.views.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 + +class EnabledSourceAdapter : RecyclerView.Adapter { + private val _sources: MutableList; + private val _touchHelper: ItemTouchHelper; + + var onRemove = Event1(); + var onClick = Event1(); + var canRemove: Boolean = false; + + constructor(sources: MutableList, touchHelper: ItemTouchHelper) : super() { + _sources = sources; + _touchHelper = touchHelper; + } + + override fun getItemCount() = _sources.size; + + override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): EnabledSourceViewHolder { + val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.list_source_enabled, viewGroup, false) + val holder = EnabledSourceViewHolder(view, _touchHelper); + holder.onRemove.subscribe { onRemove.emit(it); }; + holder.onClick.subscribe { onClick.emit(it); } + holder.setCanRemove(canRemove); + return holder; + } + + override fun onBindViewHolder(viewHolder: EnabledSourceViewHolder, position: Int) { + viewHolder.setCanRemove(canRemove); + viewHolder.bind(_sources[position]) + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/EnabledSourceViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/EnabledSourceViewHolder.kt new file mode 100644 index 00000000..62aa12dc --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/EnabledSourceViewHolder.kt @@ -0,0 +1,63 @@ +package com.futo.platformplayer.views.adapters + +import android.view.MotionEvent +import android.view.View +import android.view.View.OnTouchListener +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.constructs.Event1 + +class EnabledSourceViewHolder : ViewHolder { + private val _imageSource: ImageView; + private val _textSource: TextView; + private val _textSourceSubtitle: TextView; + private val _imageDragDrop: ImageView; + private val _buttonRemove: LinearLayout; + + var onRemove = Event1(); + var onClick = Event1(); + var source: IPlatformClient? = null + private set + + constructor(view: View, touchHelper: ItemTouchHelper) : super(view) { + _imageSource = view.findViewById(R.id.image_source); + _textSource = view.findViewById(R.id.text_source); + _textSourceSubtitle = itemView.findViewById(R.id.text_source_subtitle); + _imageDragDrop = view.findViewById(R.id.image_drag_drop); + _buttonRemove = view.findViewById(R.id.button_remove); + val root = view.findViewById(R.id.root); + + root.setOnClickListener { + source?.let { onClick.emit(it); }; + }; + + _imageDragDrop.setOnTouchListener(OnTouchListener { v, event -> + if (event.action == MotionEvent.ACTION_DOWN) { + touchHelper.startDrag(this); + } + false + }); + + _buttonRemove.setOnClickListener { + source?.let { onRemove.emit(it); }; + }; + } + + fun setCanRemove(canRemove: Boolean) { + _buttonRemove.visibility = if (canRemove) { View.VISIBLE } else { View.GONE }; + } + + fun bind(client: IPlatformClient) { + client.icon?.setImageView(_imageSource); + + _textSource.text = client.name; + _textSourceSubtitle.text = "Tap to open"; + source = client + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListAdapter.kt new file mode 100644 index 00000000..72d81241 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListAdapter.kt @@ -0,0 +1,109 @@ +package com.futo.platformplayer.views.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.* +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.models.HistoryVideo +import com.futo.platformplayer.states.StatePlaylists + +class HistoryListAdapter : RecyclerView.Adapter { + private lateinit var _filteredVideos: MutableList; + + val onClick = Event1(); + private var _query: String = ""; + + constructor() : super() { + updateFilteredVideos(); + + StatePlaylists.instance.onHistoricVideoChanged.subscribe(this) { video, position -> + val index = _filteredVideos.indexOfFirst { v -> v.video.url == video.url }; + if (index == -1) { + return@subscribe; + } + + _filteredVideos[index].position = position; + if (index < _filteredVideos.size - 2) { + notifyItemRangeChanged(index, 2); + } else { + notifyItemChanged(index); + } + }; + } + + fun setQuery(query: String) { + _query = query; + updateFilteredVideos(); + } + + fun updateFilteredVideos() { + val videos = StatePlaylists.instance.getHistory(); + if (_query.isBlank()) { + _filteredVideos = videos.toMutableList(); + } else { + _filteredVideos = videos.filter { v -> v.video.name.lowercase().contains(_query); }.toMutableList(); + } + + notifyDataSetChanged(); + } + + fun cleanup() { + StatePlaylists.instance.onHistoricVideoChanged.remove(this); + } + + override fun getItemCount() = _filteredVideos.size; + + override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): HistoryListViewHolder { + val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.list_history, viewGroup, false); + val holder = HistoryListViewHolder(view); + + holder.onRemove.subscribe { v -> + val videos = _filteredVideos; + val index = videos.indexOf(v); + if (index == -1) { + return@subscribe; + } + + StatePlaylists.instance.removeHistory(v.video.url); + _filteredVideos.removeAt(index); + notifyItemRemoved(index); + }; + holder.onClick.subscribe { v -> + val videos = _filteredVideos; + val index = videos.indexOf(v); + if (index == -1) { + return@subscribe; + } + + _filteredVideos.removeAt(index); + _filteredVideos.add(0, v); + + notifyItemMoved(index, 0); + notifyItemRangeChanged(0, 2); + onClick.emit(v); + }; + + return holder; + } + + override fun onBindViewHolder(viewHolder: HistoryListViewHolder, position: Int) { + val videos = _filteredVideos; + var watchTime: String? = null; + if (position == 0) { + watchTime = videos[position].date.toHumanNowDiffStringMinDay(); + } else { + val previousWatchTime = videos[position - 1].date.toHumanNowDiffStringMinDay(); + val currentWatchTime = videos[position].date.toHumanNowDiffStringMinDay(); + if (previousWatchTime != currentWatchTime) { + watchTime = currentWatchTime; + } + } + + viewHolder.bind(videos[position], watchTime); + } + + companion object { + val TAG = "HistoryListAdapter"; + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListViewHolder.kt new file mode 100644 index 00000000..b990dcc7 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListViewHolder.kt @@ -0,0 +1,103 @@ +package com.futo.platformplayer.views.adapters + +import android.view.View +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.images.GlideHelper.Companion.crossfade +import com.futo.platformplayer.models.HistoryVideo +import com.futo.platformplayer.toHumanNumber +import com.futo.platformplayer.toHumanTime +import com.futo.platformplayer.views.others.ProgressBar + +class HistoryListViewHolder : ViewHolder { + private val _root: ConstraintLayout; + private val _imageThumbnail: ImageView; + private val _textName: TextView; + private val _textAuthor: TextView; + private val _textMetadata: TextView; + private val _textVideoDuration: TextView; + private val _containerDuration: LinearLayout; + private val _containerLive: LinearLayout; + private val _imageRemove: ImageButton; + private val _textHeader: TextView; + private val _timeBar: ProgressBar; + + var video: HistoryVideo? = null + private set; + + val onClick = Event1(); + val onRemove = Event1(); + + constructor(view: View) : super(view) { + _root = view.findViewById(R.id.root); + _imageThumbnail = view.findViewById(R.id.image_video_thumbnail); + _imageThumbnail?.clipToOutline = true; + _textName = view.findViewById(R.id.text_video_name); + _textAuthor = view.findViewById(R.id.text_author); + _textMetadata = view.findViewById(R.id.text_video_metadata); + _textVideoDuration = view.findViewById(R.id.thumbnail_duration); + _containerDuration = view.findViewById(R.id.thumbnail_duration_container); + _containerLive = view.findViewById(R.id.thumbnail_live_container); + _imageRemove = view.findViewById(R.id.image_trash); + _textHeader = view.findViewById(R.id.text_header); + _timeBar = view.findViewById(R.id.time_bar); + + _root.setOnClickListener { + val v = video ?: return@setOnClickListener; + onClick.emit(v); + }; + + _imageRemove?.setOnClickListener { + val v = video ?: return@setOnClickListener; + onRemove.emit(v); + }; + } + + fun bind(v: HistoryVideo, watchTime: String?) { + Glide.with(_imageThumbnail) + .load(v.video.thumbnails.getLQThumbnail()) + .placeholder(R.drawable.placeholder_video_thumbnail) + .crossfade() + .into(_imageThumbnail); + + _textName.text = v.video.name; + _textAuthor.text = v.video.author.name; + _textVideoDuration.text = v.video.duration.toHumanTime(false); + + if(v.video.isLive) { + _containerDuration.visibility = View.GONE; + _containerLive.visibility = View.VISIBLE; + } + else { + _containerLive.visibility = View.GONE; + _containerDuration.visibility = View.VISIBLE; + } + + if (watchTime != null) { + _textHeader.text = watchTime; + _textHeader.visibility = View.VISIBLE; + } else { + _textHeader.visibility = View.GONE; + } + + var metadata = ""; + if (v.video.viewCount > 0) + metadata += "${v.video.viewCount.toHumanNumber()} views"; + + _textMetadata.text = metadata; + + _timeBar.progress = v.position.toFloat() / v.video.duration.toFloat(); + video = v; + } + + companion object { + val TAG = "HistoryListViewHolder"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/InsertedViewAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/InsertedViewAdapter.kt new file mode 100644 index 00000000..3cd05372 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/InsertedViewAdapter.kt @@ -0,0 +1,70 @@ +package com.futo.platformplayer.views.adapters + +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder + +open class InsertedViewAdapter : RecyclerView.Adapter> where TViewHolder : ViewHolder { + val viewsToPrepend: ArrayList; + var viewsToAppend: ArrayList; + protected var childViewHolderFactory: ((viewGroup: ViewGroup, viewType: Int) -> TViewHolder)? = null; + protected var childViewHolderBinder: ((viewHolder: TViewHolder, position: Int) -> Unit)? = null; + protected var childCountGetter: (() -> Int)? = null; + + constructor(viewsToPrepend: ArrayList, + viewsToAppend: ArrayList, + childCountGetter: () -> Int, + childViewHolderFactory: (viewGroup: ViewGroup, viewType: Int) -> TViewHolder, + childViewHolderBinder: (viewHolder: TViewHolder, position: Int) -> Unit) : super() + { + this.viewsToPrepend = viewsToPrepend; + this.viewsToAppend = viewsToAppend; + this.childCountGetter = childCountGetter; + this.childViewHolderFactory = childViewHolderFactory; + this.childViewHolderBinder = childViewHolderBinder; + } + + protected constructor(viewsToPrepend: ArrayList, viewsToAppend: ArrayList) { + this.viewsToPrepend = viewsToPrepend; + this.viewsToAppend = viewsToAppend; + } + + open fun getChildCount(): Int = childCountGetter!!(); + override fun getItemCount() = viewsToPrepend.size + getChildCount() + viewsToAppend.size; + + open fun createChild(viewGroup: ViewGroup, viewType: Int): TViewHolder = childViewHolderFactory!!.invoke(viewGroup, viewType); + override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): InsertedViewHolder { + return InsertedViewHolder(viewGroup.context, createChild(viewGroup, viewType)); + } + + open fun bindChild(holder: TViewHolder, pos: Int) = childViewHolderBinder!!(holder, pos); + override fun onBindViewHolder(viewHolder: InsertedViewHolder, position: Int) { + if (position < viewsToPrepend.size) { + viewHolder.bindView(viewsToPrepend[position]); + return; + } + + val childCount = getChildCount(); + val originalAdapterPosition = position - viewsToPrepend.size; + if (originalAdapterPosition < childCount) { + bindChild(viewHolder.childViewHolder, originalAdapterPosition); + viewHolder.bindChild(); + return; + } + + val viewsToAppendIndex = position - childCount - viewsToPrepend.size; + if (viewsToAppendIndex < viewsToAppend.size) { + viewHolder.bindView(viewsToAppend[viewsToAppendIndex]); + return; + } + } + + fun childToParentPosition(position: Int): Int { + return position + viewsToPrepend.size; + } + + fun parentToChildPosition(position: Int): Int { + return position - viewsToPrepend.size; + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/InsertedViewAdapterWithLoader.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/InsertedViewAdapterWithLoader.kt new file mode 100644 index 00000000..de012903 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/InsertedViewAdapterWithLoader.kt @@ -0,0 +1,91 @@ +package com.futo.platformplayer.views.adapters + +import android.content.Context +import android.graphics.drawable.Animatable +import android.util.TypedValue +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.futo.platformplayer.R + +open class InsertedViewAdapterWithLoader : InsertedViewAdapter where TViewHolder : ViewHolder { + private var _loaderView: ImageView? = null; + private var _loading = false; + + constructor( + context: Context, + viewsToPrepend: ArrayList, + viewsToAppend: ArrayList, + childCountGetter: () -> Int, + childViewHolderFactory: (viewGroup: ViewGroup, viewType: Int) -> TViewHolder, + childViewHolderBinder: (viewHolder: TViewHolder, position: Int) -> Unit) : super( + viewsToPrepend = viewsToPrepend, + viewsToAppend = viewsToAppend, + childCountGetter = childCountGetter, + childViewHolderFactory = childViewHolderFactory, + childViewHolderBinder = childViewHolderBinder + ) + { + val loaderView = createLoaderView(context); + this.viewsToAppend.add(loaderView); + _loaderView = loaderView; + } + + protected constructor( + context: Context, + viewsToPrepend: ArrayList, + viewsToAppend: ArrayList) : super( + viewsToPrepend = viewsToPrepend, + viewsToAppend = viewsToAppend) + { + val loaderView = createLoaderView(context); + this.viewsToAppend.add(loaderView); + _loaderView = loaderView; + } + + fun setLoading(loading: Boolean) { + if (_loading == loading) { + return; + } + + _loading = loading; + + if (loading) { + _loaderView?.let { + it.visibility = View.VISIBLE; + (it.drawable as Animatable?)?.start(); + }; + } else { + _loaderView?.let { + it.visibility = View.INVISIBLE; + (it.drawable as Animatable?)?.stop(); + }; + } + } + + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + super.onDetachedFromRecyclerView(recyclerView); + (_loaderView?.drawable as Animatable?)?.stop(); + } + + companion object { + private fun createLoaderView(context: Context): ImageView { + val loaderView = ImageView(context); + loaderView.visibility = View.GONE; + loaderView.contentDescription = context.resources.getString(R.string.loading); + loaderView.setImageResource(R.drawable.ic_loader_animated); + + val lp = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 50.0f, context.resources.displayMetrics).toInt()); + lp.marginStart = 10; + lp.marginEnd = 10; + lp.gravity = Gravity.CENTER; + loaderView.layoutParams = lp; + + return loaderView; + } + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/InsertedViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/InsertedViewHolder.kt new file mode 100644 index 00000000..64ffcc4b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/InsertedViewHolder.kt @@ -0,0 +1,46 @@ +package com.futo.platformplayer.views.adapters + +import android.content.Context +import android.util.Log +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.recyclerview.widget.RecyclerView.ViewHolder + +class InsertedViewHolder : ViewHolder where TViewHolder : ViewHolder { + private val _container: FrameLayout; + private var _boundView: View? = null; + + val childViewHolder: TViewHolder; + + constructor(context: Context, childViewHolder: TViewHolder) : super(FrameLayout(context)) { + _container = itemView as FrameLayout; + this.childViewHolder = childViewHolder; + + _container.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT); + _container.addView(childViewHolder.itemView); + } + + fun bindView(view: View) { + _boundView?.let { _container.removeView(it); } + childViewHolder.itemView.visibility = View.GONE; + + val parent = view.parent; + if (parent != null && parent is ViewGroup) { + parent.removeView(view); + } + + _container.addView(view); + _boundView = view; + } + + fun bindChild() { + val boundView = _boundView; + if (boundView != null) { + _container.removeView(boundView); + _boundView = null; + } + + childViewHolder.itemView.visibility = View.VISIBLE; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/ItemMoveCallback.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/ItemMoveCallback.kt new file mode 100644 index 00000000..4b34d353 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/ItemMoveCallback.kt @@ -0,0 +1,47 @@ +package com.futo.platformplayer.views.adapters + +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event2 + +class ItemMoveCallback : ItemTouchHelper.Callback { + var onRowMoved = Event2(); + var onRowSelected = Event1(); + var onRowClear = Event1(); + + constructor() : super() { } + + override fun isLongPressDragEnabled(): Boolean { return true; } + override fun isItemViewSwipeEnabled(): Boolean { return false; } + + override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { + val dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN; + return makeMovementFlags(dragFlags, 0); + } + + override fun onMove(recyclerView: RecyclerView, viewHolder: ViewHolder, target: ViewHolder): Boolean { + onRowMoved.emit(viewHolder.absoluteAdapterPosition, target.absoluteAdapterPosition); + return true; + } + + override fun onSelectedChanged(viewHolder: ViewHolder?, actionState: Int) { + if (actionState != ItemTouchHelper.ACTION_STATE_IDLE) { + if (viewHolder != null) { + onRowSelected.emit(viewHolder); + } + } + + super.onSelectedChanged(viewHolder, actionState); + } + + override fun clearView(recyclerView: RecyclerView, viewHolder: ViewHolder) { + super.clearView(recyclerView, viewHolder); + onRowClear.emit(viewHolder); + } + + override fun onSwiped(viewHolder: ViewHolder, direction: Int) { + + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt new file mode 100644 index 00000000..10335eaf --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt @@ -0,0 +1,169 @@ +package com.futo.platformplayer.views.adapters + +import android.animation.ObjectAnimator +import android.content.Context +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.bumptech.glide.Glide +import com.futo.platformplayer.* +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.images.GlideHelper.Companion.crossfade +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.polycentric.PolycentricCache +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.platform.PlatformIndicator + + +open class PlaylistView : LinearLayout { + protected val _feedStyle : FeedStyle; + + protected val _imageThumbnail: ImageView + protected val _imageChannel: ImageView? + protected val _creatorThumbnail: CreatorThumbnail? + protected val _imageNeopassChannel: ImageView?; + protected val _platformIndicator: PlatformIndicator; + protected val _textPlaylistName: TextView + protected val _textVideoCount: TextView + protected val _textPlaylistItems: TextView + protected val _textChannelName: TextView + protected var _neopassAnimator: ObjectAnimator? = null; + + private val _taskLoadValidClaims = TaskHandler(StateApp.instance.scopeGetter, + { PolycentricCache.instance.getValidClaimsAsync(it).await() }) + .success { it -> updateClaimsLayout(it, animate = true) } + .exception { + Logger.w(TAG, "Failed to load claims.", it); + }; + + val onPlaylistClicked = Event1(); + val onChannelClicked = Event1(); + + var currentPlaylist: IPlatformPlaylist? = null + private set + + val content: IPlatformContent? get() = currentPlaylist; + + constructor(context: Context, feedStyle : FeedStyle) : super(context) { + inflate(feedStyle); + _feedStyle = feedStyle; + + _imageThumbnail = findViewById(R.id.image_thumbnail); + _imageChannel = findViewById(R.id.image_channel_thumbnail); + _creatorThumbnail = findViewById(R.id.creator_thumbnail); + _platformIndicator = findViewById(R.id.thumbnail_platform); + _textPlaylistName = findViewById(R.id.text_playlist_name); + _textVideoCount = findViewById(R.id.text_video_count); + _textChannelName = findViewById(R.id.text_channel_name); + _textPlaylistItems = findViewById(R.id.text_playlist_items); + _imageNeopassChannel = findViewById(R.id.image_neopass_channel); + + setOnClickListener { onOpenClicked() }; + _imageChannel?.setOnClickListener { currentPlaylist?.let { onChannelClicked.emit(it.author) } }; + _textChannelName.setOnClickListener { currentPlaylist?.let { onChannelClicked.emit(it.author) } }; + } + + protected open fun inflate(feedStyle: FeedStyle) { + inflate(context, when(feedStyle) { + FeedStyle.PREVIEW -> R.layout.list_playlist_feed_preview + else -> R.layout.list_playlist_feed + }, this) + } + + protected open fun onOpenClicked() { + currentPlaylist?.let { + onPlaylistClicked.emit(it); + } + } + + + open fun bind(content: IPlatformContent) { + _taskLoadValidClaims.cancel(); + + if (content.author.id.claimType > 0) { + val cachedClaims = PolycentricCache.instance.getCachedValidClaims(content.author.id); + if (cachedClaims != null) { + updateClaimsLayout(cachedClaims, animate = false); + } else { + updateClaimsLayout(null, animate = false); + _taskLoadValidClaims.run(content.author.id); + } + } else { + updateClaimsLayout(null, animate = false); + } + + isClickable = true; + + _imageChannel?.let { + if (content.author.thumbnail != null) + Glide.with(it) + .load(content.author.thumbnail) + .placeholder(R.drawable.placeholder_channel_thumbnail) + .into(it) + else + Glide.with(it).load(R.drawable.placeholder_channel_thumbnail).into(it); + }; + + _imageChannel?.clipToOutline = true; + + _textPlaylistName.text = content.name; + _textChannelName.text = content.author.name; + _textPlaylistItems.text = ""; //TODO: Show items + + _platformIndicator.setPlatformFromClientID(content.id.pluginId); + + if(content is IPlatformPlaylist) { + val playlist = content; + + currentPlaylist = playlist + val thumbnail = playlist.thumbnail + if(thumbnail != null) + Glide.with(_imageThumbnail) + .load(thumbnail) + .placeholder(R.drawable.placeholder_video_thumbnail) + .crossfade() + .into(_imageThumbnail); + else + Glide.with(_imageThumbnail) + .load(R.drawable.placeholder_video_thumbnail) + .crossfade() + .into(_imageThumbnail); + + _textVideoCount.text = content.videoCount.toString(); + } + else { + currentPlaylist = null; + _imageThumbnail.setImageResource(0); + } + } + + private fun updateClaimsLayout(claims: PolycentricCache.CachedOwnedClaims?, animate: Boolean) { + _neopassAnimator?.cancel(); + _neopassAnimator = null; + + val harborAvailable = claims != null && !claims.ownedClaims.isNullOrEmpty(); + if (harborAvailable) { + _imageNeopassChannel?.visibility = View.VISIBLE + if (animate) { + _neopassAnimator = ObjectAnimator.ofFloat(_imageNeopassChannel, "alpha", 0.0f, 1.0f).setDuration(500) + _neopassAnimator?.start() + } + } else { + _imageNeopassChannel?.visibility = View.GONE + } + + _creatorThumbnail?.setHarborAvailable(harborAvailable, animate) + } + + companion object { + private val TAG = "VideoPreviewViewHolder" + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistsAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistsAdapter.kt new file mode 100644 index 00000000..676f68f0 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistsAdapter.kt @@ -0,0 +1,68 @@ +package com.futo.platformplayer.views.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.models.Playlist + +class PlaylistsAdapter : RecyclerView.Adapter { + private val _dataset: ArrayList; + + val onClick = Event1(); + val onPlay = Event1(); + val onRemoved = Event1(); + + private val _inflater: LayoutInflater; + private val _deletionConfirmationMessage: String; + + constructor(dataset: ArrayList, inflater: LayoutInflater, deletionConfirmationMessage: String) : super() { + _dataset = dataset; + _inflater = inflater; + _deletionConfirmationMessage = deletionConfirmationMessage; + } + + override fun getItemCount() = _dataset.size; + + override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): PlaylistsViewHolder { + val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.list_playlists, viewGroup, false); + val holder = PlaylistsViewHolder(view); + holder.onClick.subscribe { + val playlist = holder.playlist; + if (playlist != null) + onClick.emit(playlist); + }; + + holder.onPlay.subscribe { + val playlist = holder.playlist; + if (playlist != null) { + onPlay.emit(playlist); + } + }; + + holder.onRemove.subscribe { + val playlist = holder.playlist; + if (playlist != null) { + UIDialogs.showConfirmationDialog(_inflater.context, _deletionConfirmationMessage, { + val index = _dataset.indexOf(playlist); + if (index >= 0) { + _dataset.removeAt(index); + notifyItemRemoved(index); + onRemoved.emit(playlist); + } + + StatePlaylists.instance.removePlaylist(playlist); + }); + } + }; + + return holder; + } + + override fun onBindViewHolder(viewHolder: PlaylistsViewHolder, position: Int) { + viewHolder.bind(_dataset[position]) + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistsViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistsViewHolder.kt new file mode 100644 index 00000000..2ddbd8f6 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistsViewHolder.kt @@ -0,0 +1,58 @@ +package com.futo.platformplayer.views.adapters + +import android.view.View +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.images.GlideHelper.Companion.crossfade +import com.futo.platformplayer.models.Playlist + +class PlaylistsViewHolder : ViewHolder { + private val _root: ConstraintLayout; + private val _imageThumbnail: ImageView; + private val _textName: TextView; + private val _textMetadata: TextView; + private val _buttonTrash: ImageButton; + //private val _buttonPlay: ImageButton; + + var playlist: Playlist? = null + private set; + + val onClick = Event0(); + val onRemove = Event0(); + val onPlay = Event0(); + + constructor(view: View) : super(view) { + _root = view.findViewById(R.id.root); + _imageThumbnail = view.findViewById(R.id.image_video_thumbnail); + _textName = view.findViewById(R.id.text_name); + _textMetadata = view.findViewById(R.id.text_metadata); + _buttonTrash = view.findViewById(R.id.button_trash); + //_buttonPlay = view.findViewById(R.id.button_play); + + _root.setOnClickListener { onClick.emit(); }; + _buttonTrash.setOnClickListener { onRemove.emit(); }; + //_buttonPlay.setOnClickListener { onPlay.emit(); }; + } + + fun bind(p: Playlist) { + if (p.videos.isNotEmpty()) { + Glide.with(_imageThumbnail) + .load(p.videos[0].thumbnails.getLQThumbnail()) + .placeholder(R.drawable.placeholder_video_thumbnail) + .crossfade() + .into(_imageThumbnail); + } else { + _imageThumbnail.setImageResource(R.drawable.placeholder_video_thumbnail); + } + + _textName.text = p.name; + _textMetadata.text = "${p.videos.size} videos"; + playlist = p; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewContentListAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewContentListAdapter.kt new file mode 100644 index 00000000..1e370010 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewContentListAdapter.kt @@ -0,0 +1,159 @@ +package com.futo.platformplayer.views.adapters + +import android.content.Context +import android.util.Log +import android.view.* +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.debug.Stopwatch +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.video.PlayerManager +import com.futo.platformplayer.views.FeedStyle + +class PreviewContentListAdapter : InsertedViewAdapterWithLoader { + private var _initialPlay = true; + private var _previewingViewHolder: ContentPreviewViewHolder? = null; + private val _dataSet: ArrayList; + private val _exoPlayer: PlayerManager?; + private val _feedStyle : FeedStyle; + private var _paused: Boolean = false; + + val onContentUrlClicked = Event2(); + val onContentClicked = Event2(); + val onChannelClicked = Event1(); + val onAddToClicked = Event1(); + val onAddToQueueClicked = Event1(); + + private var _taskLoadContent = TaskHandler, Pair>( + StateApp.instance.scopeGetter, { (viewHolder, video) -> + val stopwatch = Stopwatch() + val contentDetails = StatePlatform.instance.getContentDetails(video.url).await(); + stopwatch.logAndNext(TAG, "Retrieving video detail (IO thread)") + return@TaskHandler Pair(viewHolder, contentDetails!!) + }).success { previewContentDetails(it.first, it.second) } + + constructor(context: Context, feedStyle : FeedStyle, dataSet: ArrayList, exoPlayer: PlayerManager? = null, + initialPlay: Boolean = false, viewsToPrepend: ArrayList = arrayListOf(), + viewsToAppend: ArrayList = arrayListOf()) : super(context, viewsToPrepend, viewsToAppend) { + + this._feedStyle = feedStyle; + this._dataSet = dataSet; + this._initialPlay = initialPlay; + this._exoPlayer = exoPlayer; + } + + override fun getChildCount(): Int = _dataSet.size; + override fun getItemViewType(position: Int): Int { + val p = parentToChildPosition(position); + if (p < 0) { + return -1; + } + + val item = _dataSet.getOrNull(p) ?: return -1; + return item.contentType.value; + } + override fun createChild(viewGroup: ViewGroup, viewType: Int): ContentPreviewViewHolder { + if(viewType == -1) + return EmptyPreviewViewHolder(viewGroup); + val contentType = ContentType.fromInt(viewType); + return when(contentType) { + ContentType.PLACEHOLDER -> createPlaceholderViewHolder(viewGroup); + ContentType.MEDIA -> createVideoPreviewViewHolder(viewGroup); + ContentType.POST -> createPostViewHolder(viewGroup); + ContentType.PLAYLIST -> createPlaylistViewHolder(viewGroup); + ContentType.NESTED_VIDEO -> createNestedViewHolder(viewGroup); + else -> EmptyPreviewViewHolder(viewGroup) + } + } + + private fun createPostViewHolder(viewGroup: ViewGroup): PreviewPostViewHolder = PreviewPostViewHolder(viewGroup, _feedStyle).apply { + this.onContentClicked.subscribe { this@PreviewContentListAdapter.onContentClicked.emit(it, 0); } + this.onChannelClicked.subscribe { this@PreviewContentListAdapter.onChannelClicked.emit(it); } + } + private fun createNestedViewHolder(viewGroup: ViewGroup): PreviewNestedVideoViewHolder = PreviewNestedVideoViewHolder(viewGroup, _feedStyle, _exoPlayer).apply { + this.onContentUrlClicked.subscribe(this@PreviewContentListAdapter.onContentUrlClicked::emit); + this.onVideoClicked.subscribe(this@PreviewContentListAdapter.onContentClicked::emit); + this.onChannelClicked.subscribe(this@PreviewContentListAdapter.onChannelClicked::emit); + this.onAddToClicked.subscribe(this@PreviewContentListAdapter.onAddToClicked::emit); + this.onAddToQueueClicked.subscribe(this@PreviewContentListAdapter.onAddToQueueClicked::emit); + }; + private fun createPlaceholderViewHolder(viewGroup: ViewGroup): PreviewPlaceholderViewHolder + = PreviewPlaceholderViewHolder(viewGroup, _feedStyle); + private fun createVideoPreviewViewHolder(viewGroup: ViewGroup): PreviewVideoViewHolder = PreviewVideoViewHolder(viewGroup, _feedStyle, _exoPlayer).apply { + this.onVideoClicked.subscribe(this@PreviewContentListAdapter.onContentClicked::emit); + this.onChannelClicked.subscribe(this@PreviewContentListAdapter.onChannelClicked::emit); + this.onAddToClicked.subscribe(this@PreviewContentListAdapter.onAddToClicked::emit); + this.onAddToQueueClicked.subscribe(this@PreviewContentListAdapter.onAddToQueueClicked::emit); + }; + private fun createPlaylistViewHolder(viewGroup: ViewGroup): PreviewPlaylistViewHolder = PreviewPlaylistViewHolder(viewGroup, _feedStyle).apply { + this.onPlaylistClicked.subscribe { this@PreviewContentListAdapter.onContentClicked.emit(it, 0L) }; + this.onChannelClicked.subscribe(this@PreviewContentListAdapter.onChannelClicked::emit); + }; + + override fun bindChild(viewHolder: ContentPreviewViewHolder, position: Int) { + val value = _dataSet[position]; + + viewHolder.bind(value); + if (_initialPlay && position == 0) { + _initialPlay = false; + + if (_feedStyle != FeedStyle.THUMBNAIL) + preview(viewHolder); + } + } + + fun preview(viewHolder: ContentPreviewViewHolder) { + Log.v(TAG, "previewing content"); + if (viewHolder == _previewingViewHolder) + return + + val content = viewHolder.content ?: return + if(content is IPlatformVideoDetails) + previewContentDetails(viewHolder, content); + else if(content is IPlatformVideo) + _taskLoadContent.run(Pair(viewHolder, content)); + else if(content is IPlatformNestedContent) + previewContentDetails(viewHolder, null); + } + fun stopPreview() { + _taskLoadContent.cancel(); + _previewingViewHolder?.stopPreview(); + _previewingViewHolder = null; + } + fun pausePreview() { + _previewingViewHolder?.pausePreview() + _paused = true; + } + fun resumePreview() { + _previewingViewHolder?.resumePreview() + _paused = false; + } + + fun release() { + _taskLoadContent.dispose(); + onContentUrlClicked.clear(); + onContentClicked.clear(); + onChannelClicked.clear(); + onAddToClicked.clear(); + onAddToQueueClicked.clear(); + } + + private fun previewContentDetails(viewHolder: ContentPreviewViewHolder, videoDetails: IPlatformContentDetails?) { + _previewingViewHolder?.stopPreview(); + viewHolder.preview(videoDetails, _paused); + _previewingViewHolder = viewHolder; + } + + companion object { + private val TAG = "VideoPreviewListAdapter"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewNestedVideoView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewNestedVideoView.kt new file mode 100644 index 00000000..83b63ff4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewNestedVideoView.kt @@ -0,0 +1,151 @@ +package com.futo.platformplayer.views.adapters + +import android.content.Context +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.images.GlideHelper.Companion.loadThumbnails +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.video.PlayerManager +import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.platform.PlatformIndicator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class PreviewNestedVideoView : PreviewVideoView { + + protected val _platformIndicatorNested: PlatformIndicator; + protected val _containerLoader: LinearLayout; + protected val _containerUnavailable: LinearLayout; + protected val _textNestedUrl: TextView; + + private var _content: IPlatformContent? = null; + private var _contentNested: IPlatformContentDetails? = null; + + private var _contentSupported = false; + + val onContentUrlClicked = Event2(); + + constructor(context: Context, feedStyle: FeedStyle, exoPlayer: PlayerManager? = null): super(context, feedStyle, exoPlayer) { + _platformIndicatorNested = findViewById(R.id.thumbnail_platform_nested); + _containerLoader = findViewById(R.id.container_loader); + _containerUnavailable = findViewById(R.id.container_unavailable); + _textNestedUrl = findViewById(R.id.text_nested_url); + } + + override fun inflate(feedStyle: FeedStyle) { + inflate(context, when(feedStyle) { + FeedStyle.PREVIEW -> R.layout.list_video_preview_nested + else -> R.layout.list_video_thumbnail_nested + }, this) + } + + override fun onOpenClicked() { + if(_contentNested is IPlatformVideoDetails) + super.onOpenClicked(); + else if(_content is IPlatformNestedContent) { + (_content as IPlatformNestedContent).let { + onContentUrlClicked.emit(it.contentUrl, if(_contentSupported) it.nestedContentType else ContentType.URL); + }; + } + } + + + override fun bind(content: IPlatformContent) { + _content = content; + _contentNested = null; + + super.bind(content); + + _platformIndicator.setPlatformFromClientID(content.id.pluginId); + _platformIndicatorNested.setPlatformFromClientID(content.id.pluginId); + + if(content is IPlatformNestedContent) { + _textNestedUrl.text = content.contentUrl; + _imageVideo.loadThumbnails(content.contentThumbnails, true) { + it.placeholder(R.drawable.placeholder_video_thumbnail) + .into(_imageVideo); + }; + + + _contentSupported = content.contentSupported; + if(!_contentSupported) { + _containerUnavailable.visibility = View.VISIBLE; + _containerLoader.visibility = View.GONE; + } + else { + if(_feedStyle == FeedStyle.THUMBNAIL) + _platformIndicator.setPlatformFromClientID(content.contentPlugin); + else + _platformIndicatorNested.setPlatformFromClientID(content.contentPlugin); + _containerUnavailable.visibility = View.GONE; + if(_feedStyle == FeedStyle.PREVIEW) + loadNested(content); + } + } + else { + _contentSupported = false; + _containerUnavailable.visibility = View.VISIBLE; + _containerLoader.visibility = View.GONE; + } + } + + private fun loadNested(content: IPlatformNestedContent) { + Logger.i(TAG, "Loading nested content [${content.contentUrl}]"); + _containerLoader.visibility = View.VISIBLE; + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + val def = StatePlatform.instance.getContentDetails(content.contentUrl); + def.invokeOnCompletion { + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { + Logger.i(TAG, "Loaded nested content [${content.contentUrl}] (${_content == content})"); + if(it != null) { + Logger.e(TAG, "Failed to load nested", it); + if(_content == content) { + _containerUnavailable.visibility = View.VISIBLE; + _containerLoader.visibility = View.GONE; + } + //TODO: Handle exception + } + else if(_content == content) { + _containerLoader.visibility = View.GONE; + val nestedContent = def.getCompleted(); + _contentNested = nestedContent; + if(nestedContent is IPlatformVideoDetails) { + super.bind(nestedContent); + if(_feedStyle == FeedStyle.PREVIEW) { + _platformIndicator.setPlatformFromClientID(content.id.pluginId); + _platformIndicatorNested.setPlatformFromClientID(nestedContent.id.pluginId); + } + else + _platformIndicatorNested.setPlatformFromClientID(content.id.pluginId); + } + else { + _containerUnavailable.visibility = View.VISIBLE; + } + } + } + }; + } + } + + override fun preview(video: IPlatformContentDetails?, paused: Boolean) { + if(video != null) + super.preview(video, paused); + else if(_content is IPlatformVideoDetails) _contentNested?.let { + super.preview(it, paused); + }; + } + + companion object { + val TAG = "PreviewNestedVideoView"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewNestedVideoViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewNestedVideoViewHolder.kt new file mode 100644 index 00000000..1f6b516c --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewNestedVideoViewHolder.kt @@ -0,0 +1,62 @@ +package com.futo.platformplayer.views.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.FrameLayout +import com.futo.platformplayer.* +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.video.PlayerManager +import com.futo.platformplayer.views.FeedStyle + + +class PreviewNestedVideoViewHolder : ContentPreviewViewHolder { + val onContentUrlClicked = Event2(); + val onVideoClicked = Event2(); + val onChannelClicked = Event1(); + val onAddToClicked = Event1(); + val onAddToQueueClicked = Event1(); + + override val content: IPlatformContent? get() = view.content; + private val view: PreviewNestedVideoView get() = itemView as PreviewNestedVideoView; + + constructor(viewGroup: ViewGroup, feedStyle : FeedStyle, exoPlayer: PlayerManager? = null): super(PreviewNestedVideoView(viewGroup.context, feedStyle, exoPlayer)) { + view.onContentUrlClicked.subscribe(onContentUrlClicked::emit); + view.onVideoClicked.subscribe(onVideoClicked::emit); + view.onChannelClicked.subscribe(onChannelClicked::emit); + view.onAddToClicked.subscribe(onAddToClicked::emit); + view.onAddToQueueClicked.subscribe(onAddToQueueClicked::emit); + } + + + override fun bind(content: IPlatformContent) { + view.bind(content); + } + + override fun preview(details: IPlatformContentDetails?, paused: Boolean) { + view.preview(details, paused); + } + + override fun stopPreview() { + view.stopPreview(); + } + + override fun pausePreview() { + view.pausePreview(); + } + + override fun resumePreview() { + view.resumePreview(); + } + + + + companion object { + private val TAG = "PreviewNestedVideoViewHolder" + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewPlaceholderViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewPlaceholderViewHolder.kt new file mode 100644 index 00000000..692aa8f4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewPlaceholderViewHolder.kt @@ -0,0 +1,52 @@ +package com.futo.platformplayer.views.adapters + +import android.content.Context +import android.graphics.drawable.Animatable +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import com.futo.platformplayer.* +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.contents.PlatformContentPlaceholder +import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.platform.PlatformIndicator + + +class PreviewPlaceholderViewHolder : ContentPreviewViewHolder { + override var content: IPlatformContent? = null; + + private val _loader: ImageView; + private val _platformIndicator: PlatformIndicator; + + val context: Context; + + //TODO: Aspect ratio sizing of layout + constructor(viewGroup: ViewGroup, feedStyle: FeedStyle) : super(LayoutInflater.from(viewGroup.context).inflate(when(feedStyle) { + FeedStyle.PREVIEW -> R.layout.list_placeholder_preview + FeedStyle.THUMBNAIL -> R.layout.list_placeholder_thumbnail + else -> R.layout.list_placeholder_thumbnail + }, viewGroup, false)) { + context = itemView.context; + _loader = itemView.findViewById(R.id.loader); + _platformIndicator = itemView.findViewById(R.id.thumbnail_platform); + + (_loader.drawable as Animatable?)?.start(); //TODO: stop? + } + + override fun bind(content: IPlatformContent) { + if(content is PlatformContentPlaceholder) + _platformIndicator.setPlatformFromClientID(content.id.pluginId); + else + _platformIndicator.clearPlatform(); + } + + override fun preview(video: IPlatformContentDetails?, paused: Boolean) { } + override fun stopPreview() { } + override fun pausePreview() { } + override fun resumePreview() { } + + companion object { + private val TAG = "PlaceholderPreviewViewHolder" + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewPlaylistViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewPlaylistViewHolder.kt new file mode 100644 index 00000000..8deeca26 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewPlaylistViewHolder.kt @@ -0,0 +1,37 @@ +package com.futo.platformplayer.views.adapters + +import android.view.ViewGroup +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.views.FeedStyle + + +class PreviewPlaylistViewHolder : ContentPreviewViewHolder { + val onPlaylistClicked = Event1(); + val onChannelClicked = Event1(); + + val currentPlaylist: IPlatformPlaylist? get() = view.currentPlaylist; + + override val content: IPlatformContent? get() = currentPlaylist; + + private val view: PlaylistView get() = itemView as PlaylistView; + + constructor(viewGroup: ViewGroup, feedStyle : FeedStyle): super(PlaylistView(viewGroup.context, feedStyle)) { + view.onPlaylistClicked.subscribe(onPlaylistClicked::emit); + view.onChannelClicked.subscribe(onChannelClicked::emit); + } + + override fun bind(content: IPlatformContent) = view.bind(content); + + override fun preview(details: IPlatformContentDetails?, paused: Boolean) = Unit; + override fun stopPreview() = Unit; + override fun pausePreview() = Unit; + override fun resumePreview() = Unit; + + companion object { + private val TAG = "PlaylistViewHolder" + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewPostView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewPostView.kt new file mode 100644 index 00000000..68f307e6 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewPostView.kt @@ -0,0 +1,316 @@ +package com.futo.platformplayer.views.adapters + +import android.animation.ObjectAnimator +import android.content.Context +import android.content.res.Resources +import android.graphics.drawable.Drawable +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.children +import com.bumptech.glide.Glide +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.target.Target +import com.bumptech.glide.request.transition.Transition +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.post.IPlatformPost +import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.dp +import com.futo.platformplayer.fixHtmlWhitespace +import com.futo.platformplayer.images.GlideHelper.Companion.crossfade +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.polycentric.PolycentricCache +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.platform.PlatformIndicator +import com.google.android.material.imageview.ShapeableImageView +import com.google.android.material.shape.CornerFamily +import com.google.android.material.shape.ShapeAppearanceModel + +class PreviewPostView : LinearLayout { + private var _content: IPlatformContent? = null; + + private val _imageAuthorThumbnail: ImageView; + private val _textAuthorName: TextView; + private val _imageNeopassChannel: ImageView; + private val _textMetadata: TextView; + private val _textTitle: TextView; + private val _textDescription: TextView; + private val _platformIndicator: PlatformIndicator; + + private val _layoutImages: LinearLayout?; + private val _imageImage: ImageView?; + private val _layoutImageCount: LinearLayout?; + private val _textImageCount: TextView?; + + private val _layoutRating: LinearLayout?; + private val _imageLikeIcon: ImageView?; + private val _textLikes: TextView?; + private val _imageDislikeIcon: ImageView?; + private val _textDislikes: TextView?; + + private val _layoutComments: LinearLayout?; + private val _textComments: TextView?; + + private var _neopassAnimator: ObjectAnimator? = null; + + private val _taskLoadValidClaims = TaskHandler(StateApp.instance.scopeGetter, + { PolycentricCache.instance.getValidClaimsAsync(it).await() }) + .success { it -> updateClaimsLayout(it, animate = true) } + .exception { + Logger.w(TAG, "Failed to load claims.", it); + }; + + val content: IPlatformContent? get() = _content; + + val onContentClicked = Event1(); + val onChannelClicked = Event1(); + + constructor(context: Context, feedStyle: FeedStyle): super(context) { + inflate(feedStyle); + + _imageAuthorThumbnail = findViewById(R.id.image_author_thumbnail); + _textAuthorName = findViewById(R.id.text_author_name); + _imageNeopassChannel = findViewById(R.id.image_neopass_channel); + _textMetadata = findViewById(R.id.text_metadata); + _textTitle = findViewById(R.id.text_title); + _textDescription = findViewById(R.id.text_description); + _platformIndicator = findViewById(R.id.platform_indicator); + + _layoutImages = findViewById(R.id.layout_images); + _imageImage = findViewById(R.id.image_image); + _layoutImageCount = findViewById(R.id.layout_image_count); + _textImageCount = findViewById(R.id.text_image_count); + + _layoutRating = findViewById(R.id.layout_rating); + _imageLikeIcon = findViewById(R.id.image_like_icon); + _textLikes = findViewById(R.id.text_likes); + _imageDislikeIcon = findViewById(R.id.image_dislike_icon); + _textDislikes = findViewById(R.id.text_dislikes); + + _layoutComments = findViewById(R.id.layout_comments); + _textComments = findViewById(R.id.text_comments); + + val root = findViewById(R.id.root); + root.isClickable = true; + root.setOnClickListener { + _content?.let { + onContentClicked.emit(it); + } + } + + _imageAuthorThumbnail.setOnClickListener { emitChannelClicked(); }; + _textAuthorName.setOnClickListener { emitChannelClicked(); }; + _textMetadata.setOnClickListener { emitChannelClicked(); }; + } + + private fun emitChannelClicked() { + val channel = _content?.author ?: return; + onChannelClicked.emit(channel); + } + + fun inflate(feedStyle: FeedStyle) { + inflate(context, when(feedStyle) { + FeedStyle.PREVIEW -> R.layout.list_post_preview + //else -> R.layout.list_post_preview + else -> R.layout.list_post_thumbnail + }, this) + } + + fun bind(content: IPlatformContent) { + _taskLoadValidClaims.cancel(); + _content = content; + + if (content.author.id.claimType > 0) { + val cachedClaims = PolycentricCache.instance.getCachedValidClaims(content.author.id); + if (cachedClaims != null) { + updateClaimsLayout(cachedClaims, animate = false); + } else { + updateClaimsLayout(null, animate = false); + _taskLoadValidClaims.run(content.author.id); + } + } else { + updateClaimsLayout(null, animate = false); + } + + _textAuthorName.text = content.author.name; + _textMetadata.text = content.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: ""; + + if (content.author.thumbnail != null) + Glide.with(_imageAuthorThumbnail) + .load(content.author.thumbnail) + .placeholder(R.drawable.placeholder_channel_thumbnail) + .into(_imageAuthorThumbnail) + else + Glide.with(_imageAuthorThumbnail).load(R.drawable.placeholder_channel_thumbnail).into(_imageAuthorThumbnail); + + _imageAuthorThumbnail.clipToOutline = true; + _platformIndicator.setPlatformFromClientID(content.id.pluginId); + + val description = if(content is IPlatformPost) { + if(content.description.isNotEmpty()) + content.description + else if(content is IPlatformPostDetails) + content.content + else + "" + } else ""; + + if (content.name.isNullOrEmpty()) { + _textTitle.visibility = View.GONE; + } else { + _textTitle.text = content.name; + _textTitle.visibility = View.VISIBLE; + } + + _textDescription.text = description.fixHtmlWhitespace(); + + if (content is IPlatformPost) { + setImages(content.thumbnails.filterNotNull()); + } else { + setImages(null); + } + + //TODO: Rating not implemented + _layoutRating?.visibility = View.GONE; + + //TODO: Comments not implemented + _layoutComments?.visibility = View.GONE; + } + + private fun setImages(images: List?) { + //Update image count if exists + if (images == null) { + _layoutImageCount?.visibility = View.GONE; + } else { + if (images.size <= 1) { + _layoutImageCount?.visibility = View.GONE; + } else { + _layoutImageCount?.visibility = View.VISIBLE; + _textImageCount?.text = "${images.size} Images"; + } + } + + //Set single image if exists + _imageImage?.let { imageImage -> + if (!images.isNullOrEmpty()) { + imageImage.visibility = View.VISIBLE; + + val image = images.firstNotNullOfOrNull { it.getLQThumbnail() }; + if (image != null) + Glide.with(imageImage) + .load(image) + .placeholder(R.drawable.placeholder_video_thumbnail) + .listener(object: RequestListener { + override fun onLoadFailed(e: GlideException?, model: Any?, target: Target?, isFirstResource: Boolean): Boolean { + imageImage.visibility = View.GONE; + return false; + } + override fun onResourceReady(resource: Drawable?, model: Any?, target: Target?, dataSource: DataSource?, isFirstResource: Boolean): Boolean { + return false; + } + }) + .crossfade() + .into(imageImage) + else + imageImage.visibility = View.GONE; + } else { + imageImage.visibility = View.GONE; + } + } + + //Set multi image if exists + _layoutImages?.let { layoutImages -> + for (child in layoutImages.children) { + if (child is ImageView) { + Glide.with(child).clear(child); + } + } + + layoutImages.removeAllViews(); + + if (!images.isNullOrEmpty()) { + val displayMetrics = Resources.getSystem().displayMetrics + val screenWidth = displayMetrics.widthPixels + val maxWidth = screenWidth + var currentWidth = 0 + val four_dp = 4.dp(resources) + + for (url in images.mapNotNull { it.getLQThumbnail() }) { + val shapeableImageView = ShapeableImageView(context).apply { + scaleType = ImageView.ScaleType.CENTER_CROP + adjustViewBounds = true + shapeAppearanceModel = ShapeAppearanceModel.builder().setAllCorners(CornerFamily.ROUNDED, four_dp.toFloat()).build() + } + + Glide.with(context) + .asDrawable() + .load(url) + .into(object : CustomTarget() { + override fun onResourceReady(resource: Drawable, transition: Transition?) { + shapeableImageView.setImageDrawable(resource); + val ratio = shapeableImageView.drawable.intrinsicWidth.toFloat() / shapeableImageView.drawable.intrinsicHeight.toFloat() + val projectedWidth = 105.dp(resources).toFloat() * ratio + + if (currentWidth + projectedWidth <= maxWidth) { + shapeableImageView.layoutParams = LayoutParams( + projectedWidth.toInt(), + LayoutParams.MATCH_PARENT + ).apply { + marginStart = four_dp + marginEnd = four_dp + } + + currentWidth += projectedWidth.toInt() + 2 * four_dp + _layoutImages.addView(shapeableImageView) + } + } + + override fun onLoadCleared(placeholder: Drawable?) { + + } + }) + } + + layoutImages.visibility = View.VISIBLE; + } else { + layoutImages.visibility = View.GONE; + } + }; + } + + private fun updateClaimsLayout(claims: PolycentricCache.CachedOwnedClaims?, animate: Boolean) { + _neopassAnimator?.cancel(); + _neopassAnimator = null; + + val harborAvailable = claims != null && !claims.ownedClaims.isNullOrEmpty(); + if (harborAvailable) { + _imageNeopassChannel.visibility = View.VISIBLE + if (animate) { + _neopassAnimator = ObjectAnimator.ofFloat(_imageNeopassChannel, "alpha", 0.0f, 1.0f).setDuration(500) + _neopassAnimator?.start() + } + } else { + _imageNeopassChannel.visibility = View.GONE + } + + //TODO: Necessary if we decide to use creator thumbnail with neopass indicator instead + //_creatorThumbnail?.setHarborAvailable(harborAvailable, animate) + } + + companion object { + val TAG = "PreviewPostView"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewPostViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewPostViewHolder.kt new file mode 100644 index 00000000..56ffb855 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewPostViewHolder.kt @@ -0,0 +1,37 @@ +package com.futo.platformplayer.views.adapters + +import android.view.ViewGroup +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.video.PlayerManager +import com.futo.platformplayer.views.FeedStyle + + +class PreviewPostViewHolder : ContentPreviewViewHolder { + + val onContentClicked = Event1(); + val onChannelClicked = Event1(); + + override val content: IPlatformContent? get() = view.content; + + private val view: PreviewPostView get() = itemView as PreviewPostView; + + constructor(viewGroup: ViewGroup, feedStyle : FeedStyle, exoPlayer: PlayerManager? = null): super(PreviewPostView(viewGroup.context, feedStyle)) { + view.onContentClicked.subscribe(onContentClicked::emit); + view.onChannelClicked.subscribe(onChannelClicked::emit); + } + + + override fun bind(content: IPlatformContent) = view.bind(content); + + override fun preview(video: IPlatformContentDetails?, paused: Boolean) {}; + override fun stopPreview() {}; + override fun pausePreview() {}; + override fun resumePreview() {}; + + companion object { + private val TAG = "VideoPreviewViewHolder" + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewVideoView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewVideoView.kt new file mode 100644 index 00000000..ebd9e05b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewVideoView.kt @@ -0,0 +1,320 @@ +package com.futo.platformplayer.views.adapters + +import android.animation.ObjectAnimator +import android.content.Context +import android.content.res.Resources +import android.util.Log +import android.util.TypedValue +import android.view.View +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.bumptech.glide.Glide +import com.futo.platformplayer.* +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.images.GlideHelper.Companion.crossfade +import com.futo.platformplayer.images.GlideHelper.Companion.loadThumbnails +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.polycentric.PolycentricCache +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateDownloads +import com.futo.platformplayer.video.PlayerManager +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.platform.PlatformIndicator +import com.futo.platformplayer.views.video.FutoThumbnailPlayer + + +open class PreviewVideoView : LinearLayout { + protected val _feedStyle : FeedStyle; + + protected val _imageVideo: ImageView + protected val _imageChannel: ImageView? + protected val _creatorThumbnail: CreatorThumbnail? + protected val _imageNeopassChannel: ImageView?; + protected val _platformIndicator: PlatformIndicator; + protected val _textVideoName: TextView + protected val _textChannelName: TextView + protected val _textVideoMetadata: TextView + protected val _containerDuration: LinearLayout; + protected val _textVideoDuration: TextView; + protected var _playerVideoThumbnail: FutoThumbnailPlayer? = null; + protected val _containerLive: LinearLayout; + protected val _playerContainer: FrameLayout; + protected var _neopassAnimator: ObjectAnimator? = null; + protected val _layoutDownloaded: FrameLayout; + + protected val _button_add_to_queue : View; + protected val _button_add_to : View; + + protected val _exoPlayer: PlayerManager?; + + private val _taskLoadValidClaims = TaskHandler(StateApp.instance.scopeGetter, + { PolycentricCache.instance.getValidClaimsAsync(it).await() }) + .success { it -> updateClaimsLayout(it, animate = true) } + .exception { + Logger.w(TAG, "Failed to load claims.", it); + }; + + val onVideoClicked = Event2(); + val onChannelClicked = Event1(); + val onAddToClicked = Event1(); + val onAddToQueueClicked = Event1(); + + var currentVideo: IPlatformVideo? = null + private set + + val content: IPlatformContent? get() = currentVideo; + + constructor(context: Context, feedStyle : FeedStyle, exoPlayer: PlayerManager? = null) : super(context) { + inflate(feedStyle); + _feedStyle = feedStyle; + val playerContainer = findViewById(R.id.player_container); + + val displayMetrics = Resources.getSystem().displayMetrics; + val width: Double = if (feedStyle == FeedStyle.PREVIEW) { + displayMetrics.widthPixels.toDouble(); + } else { + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 177.0f, displayMetrics).toDouble(); + }; + + /* + val ar = 16.0 / 9.0; + var height = width / ar; + height += TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, displayMetrics); + + val layoutParams = playerContainer.layoutParams; + layoutParams.height = height.roundToInt(); + playerContainer.layoutParams = layoutParams;*/ + + //Logger.i(TAG, "Player container height calculated to be $height."); + + + _playerContainer = findViewById(R.id.player_container); + _imageVideo = findViewById(R.id.image_video_thumbnail) + _imageChannel = findViewById(R.id.image_channel_thumbnail); + _creatorThumbnail = findViewById(R.id.creator_thumbnail); + _platformIndicator = findViewById(R.id.thumbnail_platform); + _textVideoName = findViewById(R.id.text_video_name) + _textChannelName = findViewById(R.id.text_channel_name) + _textVideoMetadata = findViewById(R.id.text_video_metadata) + _textVideoDuration = findViewById(R.id.thumbnail_duration); + _containerDuration = findViewById(R.id.thumbnail_duration_container); + _containerLive = findViewById(R.id.thumbnail_live_container); + _button_add_to_queue = findViewById(R.id.button_add_to_queue); + _button_add_to = findViewById(R.id.button_add_to); + _imageNeopassChannel = findViewById(R.id.image_neopass_channel); + _layoutDownloaded = findViewById(R.id.layout_downloaded); + + this._exoPlayer = exoPlayer + + setOnClickListener { onOpenClicked() }; + _imageChannel.setOnClickListener { currentVideo?.let { onChannelClicked.emit(it.author) } }; + _textChannelName.setOnClickListener { currentVideo?.let { onChannelClicked.emit(it.author) } }; + _textVideoMetadata.setOnClickListener { currentVideo?.let { onChannelClicked.emit(it.author) } }; + _button_add_to.setOnClickListener { currentVideo?.let { onAddToClicked.emit(it) } }; + _button_add_to_queue.setOnClickListener { currentVideo?.let { onAddToQueueClicked.emit(it) } }; + + } + + protected open fun inflate(feedStyle: FeedStyle) { + inflate(context, when(feedStyle) { + FeedStyle.PREVIEW -> R.layout.list_video_preview + else -> R.layout.list_video_thumbnail + }, this) + } + + protected open fun onOpenClicked() { + currentVideo?.let { + val currentPlayer = _playerVideoThumbnail; + var sec = if(currentPlayer != null && currentPlayer.playing) + (currentPlayer.position / 1000).toLong(); + else 0L; + onVideoClicked.emit(it, sec); + } + } + + + open fun bind(content: IPlatformContent) { + _taskLoadValidClaims.cancel(); + + val cachedClaims = PolycentricCache.instance.getCachedValidClaims(content.author.id); + if (cachedClaims != null) { + updateClaimsLayout(cachedClaims, animate = false); + } else { + updateClaimsLayout(null, animate = false); + _taskLoadValidClaims.run(content.author.id); + } + + isClickable = true; + + val isPlanned = (content.datetime?.getNowDiffSeconds() ?: 0) < 0; + + stopPreview(); + + if(_imageChannel != null) + Glide.with(_imageChannel) + .load(content.author.thumbnail) + .placeholder(R.drawable.placeholder_channel_thumbnail) + .into(_imageChannel); + + _imageChannel?.clipToOutline = true; + + _textVideoName.text = content.name; + _textChannelName.text = content.author.name + _layoutDownloaded.visibility = if (StateDownloads.instance.isDownloaded(content.id)) VISIBLE else GONE; + + _platformIndicator.setPlatformFromClientID(content.id.pluginId); + + var metadata = "" + if (content is IPlatformVideo && content.viewCount > 0) { + if(content.isLive) + metadata += "${content.viewCount.toHumanNumber()} watching • "; + else + metadata += "${content.viewCount.toHumanNumber()} views • "; + } + + var timeMeta = ""; + if(isPlanned) { + val ago = content.datetime?.toHumanNowDiffString(true) ?: "" + timeMeta = "available in " + ago; + } + else { + val ago = content.datetime?.toHumanNowDiffString() ?: "" + timeMeta = if (ago.isNotBlank()) ago + " ago" else ago; + } + + if(content is IPlatformVideo) { + val video = content; + + currentVideo = video + + _imageVideo.loadThumbnails(video.thumbnails, true) { + it.placeholder(R.drawable.placeholder_video_thumbnail) + .crossfade() + .into(_imageVideo); + }; + + if(!isPlanned) + _textVideoDuration.text = video.duration.toHumanTime(false); + else + _textVideoDuration.text = "Planned"; + + _playerVideoThumbnail?.setLive(video.isLive); + if(!isPlanned && video.isLive) { + _containerDuration.visibility = GONE; + _containerLive.visibility = VISIBLE; + timeMeta = "LIVE" + } + else { + _containerLive.visibility = GONE; + _containerDuration.visibility = VISIBLE; + } + } + else { + currentVideo = null; + _imageVideo.setImageResource(0); + _containerDuration.visibility = GONE; + _containerLive.visibility = GONE; + } + _textVideoMetadata.text = metadata + timeMeta; + } + + open fun preview(video: IPlatformContentDetails?, paused: Boolean) { + if(video == null) + return; + Logger.i(TAG, "Previewing"); + if(video !is IPlatformVideoDetails) + throw IllegalStateException("Expected VideoDetails"); + + if(_feedStyle == FeedStyle.THUMBNAIL) + return; + + val exoPlayer = _exoPlayer ?: return; + + Log.v(TAG, "video preview start playing" + video.name); + + val playerVideoThumbnail = FutoThumbnailPlayer(context); + playerVideoThumbnail.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT); + playerVideoThumbnail.setPlayer(exoPlayer); + if(!exoPlayer.currentState.muted) + playerVideoThumbnail.unmute(); + else + playerVideoThumbnail.mute(); + + playerVideoThumbnail.setTempDuration(video.duration, false); + playerVideoThumbnail.setPreview(video); + _playerContainer.addView(playerVideoThumbnail); + _playerVideoThumbnail = playerVideoThumbnail; + } + fun stopPreview() { + if(_feedStyle == FeedStyle.THUMBNAIL) + return; + Log.v(TAG, "video preview stopping=" + currentVideo?.name); + + val playerVideoThumbnail = _playerVideoThumbnail; + if (playerVideoThumbnail != null) { + playerVideoThumbnail.stop(); + playerVideoThumbnail.setPlayer(null); + _playerContainer.removeView(playerVideoThumbnail); + _playerVideoThumbnail = null; + } + + Log.v(TAG, "video preview playing and made invisible" + currentVideo?.name) + } + fun pausePreview() { + if(_feedStyle == FeedStyle.THUMBNAIL) + return; + Log.v(TAG, "video preview pausing " + currentVideo?.name) + + _playerVideoThumbnail?.pause(); + + Log.v(TAG, "video preview paused " + currentVideo?.name) + } + fun resumePreview() { + if(_feedStyle == FeedStyle.THUMBNAIL) + return; + Log.v(TAG, "video preview resuming " + currentVideo?.name) + + _playerVideoThumbnail?.play(); + + Log.v(TAG, "video preview resumed" + currentVideo?.name) + } + + + //Events + fun setMuteChangedListener(callback : (FutoThumbnailPlayer, Boolean) -> Unit) { + _playerVideoThumbnail?.setMuteChangedListener(callback); + } + + private fun updateClaimsLayout(claims: PolycentricCache.CachedOwnedClaims?, animate: Boolean) { + _neopassAnimator?.cancel(); + _neopassAnimator = null; + + val harborAvailable = claims != null && !claims.ownedClaims.isNullOrEmpty(); + if (harborAvailable) { + _imageNeopassChannel?.visibility = View.VISIBLE + if (animate) { + _neopassAnimator = ObjectAnimator.ofFloat(_imageNeopassChannel, "alpha", 0.0f, 1.0f).setDuration(500) + _neopassAnimator?.start() + } + } else { + _imageNeopassChannel?.visibility = View.GONE + } + + _creatorThumbnail?.setHarborAvailable(harborAvailable, animate) + } + + companion object { + private val TAG = "VideoPreviewViewHolder" + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewVideoViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewVideoViewHolder.kt new file mode 100644 index 00000000..8fed1d82 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/PreviewVideoViewHolder.kt @@ -0,0 +1,46 @@ +package com.futo.platformplayer.views.adapters + +import android.view.ViewGroup +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.video.PlayerManager +import com.futo.platformplayer.views.FeedStyle + + +class PreviewVideoViewHolder : ContentPreviewViewHolder { + + val onVideoClicked = Event2(); + val onChannelClicked = Event1(); + val onAddToClicked = Event1(); + val onAddToQueueClicked = Event1(); + + //val context: Context; + val currentVideo: IPlatformVideo? get() = view.currentVideo; + + override val content: IPlatformContent? get() = currentVideo; + + private val view: PreviewVideoView get() = itemView as PreviewVideoView; + + constructor(viewGroup: ViewGroup, feedStyle : FeedStyle, exoPlayer: PlayerManager? = null): super(PreviewVideoView(viewGroup.context, feedStyle, exoPlayer)) { + view.onVideoClicked.subscribe(onVideoClicked::emit); + view.onChannelClicked.subscribe(onChannelClicked::emit); + view.onAddToClicked.subscribe(onAddToClicked::emit); + view.onAddToQueueClicked.subscribe(onAddToQueueClicked::emit); + } + + + override fun bind(content: IPlatformContent) = view.bind(content); + + override fun preview(video: IPlatformContentDetails?, paused: Boolean) = view.preview(video, paused); + override fun stopPreview() = view.stopPreview(); + override fun pausePreview() = view.pausePreview(); + override fun resumePreview() = view.resumePreview(); + + companion object { + private val TAG = "VideoPreviewViewHolder" + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/SearchSuggestionAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/SearchSuggestionAdapter.kt new file mode 100644 index 00000000..d96a7862 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/SearchSuggestionAdapter.kt @@ -0,0 +1,37 @@ +package com.futo.platformplayer.views.adapters + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event1 + +class SearchSuggestionAdapter : RecyclerView.Adapter { + private val _dataset: ArrayList; + + var onAddToQuery = Event1(); + var onClicked = Event1(); + var onRemove = Event1(); + var isHistorical: Boolean = false; + + constructor(dataSet: ArrayList) : super() { + _dataset = dataSet; + } + + override fun getItemCount() = _dataset.size; + + override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): SearchSuggestionViewHolder { + val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.list_search_suggestion, viewGroup, false) + val holder = SearchSuggestionViewHolder(view); + holder.onAddToQuery.subscribe { suggestion -> onAddToQuery.emit(suggestion); }; + holder.onClicked.subscribe { suggestion -> onClicked.emit(suggestion); }; + holder.onRemove.subscribe { suggestion -> onRemove.emit(suggestion); }; + return holder; + } + override fun onBindViewHolder(viewHolder: SearchSuggestionViewHolder, position: Int) { + viewHolder.bind(_dataset[position], isHistorical); + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/SearchSuggestionViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/SearchSuggestionViewHolder.kt new file mode 100644 index 00000000..6da9134e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/SearchSuggestionViewHolder.kt @@ -0,0 +1,43 @@ +package com.futo.platformplayer.views.adapters + +import android.view.View +import android.widget.ImageButton +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event1 + +class SearchSuggestionViewHolder : ViewHolder { + private val _textSuggestion: TextView + private val _buttonAddToQuery: ImageButton + private val _buttonRemove: ImageButton; + + var onAddToQuery = Event1(); + var onClicked = Event1(); + var onRemove = Event1(); + + var suggestion: String? = null + private set; + + constructor(view: View) : super(view) { + _textSuggestion = view.findViewById(R.id.text_suggestion); + _buttonAddToQuery = view.findViewById(R.id.button_add_to_query); + _buttonRemove = view.findViewById(R.id.button_remove); + + _buttonAddToQuery.setOnClickListener { + suggestion?.let { it1 -> onAddToQuery.emit(it1) }; + }; + _buttonRemove.setOnClickListener { + suggestion?.let { it1 -> onRemove.emit(it1) } + }; + view.setOnClickListener { + suggestion?.let { it1 -> onClicked.emit(it1) }; + }; + } + + fun bind(suggestion: String, isHistorical: Boolean) { + this.suggestion = suggestion; + _textSuggestion.text = suggestion; + _buttonRemove.visibility = if (isHistorical) View.VISIBLE else View.GONE; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt new file mode 100644 index 00000000..7b7358f3 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt @@ -0,0 +1,59 @@ +package com.futo.platformplayer.views.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.models.Subscription +import com.futo.platformplayer.states.StateSubscriptions +import com.futo.platformplayer.constructs.Event1 + +class SubscriptionAdapter : RecyclerView.Adapter { + private lateinit var _sortedDataset: List; + private val _inflater: LayoutInflater; + private val _confirmationMessage: String; + + var onClick = Event1(); + var sortBy: Int = 0 + set(value) { + field = value; + updateDataset(); + } + + constructor(inflater: LayoutInflater, confirmationMessage: String) : super() { + _inflater = inflater; + _confirmationMessage = confirmationMessage; + + StateSubscriptions.instance.onSubscriptionsChanged.subscribe { subs, added -> updateDataset(); } + updateDataset(); + } + + override fun getItemCount() = _sortedDataset.size; + + override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): SubscriptionViewHolder { + val holder = SubscriptionViewHolder(viewGroup); + holder.onClick.subscribe(onClick::emit); + holder.onTrash.subscribe { + val sub = holder.subscription ?: return@subscribe; + UIDialogs.showConfirmationDialog(_inflater.context, _confirmationMessage, { + StateSubscriptions.instance.removeSubscription(sub.channel.url); + }); + }; + + return holder; + } + + override fun onBindViewHolder(viewHolder: SubscriptionViewHolder, position: Int) { + viewHolder.bind(_sortedDataset[position]); + } + + private fun updateDataset() { + _sortedDataset = when (sortBy) { + 0 -> StateSubscriptions.instance.getSubscriptions().sortedBy({ u -> u.channel.name }) + 1 -> StateSubscriptions.instance.getSubscriptions().sortedByDescending({ u -> u.channel.name }) + else -> throw IllegalStateException("Invalid sorting algorithm selected."); + }.toList(); + + notifyDataSetChanged(); + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionViewHolder.kt new file mode 100644 index 00000000..0cf36cfa --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionViewHolder.kt @@ -0,0 +1,97 @@ +package com.futo.platformplayer.views.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.LinearLayout +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.polycentric.PolycentricCache +import com.futo.platformplayer.R +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.models.Subscription +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.dp +import com.futo.platformplayer.selectBestImage +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.platform.PlatformIndicator +import com.futo.polycentric.core.toURLInfoSystemLinkUrl + +class SubscriptionViewHolder : ViewHolder { + private val _layoutSubscription: LinearLayout; + private val _textName: TextView; + private val _creatorThumbnail: CreatorThumbnail; + private val _buttonTrash: ImageButton; + private val _platformIndicator : PlatformIndicator; + + private val _taskLoadProfile = TaskHandler( + StateApp.instance.scopeGetter, + { PolycentricCache.instance.getProfileAsync(it) }) + .success { it -> onProfileLoaded(it, true) } + .exception { + Logger.w(TAG, "Failed to load profile.", it); + }; + + var subscription: Subscription? = null + private set; + + var onClick = Event1(); + var onTrash = Event0(); + + constructor(viewGroup: ViewGroup) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_subscription, viewGroup, false)) { + _layoutSubscription = itemView.findViewById(R.id.layout_subscription); + _textName = itemView.findViewById(R.id.text_name); + _creatorThumbnail = itemView.findViewById(R.id.creator_thumbnail); + _buttonTrash = itemView.findViewById(R.id.button_trash); + _platformIndicator = itemView.findViewById(R.id.platform); + + _layoutSubscription.setOnClickListener { + val sub = subscription; + if (sub != null) { + onClick.emit(sub); + } + }; + + _buttonTrash.setOnClickListener { + onTrash.emit(); + }; + } + + fun bind(sub: Subscription) { + _taskLoadProfile.cancel(); + + this.subscription = sub; + + val cachedProfile = PolycentricCache.instance.getCachedProfile(sub.channel.url, true); + if (cachedProfile != null) { + onProfileLoaded(cachedProfile, false); + } else { + _creatorThumbnail.setThumbnail(sub.channel.thumbnail, false); + _taskLoadProfile.run(sub.channel.id); + } + + _textName.text = sub.channel.name; + _platformIndicator.setPlatformFromClientID(sub.channel.id.pluginId); + } + + private fun onProfileLoaded(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) { + val dp_46 = 46.dp(itemView.context.resources); + val avatar = cachedPolycentricProfile?.profile?.systemState?.avatar?.selectBestImage(dp_46 * dp_46) + ?.let { it.toURLInfoSystemLinkUrl(cachedPolycentricProfile.profile.system.toProto(), it.process, cachedPolycentricProfile.profile.systemState.servers.toList()) }; + + if (avatar != null) { + _creatorThumbnail.setThumbnail(avatar, animate); + } else { + _creatorThumbnail.setThumbnail(this.subscription?.channel?.thumbnail, animate); + _creatorThumbnail.setHarborAvailable(cachedPolycentricProfile?.profile != null, animate); + } + } + + companion object { + private const val TAG = "SubscriptionViewHolder" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorAdapter.kt new file mode 100644 index 00000000..aa4ee66f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorAdapter.kt @@ -0,0 +1,53 @@ +package com.futo.platformplayer.views.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.constructs.Event1 + +class VideoListEditorAdapter : RecyclerView.Adapter { + private var _videos: ArrayList? = null; + private val _touchHelper: ItemTouchHelper; + + val onClick = Event1(); + val onRemove = Event1(); + var canEdit = false + private set; + + constructor(touchHelper: ItemTouchHelper) : super() { + _touchHelper = touchHelper; + } + + override fun getItemCount() = _videos?.size ?: 0; + + override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): VideoListEditorViewHolder { + val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.list_playlist, viewGroup, false); + val holder = VideoListEditorViewHolder(view, _touchHelper); + + holder.onRemove.subscribe { v -> onRemove.emit(v); }; + holder.onClick.subscribe { v -> onClick.emit(v); }; + + return holder; + } + + override fun onBindViewHolder(viewHolder: VideoListEditorViewHolder, position: Int) { + val videos = _videos ?: return; + viewHolder.bind(videos[position], canEdit); + } + + fun setCanEdit(canEdit: Boolean, notify: Boolean = false) { + this.canEdit = canEdit; + if (notify) { + _videos?.let { notifyItemRangeChanged(0, it.size); }; + } + } + + fun setVideos(videos: ArrayList, canEdit: Boolean) { + _videos = videos; + setCanEdit(canEdit, false); + notifyDataSetChanged(); + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt new file mode 100644 index 00000000..1089e471 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt @@ -0,0 +1,116 @@ +package com.futo.platformplayer.views.adapters + +import android.view.MotionEvent +import android.view.View +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.images.GlideHelper.Companion.crossfade +import com.futo.platformplayer.states.StateDownloads +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.toHumanNumber +import com.futo.platformplayer.toHumanTime +import com.futo.platformplayer.views.platform.PlatformIndicator + +class VideoListEditorViewHolder : ViewHolder { + private val _root: ConstraintLayout; + private val _imageThumbnail: ImageView; + private val _textName: TextView; + private val _textAuthor: TextView; + private val _textMetadata: TextView; + private val _textVideoDuration: TextView; + private val _containerDuration: LinearLayout; + private val _containerLive: LinearLayout; + private val _imageRemove: ImageButton; + private val _imageDragDrop: ImageButton; + private val _platformIndicator: PlatformIndicator; + private val _layoutDownloaded: FrameLayout; + + var video: IPlatformVideo? = null + private set; + + val onClick = Event1(); + val onRemove = Event1(); + + constructor(view: View, touchHelper: ItemTouchHelper) : super(view) { + _root = view.findViewById(R.id.root); + _imageThumbnail = view.findViewById(R.id.image_video_thumbnail); + _imageThumbnail?.clipToOutline = true; + _textName = view.findViewById(R.id.text_video_name); + _textAuthor = view.findViewById(R.id.text_author); + _textMetadata = view.findViewById(R.id.text_video_metadata); + _textVideoDuration = view.findViewById(R.id.thumbnail_duration); + _containerDuration = view.findViewById(R.id.thumbnail_duration_container); + _containerLive = view.findViewById(R.id.thumbnail_live_container); + _imageRemove = view.findViewById(R.id.image_trash); + _imageDragDrop = view.findViewById(R.id.image_drag_drop); + _platformIndicator = view.findViewById(R.id.thumbnail_platform); + _layoutDownloaded = view.findViewById(R.id.layout_downloaded); + + _imageDragDrop.setOnTouchListener(View.OnTouchListener { v, event -> + if (event.action == MotionEvent.ACTION_DOWN) { + touchHelper.startDrag(this); + } + false + }); + + _root.setOnClickListener { + val v = video ?: return@setOnClickListener; + onClick.emit(v); + }; + + _imageRemove?.setOnClickListener { + val v = video ?: return@setOnClickListener; + onRemove.emit(v); + }; + } + + fun bind(v: IPlatformVideo, canEdit: Boolean) { + Glide.with(_imageThumbnail) + .load(v.thumbnails.getLQThumbnail()) + .placeholder(R.drawable.placeholder_video_thumbnail) + .crossfade() + .into(_imageThumbnail); + _textName.text = v.name; + _textAuthor.text = v.author.name; + _textVideoDuration.text = v.duration.toHumanTime(false); + + if(v.isLive) { + _containerDuration.visibility = View.GONE; + _containerLive.visibility = View.VISIBLE; + } + else { + _containerLive.visibility = View.GONE; + _containerDuration.visibility = View.VISIBLE; + } + + if (canEdit) { + _imageRemove.visibility = View.VISIBLE; + _imageDragDrop.visibility = View.VISIBLE; + } else { + _imageRemove.visibility = View.GONE; + _imageDragDrop.visibility = View.GONE; + } + + var metadata = ""; + if (v.viewCount > 0) + metadata += "${v.viewCount.toHumanNumber()} views • "; + metadata += v.datetime?.toHumanNowDiffString() ?: ""; + + _platformIndicator.setPlatformFromClientID(v.id.pluginId); + + _textMetadata.text = metadata; + + _layoutDownloaded.visibility = if (StateDownloads.instance.isDownloaded(v.id)) View.VISIBLE else View.GONE; + video = v; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListHorizontalAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListHorizontalAdapter.kt new file mode 100644 index 00000000..eb0dd83f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListHorizontalAdapter.kt @@ -0,0 +1,36 @@ +package com.futo.platformplayer.views.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.constructs.Event1 + +class VideoListHorizontalAdapter : RecyclerView.Adapter { + private val _dataset: ArrayList; + + val onClick = Event1(); + + constructor(dataset: ArrayList) : super() { + _dataset = dataset; + } + + override fun getItemCount() = _dataset.size; + + override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): VideoListHorizontalViewHolder { + val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.list_video_horizontal, viewGroup, false); + val holder = VideoListHorizontalViewHolder(view); + holder.onClick.subscribe { + val video = holder.video; + if (video != null) + onClick.emit(video); + }; + + return holder; + } + + override fun onBindViewHolder(viewHolder: VideoListHorizontalViewHolder, position: Int) { + viewHolder.bind(_dataset[position]) + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListHorizontalViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListHorizontalViewHolder.kt new file mode 100644 index 00000000..001779f1 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListHorizontalViewHolder.kt @@ -0,0 +1,62 @@ +package com.futo.platformplayer.views.adapters + +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.toHumanTime + +class VideoListHorizontalViewHolder : ViewHolder { + private val _root: ConstraintLayout; + private val _imageThumbnail: ImageView; + private val _textName: TextView; + private val _textAuthor: TextView; + private val _textVideoDuration: TextView; + private val _containerDuration: LinearLayout; + private val _containerLive: LinearLayout; + + var video: IPlatformVideo? = null + private set; + + val onClick = Event0(); + + constructor(view: View) : super(view) { + _root = view.findViewById(R.id.root); + _imageThumbnail = view.findViewById(R.id.image_video_thumbnail); + _imageThumbnail?.clipToOutline = true; + _textName = view.findViewById(R.id.text_video_name); + _textAuthor = view.findViewById(R.id.text_author); + _textVideoDuration = view.findViewById(R.id.thumbnail_duration); + _containerDuration = view.findViewById(R.id.thumbnail_duration_container); + _containerLive = view.findViewById(R.id.thumbnail_live_container); + + _root?.setOnClickListener { onClick.emit(); }; + } + + fun bind(v: IPlatformVideo) { + Glide.with(_imageThumbnail) + .load(v.thumbnails.getLQThumbnail()) + .placeholder(R.drawable.placeholder_video_thumbnail) + .into(_imageThumbnail); + _textName.text = v.name; + _textAuthor.text = v.author.name; + _textVideoDuration.text = v.duration.toHumanTime(false); + + if(v.isLive) { + _containerDuration.visibility = View.GONE; + _containerLive.visibility = View.VISIBLE; + } + else { + _containerLive.visibility = View.GONE; + _containerDuration.visibility = View.VISIBLE; + } + + video = v; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorViewHolder.kt new file mode 100644 index 00000000..fe96d55c --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorViewHolder.kt @@ -0,0 +1,64 @@ +package com.futo.platformplayer.views.adapters.viewholders + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.toHumanNumber +import com.futo.platformplayer.views.adapters.AnyAdapter +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.platform.PlatformIndicator +import com.futo.platformplayer.views.subscriptions.SubscribeButton + +class CreatorViewHolder(private val _viewGroup: ViewGroup, private val _tiny: Boolean) : AnyAdapter.AnyViewHolder( + LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_creator, _viewGroup, false)) { + + private val _textName: TextView; + private val _creatorThumbnail: CreatorThumbnail; + private val _textMetadata: TextView; + private val _buttonSubscribe: SubscribeButton; + private val _platformIndicator: PlatformIndicator; + private var _authorLink: PlatformAuthorLink? = null; + + val onClick = Event1(); + + init { + _textName = _view.findViewById(R.id.text_channel_name); + _creatorThumbnail = _view.findViewById(R.id.creator_thumbnail); + _textMetadata = _view.findViewById(R.id.text_channel_metadata); + _buttonSubscribe = _view.findViewById(R.id.button_subscribe); + _platformIndicator = _view.findViewById(R.id.platform_indicator); + + if (_tiny) { + _buttonSubscribe.visibility = View.GONE; + _textMetadata.visibility = View.GONE; + } + + _view.findViewById(R.id.root).setOnClickListener { + val s = _authorLink ?: return@setOnClickListener; + onClick.emit(s); + } + } + + override fun bind(authorLink: PlatformAuthorLink) { + _textName.text = authorLink.name; + _creatorThumbnail.setThumbnail(authorLink.thumbnail, false); + if(authorLink.subscribers == null || (authorLink.subscribers ?: 0) <= 0L) + _textMetadata.visibility = View.GONE; + else { + _textMetadata.text = authorLink.subscribers!!.toHumanNumber() + " subscribers"; + _textMetadata.visibility = View.VISIBLE; + } + _buttonSubscribe.setSubscribeChannel(authorLink.url); + _platformIndicator.setPlatformFromClientID(authorLink.id.pluginId); + _authorLink = authorLink; + } + + companion object { + private const val TAG = "CreatorViewHolder"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ImportPlaylistsViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ImportPlaylistsViewHolder.kt new file mode 100644 index 00000000..669f987e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ImportPlaylistsViewHolder.kt @@ -0,0 +1,67 @@ +package com.futo.platformplayer.views.adapters.viewholders + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.models.Playlist +import com.futo.platformplayer.views.others.Checkbox +import com.futo.platformplayer.views.adapters.AnyAdapter + +class ImportPlaylistsViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder( + LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_import_playlist, _viewGroup, false)) { + + private val _checkbox: Checkbox; + private val _imageThumbnail: ImageView; + private val _textName: TextView; + private val _textMetadata: TextView; + private val _root: LinearLayout; + private var _playlist: SelectablePlaylist? = null; + + val onSelectedChange = Event1(); + + init { + _checkbox = _view.findViewById(R.id.checkbox); + _imageThumbnail = _view.findViewById(R.id.image_thumbnail); + _textName = _view.findViewById(R.id.text_name); + _textMetadata = _view.findViewById(R.id.text_metadata); + _root = _view.findViewById(R.id.root); + + _checkbox.onValueChanged.subscribe { + _playlist?.selected = it; + _playlist?.let { onSelectedChange.emit(it); }; + }; + + _root.setOnClickListener { + _checkbox.value = !_checkbox.value; + _playlist?.selected = _checkbox.value; + _playlist?.let { onSelectedChange.emit(it); }; + }; + } + + override fun bind(playlist: SelectablePlaylist) { + _textName.text = playlist.playlist.name; + _textMetadata.text = "${playlist.playlist.videos.size} videos"; + _checkbox.value = playlist.selected; + + val thumbnail = playlist.playlist.videos.firstOrNull()?.thumbnails?.getHQThumbnail(); + if (thumbnail != null) + Glide.with(_imageThumbnail) + .load(thumbnail) + .placeholder(R.drawable.placeholder_channel_thumbnail) + .into(_imageThumbnail); + else + Glide.with(_imageThumbnail).clear(_imageThumbnail); + + _playlist = playlist; + } +} + +class SelectablePlaylist( + val playlist: Playlist, + var selected: Boolean = false +) { } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ImportSubscriptionViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ImportSubscriptionViewHolder.kt new file mode 100644 index 00000000..499fd3e2 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ImportSubscriptionViewHolder.kt @@ -0,0 +1,70 @@ +package com.futo.platformplayer.views.adapters.viewholders + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.views.others.Checkbox +import com.futo.platformplayer.views.platform.PlatformIndicator +import com.futo.platformplayer.views.adapters.AnyAdapter + +class ImportSubscriptionViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder( + LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_import_subscription, _viewGroup, false)) { + + private val _checkbox: Checkbox; + private val _imageThumbnail: ImageView; + private val _textName: TextView; + private val _platform: PlatformIndicator; + private val _root: LinearLayout; + private var _channel: SelectableIPlatformChannel? = null; + + val onSelectedChange = Event1(); + + init { + _checkbox = _view.findViewById(R.id.checkbox); + _imageThumbnail = _view.findViewById(R.id.image_channel_thumbnail); + _textName = _view.findViewById(R.id.text_name); + _platform = _view.findViewById(R.id.platform); + _root = _view.findViewById(R.id.root); + + _imageThumbnail.clipToOutline = true; + + _checkbox.onValueChanged.subscribe { + _channel?.selected = it; + _channel?.let { onSelectedChange.emit(it); }; + }; + + _root.setOnClickListener { + _checkbox.value = !_checkbox.value; + _channel?.selected = _checkbox.value; + _channel?.let { onSelectedChange.emit(it); }; + }; + } + + override fun bind(channel: SelectableIPlatformChannel) { + _textName.text = channel.channel.name; + _checkbox.value = channel.selected; + + val thumbnail = channel.channel.thumbnail; + if (thumbnail != null) + Glide.with(_imageThumbnail) + .load(thumbnail) + .placeholder(R.drawable.placeholder_channel_thumbnail) + .into(_imageThumbnail); + else + Glide.with(_imageThumbnail).clear(_imageThumbnail); + + _platform.setPlatformFromClientID(channel.channel.id.pluginId); + _channel = channel; + } +} + +class SelectableIPlatformChannel( + val channel: IPlatformChannel, + var selected: Boolean = false +) { } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionBarViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionBarViewHolder.kt new file mode 100644 index 00000000..9e17d945 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionBarViewHolder.kt @@ -0,0 +1,82 @@ +package com.futo.platformplayer.views.adapters.viewholders + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.polycentric.PolycentricCache +import com.futo.platformplayer.R +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.channels.SerializedChannel +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.dp +import com.futo.platformplayer.models.Subscription +import com.futo.platformplayer.selectBestImage +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.adapters.AnyAdapter +import com.futo.polycentric.core.toURLInfoSystemLinkUrl + +class SubscriptionBarViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder( + LayoutInflater.from(_viewGroup.context).inflate(R.layout.view_subscription_bar_icon, _viewGroup, false)) { + + private val _creatorThumbnail: CreatorThumbnail; + private val _name: TextView; + private var _subscription: Subscription? = null; + private var _channel: SerializedChannel? = null; + + private val _taskLoadProfile = TaskHandler( + StateApp.instance.scopeGetter, + { PolycentricCache.instance.getProfileAsync(it) }) + .success { onProfileLoaded(it, true) } + .exception { + Logger.w(TAG, "Failed to load profile.", it); + }; + + val onClick = Event1(); + + init { + _creatorThumbnail = _view.findViewById(R.id.creator_thumbnail); + _name = _view.findViewById(R.id.text_channel_name); + _view.findViewById(R.id.root).setOnClickListener { + val s = _subscription ?: return@setOnClickListener; + onClick.emit(s); + } + } + + override fun bind(subscription: Subscription) { + _taskLoadProfile.cancel(); + + _channel = subscription.channel; + + val cachedProfile = PolycentricCache.instance.getCachedProfile(subscription.channel.url, true); + if (cachedProfile != null) { + onProfileLoaded(cachedProfile, false); + } else { + _creatorThumbnail.setThumbnail(subscription.channel.thumbnail, false); + _taskLoadProfile.run(subscription.channel.id); + } + + _name.text = subscription.channel.name; + _subscription = subscription; + } + + private fun onProfileLoaded(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) { + val dp_55 = 55.dp(itemView.context.resources) + val avatar = cachedPolycentricProfile?.profile?.systemState?.avatar?.selectBestImage(dp_55 * dp_55) + ?.let { it.toURLInfoSystemLinkUrl(cachedPolycentricProfile.profile.system.toProto(), it.process, cachedPolycentricProfile.profile.systemState.servers.toList()) }; + + if (avatar != null) { + _creatorThumbnail.setThumbnail(avatar, animate); + } else { + _creatorThumbnail.setThumbnail(_channel?.thumbnail, animate); + _creatorThumbnail.setHarborAvailable(cachedPolycentricProfile?.profile != null, animate); + } + } + + companion object { + private const val TAG = "SubscriptionBarViewHolder"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/TabViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/TabViewHolder.kt new file mode 100644 index 00000000..262311cf --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/TabViewHolder.kt @@ -0,0 +1,58 @@ +package com.futo.platformplayer.views.adapters.viewholders + +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.futo.platformplayer.* +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment +import com.futo.platformplayer.views.others.Toggle +import com.futo.platformplayer.views.adapters.AnyAdapter + +data class TabViewHolderData(val buttonDefinition: MenuBottomBarFragment.ButtonDefinition, var enabled: Boolean); + +class TabViewHolder(_viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder( + LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_tab, _viewGroup, false)) { + var data: TabViewHolderData? = null; + + private val _imageDragDrop: ImageView = _view.findViewById(R.id.image_drag_drop); + private val _textTabName: TextView = _view.findViewById(R.id.text_tab_name); + private val _toggleTab: Toggle = _view.findViewById(R.id.toggle_tab); + + val onDragDrop = Event1(); + val onEnableChanged = Event1(); + + init { + _toggleTab.onValueChanged.subscribe { + onEnableChanged.emit(it); + }; + _view.isClickable = true; + _view.setOnClickListener { + val d = data ?: return@setOnClickListener; + if (!d.buttonDefinition.canToggle) { + return@setOnClickListener; + } + + d.enabled = !d.enabled; + _toggleTab.setValue(d.enabled, true); + onEnableChanged.emit(d.enabled); + }; + _imageDragDrop.setOnTouchListener(View.OnTouchListener { v, event -> + if (event.action == MotionEvent.ACTION_DOWN) { + onDragDrop.emit(this); + } + false + }); + } + + override fun bind(i: TabViewHolderData) { + _textTabName.text = _view.context.resources.getString(i.buttonDefinition.string); + _toggleTab.visibility = if (i.buttonDefinition.canToggle) View.VISIBLE else View.GONE; + _toggleTab.setValue(i.enabled, false); + data = i; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/VideoDownloadViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/VideoDownloadViewHolder.kt new file mode 100644 index 00000000..e97434b3 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/VideoDownloadViewHolder.kt @@ -0,0 +1,78 @@ +package com.futo.platformplayer.views.adapters.viewholders + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.* +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.downloads.VideoLocal +import com.futo.platformplayer.images.GlideHelper.Companion.loadThumbnails +import com.futo.platformplayer.states.StateDownloads +import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.views.adapters.AnyAdapter + + +class VideoDownloadViewHolder(_viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder( + LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_downloaded, _viewGroup, false)) { + private var _video: VideoLocal? = null; + + private val _videoName: TextView = _view.findViewById(R.id.downloaded_video_name); + private val _videoImage: ImageView = _view.findViewById(R.id.downloaded_video_image); + private val _videoDuration: TextView = _view.findViewById(R.id.downloaded_video_duration); + private val _videoAuthor: TextView = _view.findViewById(R.id.downloaded_author); + private val _videoInfo: TextView = _view.findViewById(R.id.downloaded_video_info); + private val _videoAddToQueue: ImageButton = _view.findViewById(R.id.button_add_to_queue); + private val _videoDelete: LinearLayout = _view.findViewById(R.id.downloaded_video_delete); + private val _videoExport: LinearLayout = _view.findViewById(R.id.button_export); + private val _videoSize: TextView = _view.findViewById(R.id.downloaded_video_size); + + val onClick = Event1(); + + init { + _view.setOnClickListener { + _video?.let { onClick.emit(it) } + }; + _videoDelete.setOnClickListener { + val id = _video?.id ?: return@setOnClickListener; + UIDialogs.showConfirmationDialog(_view.context, "Are you sure you want to delete this video?", { + StateDownloads.instance.deleteCachedVideo(id); + }); + } + _videoAddToQueue.setOnClickListener { + val v = _video ?: return@setOnClickListener; + StatePlayer.instance.addToQueue(v); + } + _videoExport.setOnClickListener { + val v = _video ?: return@setOnClickListener; + StateDownloads.instance.export(v, v.videoSource.firstOrNull(), v.audioSource.firstOrNull(), v.subtitlesSources.firstOrNull()); + } + } + + override fun bind(video: VideoLocal) { + _video = video; + _videoName.text = video.name; + _videoDuration.text = video.duration.toHumanTime(false); + _videoAuthor.text = video.author.name; + _videoSize.text = (video.videoSource.sumOf { it.fileSize } + video.audioSource.sumOf { it.fileSize }).toHumanBytesSize(false); + + val tokens = arrayListOf(); + + if(video.videoSource.isNotEmpty()) { + tokens.add(video.videoSource.maxBy { it.width * it.height }.let { "${it.width}x${it.height} (${it.container})" }); + } + + if (video.audioSource.isNotEmpty()) { + tokens.add(video.audioSource.maxBy { it.bitrate }.let { it.bitrate.toHumanBitrate() }); + } + + _videoInfo.text =tokens.joinToString(" • "); + + _videoImage.loadThumbnails(video.thumbnails, true) { + it.placeholder(R.drawable.placeholder_video_thumbnail) + .into(_videoImage); + }; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/announcements/AnnouncementView.kt b/app/src/main/java/com/futo/platformplayer/views/announcements/AnnouncementView.kt new file mode 100644 index 00000000..873b02ea --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/announcements/AnnouncementView.kt @@ -0,0 +1,163 @@ +package com.futo.platformplayer.views.announcements + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.* +import androidx.constraintlayout.widget.ConstraintLayout +import com.futo.platformplayer.R +import com.futo.platformplayer.dp +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.Announcement +import com.futo.platformplayer.states.AnnouncementType +import com.futo.platformplayer.states.SessionAnnouncement +import com.futo.platformplayer.states.StateAnnouncement +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.toHumanNowDiffString + +class AnnouncementView : LinearLayout { + private val _root: ConstraintLayout; + private val _textTitle: TextView; + private val _textCounter: TextView; + private val _textBody: TextView; + private val _textClose: TextView; + private val _textNever: TextView; + private val _buttonAction: FrameLayout; + private val _textAction: TextView; + private val _textTime: TextView; + private val _category: String?; + private var _currentAnnouncement: Announcement? = null; + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + inflate(context, R.layout.view_announcement, this); + + val dp10 = 10.dp(resources); + setPadding(dp10, dp10, dp10, dp10); + + _root = findViewById(R.id.root); + _textTitle = findViewById(R.id.text_title); + _textCounter = findViewById(R.id.text_counter); + _textBody = findViewById(R.id.text_body); + _textClose = findViewById(R.id.text_close); + _textNever = findViewById(R.id.text_never); + _buttonAction = findViewById(R.id.button_action); + _textAction = findViewById(R.id.text_action); + _textTime = findViewById(R.id.text_time); + + _buttonAction.setOnClickListener { + val a = _currentAnnouncement ?: return@setOnClickListener; + val scope = StateApp.instance.scopeOrNull ?: return@setOnClickListener; + StateAnnouncement.instance.actionAnnouncement(a); + }; + + _textClose.setOnClickListener { + val a = _currentAnnouncement ?: return@setOnClickListener; + StateAnnouncement.instance.closeAnnouncement(a.id); + refresh(); + }; + + _textNever.setOnClickListener { + val a = _currentAnnouncement ?: return@setOnClickListener; + StateAnnouncement.instance.neverAnnouncement(a.id); + refresh(); + }; + + val attrArr = context.obtainStyledAttributes(attrs, R.styleable.AnnouncementView, 0, 0); + _category = attrArr.getText(R.styleable.AnnouncementView_category)?.toString(); + + refresh(); + } + + override fun onAttachedToWindow() { + Logger.i(TAG, "onAttachedToWindow"); + + super.onAttachedToWindow() + StateAnnouncement.instance.onAnnouncementChanged.subscribe(this) { + refresh(); + } + + refresh(); + } + + override fun onDetachedFromWindow() { + Logger.i(TAG, "onDetachedFromWindow"); + + super.onDetachedFromWindow() + StateAnnouncement.instance.onAnnouncementChanged.remove(this) + } + + private fun refresh() { + Logger.i(TAG, "refresh"); + val announcements = StateAnnouncement.instance.getVisibleAnnouncements(_category); + setAnnouncement(announcements.firstOrNull(), announcements.size); + } + + private fun setAnnouncement(announcement: Announcement?, count: Int) { + Logger.i(TAG, "setAnnouncement announcement=$announcement count=$count"); + + _currentAnnouncement = announcement; + + if (announcement == null) { + _root.visibility = View.GONE; + return; + } + + _root.visibility = View.VISIBLE; + + _textTitle.text = announcement.title; + _textBody.text = announcement.msg; + _textCounter.text = "1/${count}"; + + if (announcement.actionName != null) { + _textAction.text = announcement.actionName; + _buttonAction.visibility = View.VISIBLE; + } else { + _buttonAction.visibility = View.GONE; + } + + if(announcement is SessionAnnouncement) { + if(announcement.cancelName != null) + { + _textClose.text = announcement.cancelName; + } + else + _textClose.text = "Dismiss"; + } + else + _textClose.text = "Dismiss"; + + when (announcement.announceType) { + AnnouncementType.DELETABLE -> { + _textClose.visibility = View.VISIBLE; + _textNever.visibility = View.GONE; + } + AnnouncementType.RECURRING -> { + _textClose.visibility = View.VISIBLE; + _textNever.visibility = View.VISIBLE; + } + AnnouncementType.PERMANENT -> { + _textClose.visibility = View.VISIBLE; + _textNever.visibility = View.GONE; + } + AnnouncementType.SESSION -> { + _textClose.visibility = View.VISIBLE; + _textNever.visibility = View.GONE; + } + AnnouncementType.SESSION_RECURRING -> { + _textClose.visibility = View.VISIBLE; + _textNever.visibility = View.VISIBLE; + } + } + + if (announcement.time != null) { + _textTime.visibility = View.VISIBLE; + _textTime.text = announcement.time.toHumanNowDiffString(true) + " ago" + } else { + _textTime.visibility = View.GONE; + } + } + + companion object { + const val TAG = "AnnouncementView" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/behavior/GestureControlView.kt b/app/src/main/java/com/futo/platformplayer/views/behavior/GestureControlView.kt new file mode 100644 index 00000000..1b3ed5a1 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/behavior/GestureControlView.kt @@ -0,0 +1,531 @@ +package com.futo.platformplayer.views.behavior + +import android.animation.Animator +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import android.content.Context +import android.graphics.drawable.Animatable +import android.util.AttributeSet +import android.view.GestureDetector +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.animation.doOnEnd +import androidx.core.animation.doOnStart +import androidx.core.view.GestureDetectorCompat +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.views.others.CircularProgressBar +import kotlinx.coroutines.* + +class GestureControlView : LinearLayout { + private val _scope = CoroutineScope(Dispatchers.Main); + private val _imageFastForward: ImageView; + private val _textFastForward: TextView; + private val _imageRewind: ImageView; + private val _textRewind: TextView; + private val _layoutFastForward: LinearLayout; + private val _layoutRewind: LinearLayout; + private var _rewinding: Boolean = false; + private var _skipping: Boolean = false; + private var _animatorSetControls: AnimatorSet? = null; + private var _animatorSetFastForward: AnimatorSet? = null; + private var _fastForwardCounter: Int = 1; + private var _jobAutoFastForward: Job? = null; + private var _jobExitFastForward: Job? = null; + private var _jobHideControls: Job? = null; + private var _controlsVisible: Boolean = true; + private var _isControlsLocked: Boolean = false; + private var _layoutControls: ViewGroup? = null; + private var _background: View? = null; + private var _soundFactor = 1.0f; + private var _adjustingSound: Boolean = false; + private val _layoutControlsSound: FrameLayout; + private val _progressSound: CircularProgressBar; + private var _animatorSound: ObjectAnimator? = null; + private var _brightnessFactor = 1.0f; + private var _adjustingBrightness: Boolean = false; + private val _layoutControlsBrightness: FrameLayout; + private val _progressBrightness: CircularProgressBar; + private var _isFullScreen = false; + private var _animatorBrightness: ObjectAnimator? = null; + private val _layoutControlsFullscreen: FrameLayout; + private var _adjustingFullscreen: Boolean = false; + private var _fullScreenFactor = 1.0f; + + val onSeek = Event1(); + val onBrightnessAdjusted = Event1(); + val onSoundAdjusted = Event1(); + val onToggleFullscreen = Event0(); + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { + LayoutInflater.from(context).inflate(R.layout.view_gesture_controls, this, true); + + _imageFastForward = findViewById(R.id.image_fastforward); + _textFastForward = findViewById(R.id.text_fastforward); + _imageRewind = findViewById(R.id.image_rewind); + _textRewind = findViewById(R.id.text_rewind); + _layoutFastForward = findViewById(R.id.layout_controls_fast_forward); + _layoutRewind = findViewById(R.id.layout_controls_rewind); + _layoutControlsSound = findViewById(R.id.layout_controls_sound); + _progressSound = findViewById(R.id.progress_sound); + _layoutControlsBrightness = findViewById(R.id.layout_controls_brightness); + _progressBrightness = findViewById(R.id.progress_brightness); + _layoutControlsFullscreen = findViewById(R.id.layout_controls_fullscreen); + } + + fun setupTouchArea(view: View, layoutControls: ViewGroup? = null, background: View? = null) { + _layoutControls = layoutControls; + _background = background; + + val gestureController = GestureDetectorCompat(context, object : GestureDetector.OnGestureListener { + override fun onDown(p0: MotionEvent): Boolean { return false; } + override fun onShowPress(p0: MotionEvent) = Unit; + override fun onSingleTapUp(p0: MotionEvent): Boolean { return false; } + override fun onScroll(p0: MotionEvent, p1: MotionEvent, distanceX: Float, distanceY: Float): Boolean { + if (_isFullScreen && _adjustingBrightness) { + val adjustAmount = (distanceY * 2) / height; + _brightnessFactor = (_brightnessFactor + adjustAmount).coerceAtLeast(0.0f).coerceAtMost(1.0f); + _progressBrightness.progress = _brightnessFactor; + onBrightnessAdjusted.emit(_brightnessFactor); + } else if (_isFullScreen && _adjustingSound) { + val adjustAmount = (distanceY * 2) / height; + _soundFactor = (_soundFactor + adjustAmount).coerceAtLeast(0.0f).coerceAtMost(1.0f); + _progressSound.progress = _soundFactor; + onSoundAdjusted.emit(_soundFactor); + } else if (_adjustingFullscreen) { + val adjustAmount = (distanceY * 2) / height; + _fullScreenFactor = (_fullScreenFactor + adjustAmount).coerceAtLeast(0.0f).coerceAtMost(1.0f); + _layoutControlsFullscreen.transitionAlpha = _fullScreenFactor; + } else { + val rx = p0.x / width; + val ry = p0.y / height; + Logger.i(TAG, "rx = $rx, ry = $ry, _isFullScreen = $_isFullScreen") + if (ry > 0.1 && ry < 0.9) { + if (_isFullScreen && rx < 0.4) { + startAdjustingBrightness(); + } else if (_isFullScreen && rx > 0.6) { + startAdjustingSound(); + } else if (rx >= 0.4 && rx <= 0.6) { + startAdjustingFullscreen(); + } + } + } + + return true; + } + override fun onLongPress(p0: MotionEvent) = Unit; + override fun onFling(p0: MotionEvent, p1: MotionEvent, p2: Float, p3: Float): Boolean { return false; } + }); + + gestureController.setOnDoubleTapListener(object : GestureDetector.OnDoubleTapListener { + override fun onSingleTapConfirmed(ev: MotionEvent): Boolean { + if (_skipping) { + return false; + } + + if (_controlsVisible) { + hideControls(); + } else { + showControls(); + } + + return true; + } + + override fun onDoubleTap(ev: MotionEvent): Boolean { + if (_isControlsLocked || _skipping) { + return false; + } + + val rewinding = (ev.x / width) < 0.5; + startFastForward(rewinding); + return true; + } + + override fun onDoubleTapEvent(ev: MotionEvent): Boolean { + return false; + } + }); + + val touchListener = object : OnTouchListener { + override fun onTouch(v: View?, ev: MotionEvent): Boolean { + cancelHideJob(); + + if (_skipping) { + if (ev.action == MotionEvent.ACTION_UP) { + startExitFastForward(); + stopAutoFastForward(); + } else if (ev.action == MotionEvent.ACTION_DOWN) { + _jobExitFastForward?.cancel(); + _jobExitFastForward = null; + + startAutoFastForward(); + fastForwardTick(); + } + } + + if (_adjustingSound && ev.action == MotionEvent.ACTION_UP) { + stopAdjustingSound(); + } + + if (_adjustingBrightness && ev.action == MotionEvent.ACTION_UP) { + stopAdjustingBrightness(); + } + + if (_adjustingFullscreen && ev.action == MotionEvent.ACTION_UP) { + if (_fullScreenFactor > 0.5) { + onToggleFullscreen.emit(); + } + stopAdjustingFullscreen(); + } + + startHideJobIfNecessary(); + return gestureController.onTouchEvent(ev); + } + }; + + view.setOnTouchListener(touchListener); + view.isClickable = true; + } + + fun cancelHideJob() { + _jobHideControls?.cancel(); + _jobHideControls = null; + } + + private fun startHideJob() { + _jobHideControls = _scope.launch(Dispatchers.Main) { + try { + ensureActive(); + delay(3000); + ensureActive(); + + hideControls(); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to hide controls", e); + } + }; + } + + fun startHideJobIfNecessary() { + if (_controlsVisible) { + startHideJob(); + } + } + + fun restartHideJob() { + cancelHideJob(); + startHideJobIfNecessary(); + } + + fun showControls(withHideJob: Boolean = true){ + Logger.i(TAG, "showControls()"); + + if (_isControlsLocked) + return; + + if (_controlsVisible) { + return; + } + + _animatorSetControls?.cancel(); + + val animations = arrayListOf(); + _layoutControls?.let { animations.add(ObjectAnimator.ofFloat(it, "alpha", 0.0f, 1.0f).setDuration( + ANIMATION_DURATION_CONTROLS + )); }; + _background?.let { animations.add(ObjectAnimator.ofFloat(it, "alpha", 0.0f, 1.0f).setDuration( + ANIMATION_DURATION_CONTROLS + )); }; + + val animatorSet = AnimatorSet(); + animatorSet.doOnStart { + _background?.visibility = View.VISIBLE; + _layoutControls?.visibility = View.VISIBLE; + }; + animatorSet.doOnEnd { + _animatorSetControls = null; + }; + animatorSet.playTogether(animations); + animatorSet.start(); + _animatorSetControls = animatorSet; + + _controlsVisible = true + if (withHideJob) { + startHideJobIfNecessary(); + } else { + cancelHideJob(); + } + } + + fun hideControls(animate: Boolean = true){ + Logger.v(TAG, "hideControls(animate: $animate)"); + + if (!_controlsVisible) { + return; + } + + stopFastForward(); + + _animatorSetControls?.cancel(); + + if (animate) { + val animations = arrayListOf(); + _layoutControls?.let { animations.add(ObjectAnimator.ofFloat(it, "alpha", 1.0f, 0.0f).setDuration( + ANIMATION_DURATION_CONTROLS + )); }; + _background?.let { animations.add(ObjectAnimator.ofFloat(it, "alpha", 1.0f, 0.0f).setDuration( + ANIMATION_DURATION_CONTROLS + )); }; + + val animatorSet = AnimatorSet(); + animatorSet.doOnEnd { + _background?.visibility = View.GONE; + _layoutControls?.visibility = View.GONE; + _animatorSetControls = null; + }; + + animatorSet.playTogether(animations); + animatorSet.start(); + + _animatorSetControls = animatorSet; + } else { + _background?.visibility = View.GONE; + _layoutControls?.visibility = View.GONE; + } + + _controlsVisible = false; + } + + fun cleanup() { + _jobExitFastForward?.cancel(); + _jobExitFastForward = null; + _jobAutoFastForward?.cancel(); + _jobAutoFastForward = null; + cancelHideJob(); + _scope.cancel(); + } + + private fun startFastForward(rewinding: Boolean) { + _skipping = true; + _rewinding = rewinding; + _fastForwardCounter = 0; + + fastForwardTick(); + startAutoFastForward(); + + _animatorSetFastForward?.cancel(); + + val animations = arrayListOf(); + val layout = if (rewinding) { _layoutRewind } else { _layoutFastForward }; + animations.add(ObjectAnimator.ofFloat(layout, "alpha", 0.0f, 1.0f).setDuration( + ANIMATION_DURATION_FAST_FORWARD + )); + + if (_controlsVisible) { + _layoutControls?.let { animations.add(ObjectAnimator.ofFloat(it, "alpha", 1.0f, 0.0f).setDuration( + ANIMATION_DURATION_FAST_FORWARD + )); }; + } else { + _background?.let { animations.add(ObjectAnimator.ofFloat(it, "alpha", 0.0f, 1.0f).setDuration( + ANIMATION_DURATION_FAST_FORWARD + )); }; + } + + val animatorSet = AnimatorSet(); + animatorSet.doOnStart { + _background?.visibility = View.VISIBLE; + layout.visibility = View.VISIBLE; + }; + animatorSet.doOnEnd { + _animatorSetFastForward = null; + if (_controlsVisible) { + _layoutControls?.visibility = View.GONE; + } + }; + animatorSet.playTogether(animations); + animatorSet.start(); + _animatorSetFastForward = animatorSet; + + if (rewinding) { + (_imageRewind.drawable as Animatable?)?.start(); + } else { + (_imageFastForward.drawable as Animatable?)?.start(); + } + } + private fun stopFastForward() { + _jobExitFastForward?.cancel(); + _jobExitFastForward = null; + stopAutoFastForward(); + + _animatorSetFastForward?.cancel(); + + val animations = arrayListOf(); + val layout = if (_rewinding) { _layoutRewind } else { _layoutFastForward }; + animations.add(ObjectAnimator.ofFloat(layout, "alpha", 1.0f, 0.0f).setDuration( + ANIMATION_DURATION_FAST_FORWARD + )); + + if (_controlsVisible) { + _layoutControls?.let { animations.add(ObjectAnimator.ofFloat(it, "alpha", 0.0f, 1.0f).setDuration( + ANIMATION_DURATION_FAST_FORWARD + )); }; + } else { + _background?.let { animations.add(ObjectAnimator.ofFloat(it, "alpha", 1.0f, 0.0f).setDuration( + ANIMATION_DURATION_FAST_FORWARD + )); }; + } + + val animatorSet = AnimatorSet(); + animatorSet.doOnStart { + if (_controlsVisible) { + _layoutControls?.visibility = View.VISIBLE; + } + }; + animatorSet.doOnEnd { + layout.visibility = View.GONE; + _animatorSetFastForward = null; + (_imageRewind.drawable as Animatable?)?.stop(); + (_imageFastForward.drawable as Animatable?)?.stop(); + }; + + animatorSet.playTogether(animations); + animatorSet.start(); + _animatorSetFastForward = animatorSet; + + _skipping = false; + } + private fun fastForwardTick() { + _fastForwardCounter++; + + val seekOffset: Long = 10000; + if (_rewinding) { + _textRewind.text = "${_fastForwardCounter * 10} seconds"; + onSeek.emit(-seekOffset); + } else { + _textFastForward.text = "${_fastForwardCounter * 10} seconds"; + onSeek.emit(seekOffset); + } + } + private fun startAutoFastForward() { + _jobAutoFastForward?.cancel(); + _jobAutoFastForward = _scope.launch(Dispatchers.Main) { + try { + while (isActive) { + ensureActive(); + delay(300); + ensureActive(); + + fastForwardTick(); + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to execute fast forward tick.", e); + } + }; + } + private fun startExitFastForward() { + _jobExitFastForward?.cancel(); + _jobExitFastForward = _scope.launch(Dispatchers.Main) { + try { + delay(600); + stopFastForward(); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to stop fast forward.", e) + } + }; + } + private fun stopAutoFastForward() { + _jobAutoFastForward?.cancel(); + _jobAutoFastForward = null; + } + + private fun startAdjustingSound() { + _adjustingSound = true; + _progressSound.progress = _soundFactor; + + _layoutControlsSound.visibility = View.VISIBLE; + _animatorSound?.cancel(); + _animatorSound = ObjectAnimator.ofFloat(_layoutControlsSound, "alpha", 0.0f, 1.0f); + _animatorSound?.duration = ANIMATION_DURATION_GESTURE_CONTROLS; + _animatorSound?.start(); + } + + private fun stopAdjustingSound() { + _adjustingSound = false; + + _animatorSound?.cancel(); + _animatorSound = ObjectAnimator.ofFloat(_layoutControlsSound, "alpha", 1.0f, 0.0f); + _animatorSound?.duration = ANIMATION_DURATION_GESTURE_CONTROLS; + _animatorSound?.doOnEnd { _layoutControlsSound.visibility = View.GONE; }; + _animatorSound?.start(); + } + + private fun startAdjustingFullscreen() { + _adjustingFullscreen = true; + _fullScreenFactor = 0f; + _layoutControlsFullscreen.transitionAlpha = 0f; + _layoutControlsFullscreen.visibility = View.VISIBLE; + } + + private fun stopAdjustingFullscreen() { + _adjustingFullscreen = false; + _layoutControlsFullscreen.visibility = View.GONE; + } + + private fun startAdjustingBrightness() { + _adjustingBrightness = true; + _progressBrightness.progress = _brightnessFactor; + + _layoutControlsBrightness.visibility = View.VISIBLE; + _animatorBrightness?.cancel(); + _animatorBrightness = ObjectAnimator.ofFloat(_layoutControlsBrightness, "alpha", 0.0f, 1.0f); + _animatorBrightness?.duration = ANIMATION_DURATION_GESTURE_CONTROLS; + _animatorBrightness?.start(); + } + + private fun stopAdjustingBrightness() { + _adjustingBrightness = false; + + _animatorBrightness?.cancel(); + _animatorBrightness = ObjectAnimator.ofFloat(_layoutControlsBrightness, "alpha", 1.0f, 0.0f); + _animatorBrightness?.duration = ANIMATION_DURATION_GESTURE_CONTROLS; + _animatorBrightness?.doOnEnd { _layoutControlsBrightness.visibility = View.GONE; }; + _animatorBrightness?.start(); + } + + fun setFullscreen(isFullScreen: Boolean) { + if (isFullScreen) { + onBrightnessAdjusted.emit(_brightnessFactor); + onSoundAdjusted.emit(_soundFactor); + } else { + onBrightnessAdjusted.emit(1.0f); + //onSoundAdjusted.emit(1.0f); + stopAdjustingBrightness(); + stopAdjustingSound(); + stopAdjustingFullscreen(); + } + + _isFullScreen = isFullScreen; + } + + fun setSoundFactor(soundFactor: Float) { + _soundFactor = soundFactor; + onSoundAdjusted.emit(_soundFactor); + } + + companion object { + const val ANIMATION_DURATION_GESTURE_CONTROLS: Long = 200; + const val ANIMATION_DURATION_CONTROLS: Long = 400; + const val ANIMATION_DURATION_FAST_FORWARD: Long = 400; + const val EXIT_DURATION_FAST_FORWARD: Long = 600; + const val TAG = "GestureControlView"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/behavior/NonScrollingTextView.kt b/app/src/main/java/com/futo/platformplayer/views/behavior/NonScrollingTextView.kt new file mode 100644 index 00000000..65ef8478 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/behavior/NonScrollingTextView.kt @@ -0,0 +1,85 @@ +package com.futo.platformplayer.views.behavior + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.text.Layout +import android.text.Spannable +import android.text.style.URLSpan +import android.util.AttributeSet +import android.view.MotionEvent +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.others.PlatformLinkMovementMethod +import com.futo.platformplayer.receivers.MediaControlReceiver +import com.futo.platformplayer.timestampRegex + +class NonScrollingTextView : androidx.appcompat.widget.AppCompatTextView { + constructor(context: Context) : super(context) {} + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {} + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {} + + override fun scrollTo(x: Int, y: Int) { + //do nothing + } + + override fun onTouchEvent(event: MotionEvent?): Boolean { + val action = event?.action + Logger.i(TAG, "onTouchEvent (action = $action)"); + + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { + val x = event.x.toInt() + val y = event.y.toInt() + + val layout: Layout? = this.layout + if (layout != null) { + val line = layout.getLineForVertical(y) + val offset = layout.getOffsetForHorizontal(line, x.toFloat()) + + val text = this.text + if (text is Spannable) { + val links = text.getSpans(offset, offset, URLSpan::class.java) + if (links.isNotEmpty()) { + for (link in links) { + Logger.i(PlatformLinkMovementMethod.TAG) { "Link clicked '${link.url}'." }; + + val c = context; + if (c is MainActivity) { + if (c.handleUrl(link.url)) { + continue; + } + + if (timestampRegex.matches(link.url)) { + val tokens = link.url.split(':'); + + var time_s = -1L; + if (tokens.size == 2) { + time_s = tokens[0].toLong() * 60 + tokens[1].toLong(); + } else if (tokens.size == 3) { + time_s = tokens[0].toLong() * 60 * 60 + tokens[1].toLong() * 60 + tokens[2].toLong(); + } + + if (time_s != -1L) { + MediaControlReceiver.onSeekToReceived.emit(time_s * 1000); + continue; + } + } + + c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url))); + } + } + + return true + } + } + } + } + + super.onTouchEvent(event) + return false + } + + companion object { + private const val TAG = "NonScrollingTextView" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/behavior/TouchInterceptFrameLayout.kt b/app/src/main/java/com/futo/platformplayer/views/behavior/TouchInterceptFrameLayout.kt new file mode 100644 index 00000000..563024a6 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/behavior/TouchInterceptFrameLayout.kt @@ -0,0 +1,55 @@ +package com.futo.platformplayer.views.behavior + +import android.content.Context +import android.graphics.Color +import android.util.AttributeSet +import android.view.MotionEvent +import android.widget.FrameLayout +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event0 + +class TouchInterceptFrameLayout : FrameLayout { + var shouldInterceptTouches: Boolean = false; + val onClick = Event0(); + private var _wasDown = false; + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + val attrArr = context.obtainStyledAttributes(attrs, R.styleable.TouchInterceptFrameLayout, 0, 0); + shouldInterceptTouches = attrArr.getBoolean(R.styleable.TouchInterceptFrameLayout_shouldInterceptTouches, false); + } + + override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean { + if (!shouldInterceptTouches) { + return super.onInterceptTouchEvent(ev); + } + + if (!_wasDown && ev?.action == MotionEvent.ACTION_DOWN) { + return true; + } else if (_wasDown && ev?.action == MotionEvent.ACTION_UP) { + return true; + } + + return super.onInterceptTouchEvent(ev); + } + + override fun onTouchEvent(ev: MotionEvent?): Boolean { + if (!shouldInterceptTouches) { + return super.onTouchEvent(ev); + } + + if (!_wasDown && ev?.action == MotionEvent.ACTION_DOWN) { + _wasDown = true; + return true; + } else if (_wasDown && ev?.action == MotionEvent.ACTION_UP) { + _wasDown = false; + onClick.emit(); + return true; + } + + return super.onTouchEvent(ev); + } + + companion object { + val TAG = "TouchInterceptFrameLayout"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/buttons/BigButton.kt b/app/src/main/java/com/futo/platformplayer/views/buttons/BigButton.kt new file mode 100644 index 00000000..6dd179ae --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/buttons/BigButton.kt @@ -0,0 +1,136 @@ +package com.futo.platformplayer.views.buttons + +import android.content.Context +import android.graphics.Bitmap +import android.util.AttributeSet +import android.util.TypedValue +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event0 +import com.google.android.material.imageview.ShapeableImageView +import com.google.android.material.shape.ShapeAppearanceModel + +class BigButton : LinearLayout { + private val _root: LinearLayout; + private val _icon: ShapeableImageView; + private val _textPrimary: TextView; + private val _textSecondary: TextView; + + val onClick = Event0(); + + constructor(context : Context, text: String, subText: String, icon: Int, action: ()->Unit) : super(context) { + inflate(context, R.layout.big_button, this); + _icon = findViewById(R.id.button_icon); + _textPrimary = findViewById(R.id.button_text); + _textSecondary = findViewById(R.id.button_sub_text); + _root = findViewById(R.id.root); + + _textPrimary.text = text; + _textSecondary.text = subText; + _icon.setImageResource(icon); + + _root.setBackgroundResource(R.drawable.background_big_button); + + _root.apply { + isClickable = true; + setOnClickListener { + action(); + onClick.emit(); + }; + } + } + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + inflate(context, R.layout.big_button, this); + _icon = findViewById(R.id.button_icon); + _textPrimary = findViewById(R.id.button_text); + _textSecondary = findViewById(R.id.button_sub_text); + _root = findViewById(R.id.root); + _root.apply { + isClickable = true; + setOnClickListener { + onClick.emit(); + }; + } + + val attrArr = context.obtainStyledAttributes(attrs, R.styleable.BigButton, 0, 0); + val attrIconRef = attrArr.getResourceId(R.styleable.BigButton_buttonIcon, -1); + withIcon(attrIconRef); + + val attrBackgroundRef = attrArr.getResourceId(R.styleable.BigButton_buttonBackground, -1); + withBackground(attrBackgroundRef); + + val attrText = attrArr.getText(R.styleable.BigButton_buttonText) ?: ""; + _textPrimary.text = attrText; + + val attrTextSecondary = attrArr.getText(R.styleable.BigButton_buttonSubText) ?: ""; + _textSecondary.text = attrTextSecondary; + } + + fun withPrimaryText(text: String): BigButton { + _textPrimary.text = text; + return this; + } + + fun withSecondaryText(text: String): BigButton { + _textSecondary.text = text; + return this; + } + + fun withIcon(resourceId: Int, rounded: Boolean = false): BigButton { + if (resourceId != -1) { + _icon.visibility = View.VISIBLE; + _icon.setImageResource(resourceId); + } else + _icon.visibility = View.GONE; + + if (rounded) { + val shapeAppearanceModel = ShapeAppearanceModel().toBuilder() + .setAllCornerSizes(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16.0f, context.resources.displayMetrics)) + .build(); + + _icon.scaleType = ImageView.ScaleType.FIT_CENTER; + _icon.shapeAppearanceModel = shapeAppearanceModel; + } else { + _icon.scaleType = ImageView.ScaleType.CENTER_CROP; + _icon.shapeAppearanceModel = ShapeAppearanceModel(); + } + + return this; + } + + + fun withIcon(bitmap: Bitmap, rounded: Boolean = false): BigButton { + if (bitmap != null) { + _icon.visibility = View.VISIBLE; + _icon.setImageBitmap(bitmap); + } else + _icon.visibility = View.GONE; + + if (rounded) { + val shapeAppearanceModel = ShapeAppearanceModel().toBuilder() + .setAllCornerSizes(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16.0f, context.resources.displayMetrics)) + .build(); + + _icon.scaleType = ImageView.ScaleType.FIT_CENTER; + _icon.shapeAppearanceModel = shapeAppearanceModel; + } else { + _icon.scaleType = ImageView.ScaleType.CENTER_CROP; + _icon.shapeAppearanceModel = ShapeAppearanceModel(); + } + + return this; + } + + fun withBackground(resourceId: Int): BigButton { + if (resourceId != -1) { + _root.visibility = View.VISIBLE; + _root.setBackgroundResource(resourceId); + } else + _root.setBackgroundResource(R.drawable.background_big_button); + + return this; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/buttons/BigButtonGroup.kt b/app/src/main/java/com/futo/platformplayer/views/buttons/BigButtonGroup.kt new file mode 100644 index 00000000..66d3cd67 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/buttons/BigButtonGroup.kt @@ -0,0 +1,35 @@ +package com.futo.platformplayer.views.buttons + +import android.content.Context +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.R + +class BigButtonGroup : LinearLayout { + private val _header: TextView; + private val _buttons: LinearLayout; + + constructor(context: Context) : super(context) { + inflate(context, R.layout.big_button_group, this); + _header = findViewById(R.id.header_title); + _buttons = findViewById(R.id.buttons); + } + constructor(context: Context, header: String, vararg buttons: BigButton) : super(context) { + inflate(context, R.layout.big_button_group, this); + _header = findViewById(R.id.header_title); + _buttons = findViewById(R.id.buttons); + + _header.text = header; + for(button in buttons) + _buttons.addView(button); + } + + fun setText(text: String) { + _header.text = text; + } + fun setButtons(vararg buttons: BigButton) { + _buttons.removeAllViews(); + for(button in buttons) + _buttons.addView(button); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/buttons/DescButton.kt b/app/src/main/java/com/futo/platformplayer/views/buttons/DescButton.kt new file mode 100644 index 00000000..f5e13eae --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/buttons/DescButton.kt @@ -0,0 +1,48 @@ +package com.futo.platformplayer.views.buttons + +import android.content.Context +import android.graphics.Color +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 + +class DescButton : LinearLayout { + + val imageIcon: ImageView; + val textTitle: TextView; + val textDescription: TextView; + + var onClick = Event0(); + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + inflate(context, R.layout.view_desc_button, this); + + imageIcon = findViewById(R.id.image_icon) + textTitle = findViewById(R.id.text_title) + textDescription = findViewById(R.id.text_description) + + val attrArr = context.obtainStyledAttributes(attrs, R.styleable.DescButton, 0, 0); + imageIcon.setImageResource(attrArr.getResourceId(R.styleable.DescButton_desc_icon, 0)) + textTitle.text = attrArr.getText(R.styleable.DescButton_desc_title) ?: ""; + textDescription.text = attrArr.getText(R.styleable.DescButton_desc_description) ?: ""; + + this.setOnClickListener { onClick.emit() } + } + constructor(context: Context, icon: Int, title: String, description: String) : super(context) { + imageIcon = findViewById(R.id.image_icon) + textTitle = findViewById(R.id.text_title) + textDescription = findViewById(R.id.text_description) + + imageIcon.setImageResource(icon); + textTitle.text = title ?: ""; + textDescription.text = description ?: ""; + + this.setOnClickListener { onClick.emit() } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt b/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt new file mode 100644 index 00000000..f187230c --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt @@ -0,0 +1,61 @@ +package com.futo.platformplayer.views.casting + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.content.ContextCompat +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.casting.CastConnectionState +import com.futo.platformplayer.casting.StateCasting +import com.futo.platformplayer.constructs.Event1 + +class CastButton : androidx.appcompat.widget.AppCompatImageButton { + var onClick = Event1>(); + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + setOnClickListener { UIDialogs.showCastingDialog(context); }; + + if (!isInEditMode) { + if (!Settings.instance.casting.enabled) { + visibility = View.GONE; + } + + StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ -> + updateCastState(); + }; + + updateCastState(); + } + } + + private fun updateCastState() { + val c = context ?: return; + val d = StateCasting.instance.activeDevice; + + val activeColor = ContextCompat.getColor(c, R.color.colorPrimary); + val connectingColor = ContextCompat.getColor(c, R.color.gray_c3); + val inactiveColor = ContextCompat.getColor(c, R.color.white); + + if (d != null) { + when (d.connectionState) { + CastConnectionState.CONNECTED -> setColorFilter(activeColor) + CastConnectionState.CONNECTING -> setColorFilter(connectingColor) + CastConnectionState.DISCONNECTED -> setColorFilter(activeColor) + } + } else { + setColorFilter(inactiveColor); + } + } + + fun cleanup() { + setOnClickListener(null); + StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt b/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt new file mode 100644 index 00000000..0c0be08b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt @@ -0,0 +1,192 @@ +package com.futo.platformplayer.views.casting + +import android.content.Context +import android.media.session.PlaybackState +import android.support.v4.media.session.PlaybackStateCompat +import android.util.AttributeSet +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.widget.* +import androidx.constraintlayout.widget.ConstraintLayout +import com.bumptech.glide.Glide +import com.futo.platformplayer.* +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.casting.AirPlayCastingDevice +import com.futo.platformplayer.casting.StateCasting +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.views.behavior.GestureControlView +import com.google.android.exoplayer2.ui.DefaultTimeBar +import com.google.android.exoplayer2.ui.TimeBar +import com.google.android.exoplayer2.ui.TimeBar.OnScrubListener +import kotlinx.coroutines.* + +class CastView : ConstraintLayout { + private val _thumbnail: ImageView; + private val _buttonMinimize: ImageButton; + private val _buttonSettings: ImageButton; + private val _buttonPlay: ImageButton; + private val _buttonPause: ImageButton; + private val _buttonCast: CastButton; + private val _textPosition: TextView; + private val _textDuration: TextView; + private val _textDivider: TextView; + private val _timeBar: DefaultTimeBar; + private val _background: FrameLayout; + private val _gestureControlView: GestureControlView; + private var _scope: CoroutineScope = CoroutineScope(Dispatchers.Main); + private var _updateTimeJob: Job? = null; + private var _inPictureInPicture: Boolean = false; + private var _originalBottomMargin: Int = 0; + + val onMinimizeClick = Event0(); + val onSettingsClick = Event0(); + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { + LayoutInflater.from(context).inflate(R.layout.view_cast, this, true); + + _thumbnail = findViewById(R.id.image_thumbnail); + _buttonMinimize = findViewById(R.id.button_minimize); + _buttonSettings = findViewById(R.id.button_settings); + _buttonPlay = findViewById(R.id.button_play); + _buttonPause = findViewById(R.id.button_pause); + _buttonCast = findViewById(R.id.button_cast); + _textPosition = findViewById(R.id.text_position); + _textDivider = findViewById(R.id.text_divider); + _textDuration = findViewById(R.id.text_duration); + _timeBar = findViewById(R.id.time_progress); + _background = findViewById(R.id.layout_background); + _gestureControlView = findViewById(R.id.gesture_control); + _gestureControlView.setupTouchArea(_background); + _gestureControlView.onSeek.subscribe { + val d = StateCasting.instance.activeDevice ?: return@subscribe; + StateCasting.instance.videoSeekTo(d.expectedCurrentTime + it / 1000); + }; + + _timeBar.addListener(object : OnScrubListener { + override fun onScrubStart(timeBar: TimeBar, position: Long) { + StateCasting.instance.videoSeekTo(position.toDouble()); + } + + override fun onScrubMove(timeBar: TimeBar, position: Long) { + StateCasting.instance.videoSeekTo(position.toDouble()); + } + + override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { + StateCasting.instance.videoSeekTo(position.toDouble()); + } + }); + + _buttonMinimize.setOnClickListener { onMinimizeClick.emit(); }; + _buttonSettings.setOnClickListener { onSettingsClick.emit(); }; + _buttonPlay.setOnClickListener { StateCasting.instance.resumeVideo(); }; + _buttonPause.setOnClickListener { StateCasting.instance.pauseVideo(); }; + + if (!isInEditMode) { + setIsPlaying(false); + } + } + + fun stopTimeJob() { + _updateTimeJob?.cancel(); + _updateTimeJob = null; + } + + fun setIsPlaying(isPlaying: Boolean) { + _updateTimeJob?.cancel(); + + if(isPlaying) { + val d = StateCasting.instance.activeDevice; + if (d is AirPlayCastingDevice) { + _updateTimeJob = _scope.launch { + while (true) { + val device = StateCasting.instance.activeDevice; + if (device == null || !device.isPlaying) { + break; + } + + delay(1000); + setTime((device.expectedCurrentTime * 1000.0).toLong()); + } + } + } + + if (!_inPictureInPicture) { + _buttonPause.visibility = View.VISIBLE; + _buttonPlay.visibility = View.GONE; + } + } + else if (!_inPictureInPicture) { + _buttonPause.visibility = View.GONE; + _buttonPlay.visibility = View.VISIBLE; + } + + val position = StateCasting.instance.activeDevice?.expectedCurrentTime?.times(1000.0)?.toLong(); + + if(StatePlayer.instance.hasMediaSession()) { + StatePlayer.instance.updateMediaSession(null); + StatePlayer.instance.updateMediaSessionPlaybackState(getPlaybackStateCompat(), (position ?: 0)); + } + } + + fun setButtonAlpha(alpha: Float) { + _background.alpha = alpha; + _textPosition.alpha = alpha; + _textDivider.alpha = alpha; + _textDuration.alpha = alpha; + _buttonMinimize.alpha = alpha; + _buttonSettings.alpha = alpha; + _buttonPause.alpha = alpha; + _buttonPlay.alpha = alpha; + _buttonCast.alpha = alpha; + _timeBar.alpha = alpha; + } + + fun setProgressBarOverlayed(isOverlayed: Boolean) { + if(isOverlayed) { + _thumbnail.layoutParams = _thumbnail.layoutParams.apply { + (this as MarginLayoutParams).bottomMargin = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 0f, resources.displayMetrics).toInt(); + }; + } + else { + _thumbnail.layoutParams = _thumbnail.layoutParams.apply { + (this as MarginLayoutParams).bottomMargin = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 7f, resources.displayMetrics).toInt(); + }; + } + } + + fun setVideoDetails(video: IPlatformVideoDetails, position: Long) { + Glide.with(_thumbnail) + .load(video.thumbnails.getHQThumbnail()) + .placeholder(R.drawable.placeholder_video_thumbnail) + .into(_thumbnail); + _textPosition.text = position.toHumanTime(false); + _textDuration.text = video.duration.toHumanTime(false); + _timeBar.setPosition(position); + _timeBar.setDuration(video.duration); + } + + fun setTime(ms: Long) { + _textPosition.text = ms.toHumanTime(true); + _timeBar.setPosition(ms / 1000); + StatePlayer.instance.updateMediaSessionPlaybackState(getPlaybackStateCompat(), ms); + } + + fun cleanup() { + _buttonCast.cleanup(); + _gestureControlView.cleanup(); + _updateTimeJob?.cancel(); + _updateTimeJob = null; + _scope.cancel(); + } + + private fun getPlaybackStateCompat(): Int { + val d = StateCasting.instance.activeDevice ?: return PlaybackState.STATE_NONE; + + return when(d.isPlaying) { + true -> PlaybackStateCompat.STATE_PLAYING; + else -> PlaybackStateCompat.STATE_PAUSED; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/comments/AddCommentView.kt b/app/src/main/java/com/futo/platformplayer/views/comments/AddCommentView.kt new file mode 100644 index 00000000..a364a47e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/comments/AddCommentView.kt @@ -0,0 +1,56 @@ +package com.futo.platformplayer.views.comments + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.* +import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StatePolycentric +import userpackage.Protocol + +class AddCommentView : LinearLayout { + private val _textComment: TextView; + + private var _contextUrl: String? = null + private var _ref: Protocol.Reference? = null + private var _lastClickTime = 0L + + val onCommentAdded = Event1(); + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + LayoutInflater.from(context).inflate(R.layout.view_add_comment, this, true); + + _textComment = findViewById(R.id.edit_comment); + _textComment.setOnClickListener { + val cu = _contextUrl ?: return@setOnClickListener + val ref = _ref ?: return@setOnClickListener + + val now = System.currentTimeMillis() + if (now - _lastClickTime > 3000) { + StatePolycentric.instance.requireLogin(context, "Please login to post a comment") { + try { + UIDialogs.showCommentDialog(context, cu, ref) { onCommentAdded.emit(it) }; + } catch (e: Throwable) { + Logger.w(TAG, "Failed to post comment", e); + UIDialogs.toast(context, "Failed to post comment: " + e.message); + } + }; + + _lastClickTime = now + } + } + } + + fun setContext(contextUrl: String?, ref: Protocol.Reference?) { + _contextUrl = contextUrl; + _ref = ref; + } + + companion object { + const val TAG = "AddCommentView" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/containers/DoubleTapLayout.kt b/app/src/main/java/com/futo/platformplayer/views/containers/DoubleTapLayout.kt new file mode 100644 index 00000000..119663cc --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/containers/DoubleTapLayout.kt @@ -0,0 +1,41 @@ +package com.futo.platformplayer.views.containers + +import android.content.Context +import android.util.AttributeSet +import android.view.GestureDetector +import android.view.MotionEvent +import android.widget.LinearLayout +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet.Constraint +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 + +class DoubleTapLayout : ConstraintLayout { + private var _detector : GestureDetector? = null; + + val onDoubleTap = Event1(); + + constructor(context: Context) : super(context) { + init(); + } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { + init(); + } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { + init(); + } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) { + init(); + } + + fun init(){ + if(!isInEditMode) { + _detector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { + override fun onDoubleTap(p0: MotionEvent): Boolean { + onDoubleTap.emit(p0); + return true; + } + }); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/containers/SingleViewTouchableMotionLayout.kt b/app/src/main/java/com/futo/platformplayer/views/containers/SingleViewTouchableMotionLayout.kt new file mode 100644 index 00000000..ffc507d3 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/containers/SingleViewTouchableMotionLayout.kt @@ -0,0 +1,94 @@ +package com.futo.platformplayer.views.containers + +import android.content.Context +import android.graphics.Rect +import android.util.AttributeSet +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.View +import androidx.constraintlayout.motion.widget.MotionLayout +import com.futo.platformplayer.R + +class SingleViewTouchableMotionLayout(context: Context, attributeSet: AttributeSet? = null) : MotionLayout(context, attributeSet) { + + private val viewToDetectTouch by lazy { + findViewById(R.id.touchContainer) //TODO move to Attributes + } + private val viewRect = Rect() + private var touchStarted = false + private val transitionListenerList = mutableListOf() + + var allowMotion : Boolean = true; + + init { + addTransitionListener(object : TransitionListener { + override fun onTransitionChange(p0: MotionLayout?, p1: Int, p2: Int, p3: Float) { + } + + override fun onTransitionCompleted(p0: MotionLayout?, p1: Int) { + touchStarted = false + } + + override fun onTransitionStarted(p0: MotionLayout?, p1: Int, p2: Int) { + } + + override fun onTransitionTrigger(p0: MotionLayout?, p1: Int, p2: Boolean, p3: Float) { + } + }) + + super.setTransitionListener(object : TransitionListener { + override fun onTransitionChange(p0: MotionLayout?, p1: Int, p2: Int, p3: Float) { + transitionListenerList.filterNotNull() + .forEach { it.onTransitionChange(p0, p1, p2, p3) } + } + + override fun onTransitionCompleted(p0: MotionLayout?, p1: Int) { + transitionListenerList.filterNotNull() + .forEach { it.onTransitionCompleted(p0, p1) } + } + + override fun onTransitionStarted(p0: MotionLayout?, p1: Int, p2: Int) { + } + + override fun onTransitionTrigger(p0: MotionLayout?, p1: Int, p2: Boolean, p3: Float) { + } + }) + + //isInteractionEnabled = false; + } + + override fun setTransitionListener(listener: TransitionListener?) { + addTransitionListener(listener) + } + + override fun addTransitionListener(listener: TransitionListener?) { + transitionListenerList += listener + } + + //This always triggers, workaround calling super.onTouchEvent + //Blocks click events underneath + override fun onInterceptTouchEvent(event: MotionEvent?): Boolean { + if(!allowMotion) + return false; + if(event != null) { + when (event.actionMasked) { + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + touchStarted = false + return super.onTouchEvent(event) && false; + } + } + if (!touchStarted) { + viewToDetectTouch.getHitRect(viewRect); + val isInView = viewRect.contains(event.x.toInt(), event.y.toInt()); + touchStarted = isInView + } + } + return touchStarted && super.onTouchEvent(event) && false; + } + + + //Not triggered on its own due to child views, intercept is used instead. + override fun onTouchEvent(event: MotionEvent): Boolean { + return false; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/fields/ButtonField.kt b/app/src/main/java/com/futo/platformplayer/views/fields/ButtonField.kt new file mode 100644 index 00000000..7964b95e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/fields/ButtonField.kt @@ -0,0 +1,69 @@ +package com.futo.platformplayer.views.fields + +import android.content.Context +import android.util.AttributeSet +import android.widget.* +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event2 +import java.lang.reflect.Field +import java.lang.reflect.Method + +class ButtonField : LinearLayout, IField { + override var descriptor: FormField? = null; + private var _obj : Any? = null; + private var _method : Method? = null; + + override var reference: Any? = null; + + override val obj : Any? get() { + if(this._obj == null) + throw java.lang.IllegalStateException("Can only be called if fromField is used"); + return _obj; + }; + override val field : Field? get() { + return null; + }; + + private val _title : TextView; + private val _subtitle : TextView; + + override val onChanged = Event2(); + + constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs){ + inflate(context, R.layout.field_button, this); + _title = findViewById(R.id.field_title); + _subtitle = findViewById(R.id.field_subtitle); + + setOnClickListener { + if(_method?.parameterCount == 1) + _method?.invoke(_obj, context); + else if(_method?.parameterCount == 2) + _method?.invoke(_obj, context, (if(context is AppCompatActivity) context.lifecycleScope else null)); + else + _method?.invoke(_obj); + } + } + + fun fromMethod(obj : Any, method: Method) : ButtonField { + this._method = method; + this._obj = obj; + + val attrField = method.getAnnotation(FormField::class.java); + if(attrField != null) { + _title.text = attrField.title; + _subtitle.text = attrField.subtitle; + descriptor = attrField; + } + else + _title.text = method.name; + + return this; + } + override fun fromField(obj : Any, field : Field, formField: FormField?) : ButtonField { + throw IllegalStateException("ButtonField should only be used for methods"); + } + override fun setField() { + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/fields/DropdownField.kt b/app/src/main/java/com/futo/platformplayer/views/fields/DropdownField.kt new file mode 100644 index 00000000..6ae96479 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/fields/DropdownField.kt @@ -0,0 +1,139 @@ +package com.futo.platformplayer.views.fields + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.* +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event2 +import java.lang.reflect.Field + +class DropdownField : TableRow, IField { + override var descriptor: FormField? = null; + private var _obj : Any? = null; + private var _field : Field? = null; + + override val obj : Any? get() { + if(this._obj == null) + throw java.lang.IllegalStateException("Can only be called if fromField is used"); + return _obj; + }; + override val field : Field? get() { + if(this._field == null) + throw java.lang.IllegalStateException("Can only be called if fromField is used"); + return _field; + }; + + private var _options : Array = arrayOf("Unset"); + private var _selected : Int = 0; + + private var _isInitFire : Boolean = false; + + private val _title : TextView; + private val _description : TextView; + private val _spinner : Spinner; + + override var reference: Any? = null; + + override val onChanged = Event2(); + + constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs){ + inflate(context, R.layout.field_dropdown, this); + _spinner = findViewById(R.id.field_spinner); + _title = findViewById(R.id.field_title); + _description = findViewById(R.id.field_description); + + _isInitFire = true; + _spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { + if(_isInitFire) { + _isInitFire = false; + return; + } + _selected = pos; + onChanged.emit(this@DropdownField, pos); + } + override fun onNothingSelected(parent: AdapterView<*>?) = Unit + }; + } + + fun asBoolean(name: String, description: String?, obj: Boolean) : DropdownField { + _options = resources.getStringArray(R.array.enabled_disabled_array); + _spinner.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, _options).also { + it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); + }; + _selected = if(obj) 1 else 0; + _spinner.isSelected = false; + _spinner.setSelection(_selected, true); + + _title.text = name; + if(!description.isNullOrBlank()) { + _description.text = description; + _description.visibility = View.VISIBLE; + } + else + _description.visibility = View.GONE; + + return this; + } + + override fun fromField(obj : Any, field : Field, formField: FormField?) : DropdownField { + this._field = field; + this._obj = obj; + + val attrField = formField ?: field.getAnnotation(FormField::class.java); + if(attrField != null) { + _title.text = attrField.title; + descriptor = attrField; + + if(attrField.subtitle.isNotBlank()) { + _description.text = attrField.subtitle; + _description.visibility = View.VISIBLE; + } + else + _description.visibility = View.GONE; + } + else { + _title.text = field.name; + _description.visibility = View.GONE; + } + + + _options = (field.getAnnotation(DropdownFieldOptions::class.java)?.options ?: + field.getAnnotation(DropdownFieldOptionsId::class.java)?.optionsId?.let { resources.getStringArray(it) } ?: + arrayOf("Unset")) + .toList().toTypedArray(); + + if(_options != null){ + _spinner.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, _options).also { + it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); + }; + + if(field.type == Int::class.java) + _selected = field.get(obj) as Int; + else { + val valStr = field.get(obj)?.toString(); + _selected = if (_options.contains(valStr)) _options.indexOf(valStr) else 0; + } + _spinner.isSelected = false; + _spinner.setSelection(_selected, true); + } + return this; + } + override fun setField() { + if(this._field == null) + throw java.lang.IllegalStateException("Can only setField if fromField is used"); + + if(_field?.type == Int::class.java) + _field!!.set(_obj, _selected); + else + _field!!.set(_obj, _options[_selected]); + } +} + +@Target(AnnotationTarget.FIELD) +@Retention(AnnotationRetention.RUNTIME) +annotation class DropdownFieldOptions(vararg val options : String); +@Target(AnnotationTarget.FIELD) +@Retention(AnnotationRetention.RUNTIME) +annotation class DropdownFieldOptionsId(val optionsId : Int); \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/fields/Field.kt b/app/src/main/java/com/futo/platformplayer/views/fields/Field.kt new file mode 100644 index 00000000..e072f455 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/fields/Field.kt @@ -0,0 +1,23 @@ +package com.futo.platformplayer.views.fields + +import com.futo.platformplayer.constructs.Event2 +import java.lang.reflect.Field + + +@Target(AnnotationTarget.FIELD, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class FormField(val title : String, val type : String, val subtitle : String = "", val order : Int = 0, val id : String = "") + +interface IField { + var descriptor: FormField?; + val obj : Any?; + val field : Field?; + + val onChanged : Event2; + + var reference: Any?; + + + fun fromField(obj : Any, field : Field, formField: FormField? = null) : IField; + fun setField(); +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/fields/FieldForm.kt b/app/src/main/java/com/futo/platformplayer/views/fields/FieldForm.kt new file mode 100644 index 00000000..4fb7b7ed --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/fields/FieldForm.kt @@ -0,0 +1,194 @@ +package com.futo.platformplayer.views.fields + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.LinearLayout +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.logging.Logger +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.lang.reflect.Field +import java.lang.reflect.Method +import kotlin.reflect.full.declaredMemberProperties +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.full.hasAnnotation +import kotlin.reflect.jvm.javaField +import kotlin.reflect.jvm.javaMethod +import kotlin.streams.asStream +import kotlin.streams.toList + +class FieldForm : LinearLayout { + + private val _root : LinearLayout; + + val onChanged = Event2(); + + private var _fields : List = arrayListOf(); + + constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs) { + inflate(context, R.layout.field_form, this); + _root = findViewById(R.id.field_form_root); + } + + fun fromObject(obj : Any) { + _root.removeAllViews(); + val newFields = getFieldsFromObject(context, obj); + for(field in newFields) { + if(field !is View) + throw java.lang.IllegalStateException("Only views can be IFields"); + + _root.addView(field as View); + field.onChanged.subscribe { a1, a2 -> + onChanged.emit(a1, a2); + }; + } + _fields = newFields; + } + fun fromPluginSettings(settings: List, values: HashMap, groupTitle: String? = null, groupDescription: String? = null) { + _root.removeAllViews(); + val newFields = getFieldsFromPluginSettings(context, settings, values); + if (newFields.isEmpty()) { + return; + } + + if(groupTitle == null) { + for(field in newFields) { + if(field !is View) + throw java.lang.IllegalStateException("Only views can be IFields"); + field.onChanged.subscribe { field, value -> + onChanged.emit(field, value); + } + _root.addView(field as View); + } + _fields = newFields; + } else { + for(field in newFields) { + field.onChanged.subscribe { field, value -> + onChanged.emit(field, value); + } + } + val group = GroupField(context, groupTitle, groupDescription) + .withFields(newFields); + _root.addView(group as View); + } + } + + fun setObjectValues(){ + val fields = _fields; + for (field in fields) + field.setField(); + } + + fun findField(id: String) : IField? { + for(field in _fields) { + if(field?.descriptor?.id == id) + return field; + else if(field is GroupField) + { + val subField = field.findField(id); + if(subField != null) + return subField; + } + } + return null; + } + + companion object + { + const val DROPDOWN = "dropdown"; + const val GROUP = "group"; + const val READONLYTEXT = "readonlytext"; + const val TOGGLE = "toggle"; + const val BUTTON = "button"; + + private val _json = Json {}; + + + fun getFieldsFromPluginSettings(context: Context, settings: List, values: HashMap): List { + val fields = mutableListOf() + + for(setting in settings) { + val field = when(setting.type.lowercase()) { + "boolean" -> { + val value = if(values.containsKey(setting.variableOrName)) values[setting.variableOrName] else setting.default; + val field = ToggleField(context).withValue(setting.name, + setting.description, + value == "true" || value == "1" || value == "True"); + field.onChanged.subscribe { field, value -> + values[setting.variableOrName] = _json.encodeToString (value == 1 || value == true); + } + field; + } + else -> null; + } + + if(field != null) + fields.add(field); + } + return fields; + } + + fun getFieldsFromObject(context : Context, obj : Any) : List { + val objFields = obj::class.declaredMemberProperties + .asSequence() + .asStream() + .filter { it.hasAnnotation() && it.javaField != null } + .map { Pair(it.javaField!!, it.findAnnotation()!!) } + .toList() + + val fields = mutableListOf(); + for(prop in objFields) { + prop.first.isAccessible = true; + + val field = when(prop.second.type) { + GROUP -> GroupField(context).fromField(obj, prop.first, prop.second); + DROPDOWN -> DropdownField(context).fromField(obj, prop.first, prop.second); + TOGGLE -> ToggleField(context).fromField(obj, prop.first, prop.second); + READONLYTEXT -> ReadOnlyTextField(context).fromField(obj, prop.first, prop.second); + else -> throw java.lang.IllegalStateException("Unknown field type ${prop.second.type} for ${prop.second.title}") + } + fields.add(field as IField); + } + + val objProps = obj::class.declaredMemberProperties + .asSequence() + .asStream() + .filter { it.hasAnnotation() && it.javaField == null && it.getter.javaMethod != null} + .map { Pair(it.getter.javaMethod!!, it.findAnnotation()!!) } + .toList(); + + for(prop in objProps) { + prop.first.isAccessible = true; + + val field = when(prop.second.type) { + READONLYTEXT -> ReadOnlyTextField(context).fromProp(obj, prop.first, prop.second); + else -> continue; + } + fields.add(field as IField); + } + + //TODO: replace java.declaredMethods with declaredMemberFunctions instead of filtering out get/set + val objMethods = obj::class.java.declaredMethods + .asSequence() + .asStream() + .filter { it.getAnnotation(FormField::class.java) != null && !it.name.startsWith("get") && !it.name.startsWith("set") } + .map { Pair(it, it.getAnnotation(FormField::class.java)) } + .toList(); + + for(meth in objMethods) { + meth.first.isAccessible = true; + + val field = when(meth.second.type) { + BUTTON -> ButtonField(context).fromMethod(obj, meth.first); + else -> throw java.lang.IllegalStateException("Unknown method type ${meth.second.type} for ${meth.second.title}") + } + fields.add(field as IField); + } + + return fields.sortedBy { it.descriptor?.order }.toList(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/fields/GroupField.kt b/app/src/main/java/com/futo/platformplayer/views/fields/GroupField.kt new file mode 100644 index 00000000..d26e09fe --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/fields/GroupField.kt @@ -0,0 +1,141 @@ +package com.futo.platformplayer.views.fields + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event2 +import java.lang.reflect.Field + +class GroupField : LinearLayout, IField { + override var descriptor : FormField? = null; + private var _obj : Any? = null; + private var _field : Field? = null; + + private var _fields : List = listOf(); + + override val obj : Any? get() { + if(this._obj == null) + throw java.lang.IllegalStateException("Can only be called if fromField is used"); + return _obj; + }; + override val field : Field? get() { + if(this._field == null) + throw java.lang.IllegalStateException("Can only be called if fromField is used"); + return _field; + }; + + override val onChanged = Event2(); + + private val _title : TextView; + private val _subtitle : TextView; + private val _container : LinearLayout; + + override var reference: Any? = null; + + constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs) { + inflate(context, R.layout.field_group, this); + _title = findViewById(R.id.field_group_title); + _subtitle = findViewById(R.id.field_group_subtitle); + _container = findViewById(R.id.field_group_container); + + _title.visibility = GONE; + } + + constructor(context: Context, title: String, description: String? = null) : super(context) { + inflate(context, R.layout.field_group, this); + _title = findViewById(R.id.field_group_title); + _subtitle = findViewById(R.id.field_group_subtitle); + _container = findViewById(R.id.field_group_container); + + _title.text = title; + _subtitle.text = description ?: ""; + + if(!(_title.text?.isEmpty() ?: true)) + _title.visibility = VISIBLE; + else + _title.visibility = GONE; + if(!(_subtitle.text?.isEmpty() ?: true)) + _subtitle.visibility = VISIBLE; + else + _subtitle.visibility = GONE; + } + + fun findField(id: String) : IField? { + for(field in _fields) { + if(field.descriptor?.id == id) + return field; + else if(field is GroupField) + { + val subField = field.findField(id); + if(subField != null) + return subField; + } + } + return null; + } + + fun withFields(fields: List): GroupField { + _container.removeAllViews(); + val newFields = mutableListOf() + for(field in fields) { + if(!(field is View)) + throw java.lang.IllegalStateException("Only views can be IFields"); + + field.onChanged.subscribe(onChanged::emit); + _container.addView(field as View); + newFields.add(field); + } + _fields = newFields; + + return this; + } + + override fun fromField(obj : Any, field : Field, formField: FormField?) : GroupField { + this._field = field; + this._obj = obj; + + val value = field.get(obj); + + val attrField = formField ?: field.getAnnotation(FormField::class.java); //TODO: Get this to work as default + if(attrField != null) { + _title.text = attrField.title; + _subtitle.text = attrField.subtitle; + descriptor = attrField; + } + else + _title.text = field.name; + + _container.removeAllViews(); + val newFields = mutableListOf() + for(field in FieldForm.getFieldsFromObject(context, value)) { + if(!(field is View)) + throw java.lang.IllegalStateException("Only views can be IFields"); + + field.onChanged.subscribe(onChanged::emit); + _container.addView(field as View); + newFields.add(field); + } + _fields = newFields; + + if(!(_title.text?.isEmpty() ?: true)) + _title.visibility = VISIBLE; + else + _title.visibility = GONE; + if(!(_subtitle.text?.isEmpty() ?: true)) + _subtitle.visibility = VISIBLE; + else + _subtitle.visibility = GONE; + return this; + } + override fun setField() { + if(this._field == null) + throw java.lang.IllegalStateException("Can only setField if fromField is used"); + + for(field in _fields){ + field.setField(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/fields/ReadOnlyTextField.kt b/app/src/main/java/com/futo/platformplayer/views/fields/ReadOnlyTextField.kt new file mode 100644 index 00000000..baeaa31a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/fields/ReadOnlyTextField.kt @@ -0,0 +1,77 @@ +package com.futo.platformplayer.views.fields + +import android.content.Context +import android.util.AttributeSet +import android.widget.* +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event2 +import java.lang.reflect.Field +import java.lang.reflect.Method + +class ReadOnlyTextField : TableRow, IField { + override var descriptor : FormField? = null; + private var _obj : Any? = null; + private var _field : Field? = null; + + override val obj : Any? get() { + if(this._obj == null) + throw java.lang.IllegalStateException("Can only be called if fromField is used"); + return _obj; + }; + override val field : Field? get() { + if(this._field == null) + throw java.lang.IllegalStateException("Can only be called if fromField is used"); + return _field; + }; + + private val _title : TextView; + private val _value : TextView; + + override val onChanged = Event2(); + + override var reference: Any? = null; + constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs){ + inflate(context, R.layout.field_readonly_text, this); + _title = findViewById(R.id.field_title); + _value = findViewById(R.id.field_value); + } + + override fun fromField(obj : Any, field : Field, formField: FormField?) : ReadOnlyTextField { + this._field = field; + this._obj = obj; + + val attrField = formField ?: field.getAnnotation(FormField::class.java); + if(attrField != null) { + _title.text = attrField.title; + descriptor = attrField; + } + else + _title.text = field.name; + + if(field.type == String::class.java) + _value.text = field.get(obj) as String; + else + _value.text = field.get(obj).toString(); + return this; + } + fun fromProp(obj : Any, field : Method, formField: FormField?) : ReadOnlyTextField { + this._field = null; + this._obj = obj; + + val attrField = formField ?: field.getAnnotation(FormField::class.java); + if(attrField != null) { + _title.text = attrField.title; + descriptor = attrField; + } + else + _title.text = field.name; + + if(field.returnType == String::class.java) + _value.text = field.invoke(obj) as String; + else + _value.text = field.invoke(obj)?.toString() ?: ""; + return this; + } + override fun setField() { + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/fields/ToggleField.kt b/app/src/main/java/com/futo/platformplayer/views/fields/ToggleField.kt new file mode 100644 index 00000000..0750c869 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/fields/ToggleField.kt @@ -0,0 +1,103 @@ +package com.futo.platformplayer.views.fields + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.* +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.views.others.Toggle +import java.lang.reflect.Field + +class ToggleField : TableRow, IField { + override var descriptor: FormField? = null; + private var _obj : Any? = null; + private var _field : Field? = null; + + override val obj : Any? get() { + if(this._obj == null) + throw java.lang.IllegalStateException("Can only be called if fromField is used"); + return _obj; + }; + override val field : Field? get() { + if(this._field == null) + throw java.lang.IllegalStateException("Can only be called if fromField is used"); + return _field; + }; + + private val _title : TextView; + private val _description : TextView; + private val _toggle : Toggle; + + override var reference: Any? = null; + + override val onChanged = Event2(); + + constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs){ + inflate(context, R.layout.field_toggle, this); + _toggle = findViewById(R.id.field_toggle); + _title = findViewById(R.id.field_title); + _description = findViewById(R.id.field_description); + + _toggle.onValueChanged.subscribe { + onChanged.emit(this, it); + }; + } + + fun withValue(title: String, description: String?, value: Boolean): ToggleField { + + _title.text = title; + _description.text = description; + if(!description.isNullOrEmpty()) + _description.visibility = View.VISIBLE; + else + _description.visibility = View.GONE; + + _toggle.setValue(value, true); + + return this; + } + + override fun fromField(obj : Any, field : Field, formField: FormField?) : ToggleField { + this._field = field; + this._obj = obj; + + val attrField = formField ?: field.getAnnotation(FormField::class.java); + if(attrField != null) { + _title.text = attrField.title; + descriptor = attrField; + } + else + _title.text = field.name; + + if(attrField?.subtitle?.isEmpty() != false) + _description.visibility = View.GONE; + else { + _description.text = attrField.subtitle; + _description.visibility = View.VISIBLE; + } + + val value = field.get(obj); + if(value is Boolean) + _toggle.setValue(value, true); + else if(value is Number) + _toggle.setValue((value as Number).toInt() > 0, true); + else if(value == null) + _toggle.setValue(false, true); + else + _toggle.setValue(false, true); + + return this; + } + override fun setField() { + if(this._field == null) + throw java.lang.IllegalStateException("Can only setField if fromField is used"); + + if(_field?.type == Int::class.java) + _field!!.set(_obj, if(_toggle.value) 1 else 0); + else if(_field?.type == Boolean::class.java) + _field!!.set(_obj, _toggle.value); + else + _field!!.set(_obj, _toggle.value); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/items/ActiveDownloadItem.kt b/app/src/main/java/com/futo/platformplayer/views/items/ActiveDownloadItem.kt new file mode 100644 index 00000000..f4e27d33 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/items/ActiveDownloadItem.kt @@ -0,0 +1,139 @@ +package com.futo.platformplayer.views.items + +import android.content.Context +import android.graphics.Color +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.bumptech.glide.Glide +import com.futo.platformplayer.* +import com.futo.platformplayer.downloads.VideoDownload +import com.futo.platformplayer.images.GlideHelper.Companion.crossfade +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateDownloads +import com.futo.platformplayer.views.others.ProgressBar +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class ActiveDownloadItem: LinearLayout { + private var _finalized: Boolean = false; + private val _download: VideoDownload; + + private val _videoName: TextView; + private val _videoImage: ImageView; + private val _videoSize: TextView; + private val _videoDuration: TextView; + private val _videoAuthor: TextView; + private val _videoInfo: TextView; + private val _videoBar: ProgressBar; + private val _videoSpeed: TextView; + private val _videoState: TextView; + + private val _videoCancel: TextView; + + private val _scope: CoroutineScope; + + constructor(context: Context, download: VideoDownload, lifetimeScope: CoroutineScope): super(context) { + inflate(context, R.layout.list_download, this) + _scope = lifetimeScope; + _download = download; + + _videoName = findViewById(R.id.downloaded_video_name); + _videoImage = findViewById(R.id.downloaded_video_image); + _videoSize = findViewById(R.id.downloaded_video_size); + _videoDuration = findViewById(R.id.downloaded_video_duration); + _videoAuthor = findViewById(R.id.downloaded_author); + _videoInfo = findViewById(R.id.downloaded_video_info); + _videoBar = findViewById(R.id.download_video_progress); + _videoState = findViewById(R.id.download_video_state); + _videoSpeed = findViewById(R.id.download_video_speed); + + _videoCancel = findViewById(R.id.download_cancel); + + _videoName.text = download.name; + _videoDuration.text = download.videoEither.duration.toHumanTime(false); + _videoAuthor.text = download.videoEither.author.name; + + _videoState.setOnClickListener { + UIDialogs.toast(context, _videoState.text.toString(), false); + } + + Glide.with(_videoImage) + .load(download.thumbnail) + .crossfade() + .into(_videoImage); + + updateDownloadUI(); + + _videoCancel.setOnClickListener { + StateDownloads.instance.removeDownload(_download); + }; + + _download.onProgressChanged.subscribe(this) { + _scope.launch(Dispatchers.Main) { + try { + updateDownloadUI() + } catch (e: Throwable) { + Logger.e(TAG, "Failed to update download UI.", e); + } + } + }; + _download.onStateChanged.subscribe(this) { + _scope.launch(Dispatchers.Main) { + try { + updateDownloadUI() + } catch (e: Throwable) { + Logger.e(TAG, "Failed to update download UI.", e); + } + } + } + } + + fun finalize() { + _finalized = true; + _download.onProgressChanged.remove(this); + _download.onStateChanged.remove(this); + } + + fun updateDownloadUI() { + _videoInfo.text = _download.getDownloadInfo(); + + val size = (_download.videoFileSize ?: 0) + (_download.audioFileSize ?: 0); + if(size > 0) + _videoSize.text = size.toHumanBytesSize(false); + else + _videoSize.text = "?"; + + _videoBar.progress = _download.progress.toFloat(); + _videoSpeed.text = "${_download.downloadSpeed.toHumanBytesSpeed()} ${(_download.progress * 100).toInt()}%"; + + _videoState.text = if(!Settings.instance.downloads.shouldDownload()) + "Waiting for unmetered" + (if(!_download.error.isNullOrEmpty()) "\n(Last error: " + _download.error + ")" else ""); + else if(_download.state == VideoDownload.State.QUEUED && !_download.error.isNullOrEmpty()) + _download.state.toString() + "\n(Last error: " + _download.error + ")"; + else + _download.state.toString(); + _videoState.setTextColor(Color.GRAY); + when(_download.state) { + VideoDownload.State.DOWNLOADING -> { + _videoBar.visibility = VISIBLE; + _videoSpeed.visibility = VISIBLE; + }; + VideoDownload.State.ERROR -> { + _videoState.setTextColor(Color.RED); + _videoState.text = _download.error ?: "Error"; + _videoBar.visibility = GONE; + _videoSpeed.visibility = GONE; + } + else -> { + _videoBar.visibility = GONE; + _videoSpeed.visibility = GONE; + } + } + } + + companion object { + private const val TAG = "ActiveDownloadItem" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/items/PlaylistDownloadItem.kt b/app/src/main/java/com/futo/platformplayer/views/items/PlaylistDownloadItem.kt new file mode 100644 index 00000000..22975583 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/items/PlaylistDownloadItem.kt @@ -0,0 +1,25 @@ +package com.futo.platformplayer.views.items + +import android.content.Context +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.images.GlideHelper.Companion.crossfade +import com.futo.platformplayer.models.PlaylistDownloaded + +class PlaylistDownloadItem(context: Context, val playlist: PlaylistDownloaded): LinearLayout(context) { + init { inflate(context, R.layout.list_downloaded_playlist, this) } + + var imageView: ImageView = findViewById(R.id.downloaded_playlist_image); + var imageText: TextView = findViewById(R.id.downloaded_playlist_name); + + init { + imageText.text = playlist.playlist.name; + Glide.with(imageView) + .load(playlist.playlist.videos.firstOrNull()?.thumbnails?.getHQThumbnail()) + .crossfade() + .into(imageView); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt b/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt new file mode 100644 index 00000000..d412a40a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt @@ -0,0 +1,85 @@ +package com.futo.platformplayer.views.lists + +import android.content.Context +import android.util.AttributeSet +import android.widget.FrameLayout +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.views.adapters.ItemMoveCallback +import com.futo.platformplayer.views.adapters.VideoListEditorAdapter +import java.util.* + +class VideoListEditorView : FrameLayout { + private val _videos : ArrayList = ArrayList(); + + private var _adapterVideos: VideoListEditorAdapter? = null; + + val onVideoOrderChanged = Event1>() + val onVideoRemoved = Event1(); + val onVideoClicked = Event1(); + val isEmpty get() = _videos.isEmpty(); + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + val recyclerPlaylist = RecyclerView(context, attrs); + recyclerPlaylist.isSaveEnabled = false; + + recyclerPlaylist.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); + addView(recyclerPlaylist); + + val callback = ItemMoveCallback(); + val touchHelper = ItemTouchHelper(callback); + val adapterVideos = VideoListEditorAdapter(touchHelper); + recyclerPlaylist.adapter = adapterVideos; + recyclerPlaylist.layoutManager = LinearLayoutManager(context); + touchHelper.attachToRecyclerView(recyclerPlaylist); + + callback.onRowMoved.subscribe { fromPosition, toPosition -> + synchronized(_videos) { + if (fromPosition < toPosition) { + for (i in fromPosition until toPosition) + Collections.swap(_videos, i, i + 1) + } + else { + for (i in fromPosition downTo toPosition + 1) + Collections.swap(_videos, i, i - 1) + } + onVideoOrderChanged.emit(_videos.toList()); + adapterVideos.notifyItemMoved(fromPosition, toPosition); + } + }; + + adapterVideos.onRemove.subscribe { v -> + synchronized(_videos) { + val index = _videos.indexOf(v); + if(index >= 0) { + _videos.removeAt(index); + onVideoRemoved.emit(v); + } + adapterVideos.notifyItemRemoved(index); + } + }; + adapterVideos.onClick.subscribe(onVideoClicked::emit); + + _adapterVideos = adapterVideos; + } + + fun setVideos(videos: List?, canEdit: Boolean) { + synchronized(_videos) { + _videos.clear(); + _videos.addAll(videos ?: listOf()); + _adapterVideos?.setVideos(_videos, canEdit); + } + } + + fun addVideos(videos: List) { + synchronized(_videos) { + val index = _videos.size; + _videos.addAll(videos); + _adapterVideos?.notifyItemRangeInserted(index, videos.size); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatDonationListItem.kt b/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatDonationListItem.kt new file mode 100644 index 00000000..05577fcb --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatDonationListItem.kt @@ -0,0 +1,117 @@ +package com.futo.platformplayer.views.livechat + +import android.graphics.Color +import android.graphics.drawable.LevelListDrawable +import android.text.Spannable +import android.text.style.ImageSpan +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.graphics.blue +import androidx.core.graphics.green +import androidx.core.graphics.red +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.live.ILiveEventChatMessage +import com.futo.platformplayer.api.media.models.live.LiveEventComment +import com.futo.platformplayer.api.media.models.live.LiveEventDonation +import com.futo.platformplayer.dp +import com.futo.platformplayer.isHexColor +import com.futo.platformplayer.views.adapters.AnyAdapter +import com.futo.platformplayer.views.overlays.LiveChatOverlay +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class LiveChatDonationListItem(viewGroup: ViewGroup) + : LiveChatListItem(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_chat_donation, viewGroup, false)) { + private var _liveEvent: ILiveEventChatMessage? = null; + + private val _authorImage: ImageView = _view.findViewById(R.id.image_thumbnail); + private val _authorName: TextView = _view.findViewById(R.id.text_author); + private val _authorMessage: TextView = _view.findViewById(R.id.text_body); + + private val _amountContainer: LinearLayout = _view.findViewById(R.id.donation_amount_container); + private val _amount: TextView = _view.findViewById(R.id.donation_amount); + + override fun bind(chat: LiveChatOverlay.ChatMessage) { + val event = chat.event; + + _liveEvent = event; + if(event.thumbnail.isNullOrEmpty()) + _authorImage.visibility = View.GONE; + else { + Glide.with(_authorImage) + .load(event.thumbnail) + .into(_authorImage); + _authorImage.visibility = View.VISIBLE; + } + _authorName.text = event.name; + + if(event is LiveEventDonation) { + _amountContainer.visibility = View.VISIBLE; + _amount.text = event.amount.trim(); + + if(event.colorDonation != null && event.colorDonation.isHexColor()) { + val color = Color.parseColor(event.colorDonation); + _amountContainer.background.setTint(color); + + if((color.green > 140 || color.red > 140 || color.blue > 140) && (color.red + color.green + color.blue) > 400) + _amount.setTextColor(Color.BLACK); + else + _amount.setTextColor(Color.WHITE); + } + else { + _amountContainer.background.setTint(Color.parseColor("#2A2A2A")); + _amount.setTextColor(Color.WHITE); + } + } + else + _amountContainer.visibility = View.GONE; + + //Injects emotes + if(!chat.manager.let { liveChat -> + val emojiMatches = REGEX_EMOJIS.findAll(event.message).toList(); + val span = _spanFactory.newSpannable(event.message); + var injected = 0; + + for(emoji in emojiMatches + .filter { it.groupValues.size > 1 && liveChat.hasEmoji(it.groupValues[1]) } + .groupBy { it.groupValues[1] }) { + val emojiVal = emoji.key; + val drawable = LevelListDrawable(); + + for(match in emoji.value) + span.setSpan(ImageSpan(drawable), match.range.first, match.range.last + 1, Spannable.SPAN_INCLUSIVE_INCLUSIVE); + + liveChat.getEmoji(emojiVal) { emojiDrawable -> + if(emojiDrawable != null) { + drawable.addLevel(1, 1, emojiDrawable); + val iconSize = 20.dp(_view.resources); + drawable.setBounds(0, 0, iconSize, iconSize); + drawable.setLevel(1); + if (_liveEvent == event) + chat.scope.launch(Dispatchers.Main) { + _authorMessage.setText(span, TextView.BufferType.SPANNABLE); + } + } + }; + injected++; + } + if(injected > 0) { + _authorMessage.setText(span, TextView.BufferType.SPANNABLE); + return@let true; + } else + return@let false; + }) + _authorMessage.text = event.message; + } + + + companion object { + val REGEX_EMOJIS = Regex("__(.*?)__"); + private val _spanFactory = Spannable.Factory.getInstance(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatDonationPill.kt b/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatDonationPill.kt new file mode 100644 index 00000000..02619424 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatDonationPill.kt @@ -0,0 +1,66 @@ +package com.futo.platformplayer.views.livechat + +import android.content.Context +import android.graphics.Color +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.graphics.blue +import androidx.core.graphics.green +import androidx.core.graphics.red +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.live.LiveEventDonation +import com.futo.platformplayer.isHexColor + +class LiveChatDonationPill: LinearLayout { + private val _imageAuthor: ImageView; + private val _textAmount: TextView; + + private val _expireBar: View; + + constructor(context: Context, donation: LiveEventDonation) : super(context) { + inflate(context, R.layout.list_donation, this) + _imageAuthor = findViewById(R.id.donation_author_image); + _textAmount = findViewById(R.id.donation_amount) + + _textAmount.text = donation.amount; + _expireBar = findViewById(R.id.expire_bar); + _textAmount.text = donation.amount; + + val root = findViewById(R.id.root); + + + if(donation.colorDonation != null && donation.colorDonation.isHexColor()) { + val color = Color.parseColor(donation.colorDonation); + root.background.setTint(color); + + if((color.green > 140 || color.red > 140 || color.blue > 140) && (color.red + color.green + color.blue) > 400) + _textAmount.setTextColor(Color.BLACK); + else + _textAmount.setTextColor(Color.WHITE); + } + else { + root.background.setTint(Color.parseColor("#2A2A2A")); + _textAmount.setTextColor(Color.WHITE); + } + + if(donation.thumbnail.isNullOrEmpty()) + _imageAuthor.visibility = View.GONE; + else + Glide.with(_imageAuthor) + .load(donation.thumbnail) + .circleCrop() + .into(_imageAuthor); + } + + fun animateExpire(ms: Int) { + _expireBar.scaleX = 1f; + _expireBar.animate() + .scaleX(0f) + .translationXBy(-1f) + .setDuration(ms.toLong() + 500) + .start(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatListAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatListAdapter.kt new file mode 100644 index 00000000..eecc6d7e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatListAdapter.kt @@ -0,0 +1,66 @@ +package com.futo.platformplayer.views.livechat + +import android.content.Context +import android.view.* +import android.widget.LinearLayout +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.live.LiveEventComment +import com.futo.platformplayer.api.media.models.live.LiveEventDonation +import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder +import com.futo.platformplayer.views.adapters.EmptyPreviewViewHolder +import com.futo.platformplayer.views.overlays.LiveChatOverlay + +class LiveChatListAdapter : RecyclerView.Adapter { + + private val _dataSet: ArrayList; + + + constructor(context: Context, dataSet: ArrayList): super() { + this._dataSet = dataSet; + } + + override fun getItemCount(): Int = _dataSet.size; + override fun getItemViewType(position: Int): Int { + if (position < 0) { + return -1; + } + val item = _dataSet.getOrNull(position) ?: return -1; + + if(item.event is LiveEventComment) + return 1; + else if(item.event is LiveEventDonation) + return 2; + else + return -1; + + } + + override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): LiveChatListItem { + return when(viewType) { + 1 -> createLiveChatListItem(viewGroup); + 2 -> createLiveChatDonationListItem(viewGroup); + else -> EmptyItem(viewGroup); + }; + } + + private fun createLiveChatDonationListItem(viewGroup: ViewGroup): LiveChatDonationListItem = LiveChatDonationListItem(viewGroup).apply { + } + private fun createLiveChatListItem(viewGroup: ViewGroup): LiveChatListItem = LiveChatMessageListItem(viewGroup).apply { + }; + + override fun onBindViewHolder(holder: LiveChatListItem, position: Int) { + val value = _dataSet[position]; + + holder.bind(value); + } + + companion object { + private val TAG = "LiveChatListAdapter"; + } + + class EmptyItem(viewGroup: ViewGroup): LiveChatListItem(LinearLayout(viewGroup.context)) { + override fun bind(chat: LiveChatOverlay.ChatMessage) {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatListItem.kt b/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatListItem.kt new file mode 100644 index 00000000..88f5a68a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatListItem.kt @@ -0,0 +1,25 @@ +package com.futo.platformplayer.views.livechat + +import android.graphics.Color +import android.graphics.drawable.LevelListDrawable +import android.text.Spannable +import android.text.style.ImageSpan +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.live.LiveEventComment +import com.futo.platformplayer.dp +import com.futo.platformplayer.views.adapters.AnyAdapter +import com.futo.platformplayer.views.overlays.LiveChatOverlay +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +abstract class LiveChatListItem(view: View): RecyclerView.ViewHolder(view) { + protected val _view = view; + abstract fun bind(chat: LiveChatOverlay.ChatMessage); +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatMessageListItem.kt b/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatMessageListItem.kt new file mode 100644 index 00000000..ffe7f1b3 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatMessageListItem.kt @@ -0,0 +1,132 @@ +package com.futo.platformplayer.views.livechat + +import android.graphics.Color +import android.graphics.drawable.LevelListDrawable +import android.text.Spannable +import android.text.style.ImageSpan +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.live.ILiveEventChatMessage +import com.futo.platformplayer.api.media.models.live.LiveEventComment +import com.futo.platformplayer.dp +import com.futo.platformplayer.views.adapters.AnyAdapter +import com.futo.platformplayer.views.overlays.LiveChatOverlay +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class LiveChatMessageListItem(viewGroup: ViewGroup) + : LiveChatListItem(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_chat_message, viewGroup, false)) { + private var _liveEvent: ILiveEventChatMessage? = null; + + private val _authorImage: ImageView = _view.findViewById(R.id.image_thumbnail); + private val _authorName: TextView = _view.findViewById(R.id.text_author); + private val _authorMessage: TextView = _view.findViewById(R.id.text_body); + + + override fun bind(chat: LiveChatOverlay.ChatMessage) { + val event = chat.event; + + _liveEvent = event; + if(event.thumbnail.isNullOrEmpty()) + _authorImage.visibility = View.GONE; + else { + Glide.with(_authorImage) + .load(event.thumbnail) + .into(_authorImage); + _authorImage.visibility = View.VISIBLE; + } + _authorName.text = event.name; + + if(event is LiveEventComment) { + val badges = event.badges.filter { chat.manager.hasEmoji(it) }; + if (badges.isEmpty()) + _authorName.text = event.name; + else { + val span = + _spanFactory.newSpannable(event.name + " " + badges.map { "." }.joinToString()); + for (i in badges.indices) { + val badge = badges[i]; + val drawable = LevelListDrawable(); + span.setSpan( + ImageSpan(drawable), + event.name.length + i + 1, + event.name.length + i + 2, + Spannable.SPAN_INCLUSIVE_INCLUSIVE + ); + chat.manager.getEmoji(badge) { emojiDrawable -> + if (emojiDrawable != null) { + drawable.addLevel(1, 1, emojiDrawable); + val iconSize = 16.dp(_view.resources); + drawable.setBounds(0, 0, iconSize, iconSize); + drawable.setLevel(1); + if (_liveEvent == event) + chat.scope.launch(Dispatchers.Main) { + _authorName.setText(span, TextView.BufferType.SPANNABLE); + } + } + } + } + } + + if (!event.colorName.isNullOrEmpty()) { + try { + _authorName.setTextColor(Color.parseColor(event.colorName)); + } catch (ex: Throwable) { + } + } else + _authorName.setTextColor(Color.WHITE); + + } + else { + _authorName.setTextColor(Color.WHITE); + } + + //Injects emotes + if(!chat.manager.let { liveChat -> + val emojiMatches = REGEX_EMOJIS.findAll(event.message).toList(); + val span = _spanFactory.newSpannable(event.message); + var injected = 0; + + for(emoji in emojiMatches + .filter { it.groupValues.size > 1 && liveChat.hasEmoji(it.groupValues[1]) } + .groupBy { it.groupValues[1] }) { + val emojiVal = emoji.key; + val drawable = LevelListDrawable(); + + for(match in emoji.value) + span.setSpan(ImageSpan(drawable), match.range.first, match.range.last + 1, Spannable.SPAN_INCLUSIVE_INCLUSIVE); + + liveChat.getEmoji(emojiVal) { emojiDrawable -> + if(emojiDrawable != null) { + drawable.addLevel(1, 1, emojiDrawable); + val iconSize = 20.dp(_view.resources); + drawable.setBounds(0, 0, iconSize, iconSize); + drawable.setLevel(1); + if (_liveEvent == event) + chat.scope.launch(Dispatchers.Main) { + _authorMessage.setText(span, TextView.BufferType.SPANNABLE); + } + } + }; + injected++; + } + if(injected > 0) { + _authorMessage.setText(span, TextView.BufferType.SPANNABLE); + return@let true; + } else + return@let false; + }) + _authorMessage.text = event.message; + } + + + companion object { + val REGEX_EMOJIS = Regex("__(.*?)__"); + private val _spanFactory = Spannable.Factory.getInstance(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/others/BulletPointView.kt b/app/src/main/java/com/futo/platformplayer/views/others/BulletPointView.kt new file mode 100644 index 00000000..33fdca42 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/others/BulletPointView.kt @@ -0,0 +1,41 @@ +package com.futo.platformplayer.views.others + +import android.content.Context +import android.graphics.Color +import android.util.AttributeSet +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event0 + +class BulletPointView : LinearLayout { + + val bulletPoint: TextView; + val bulletPointValue: TextView; + + var onClick = Event0(); + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + inflate(context, R.layout.view_bullet_point, this); + + bulletPointValue = findViewById(R.id.bullet_text) + bulletPoint = findViewById(R.id.bullet_point) + + val attrArr = context.obtainStyledAttributes(attrs, R.styleable.BulletPointView, 0, 0); + bulletPointValue.setTextColor(attrArr.getColor(R.styleable.BulletPointView_valueColor, Color.WHITE)); + bulletPoint.setTextColor(attrArr.getColor(R.styleable.BulletPointView_bulletColor, Color.WHITE)); + bulletPointValue.text = attrArr.getText(R.styleable.BulletPointView_bulletText) ?: ""; + + this.setOnClickListener { onClick.emit() } + } + + fun withTextColor(color: Int) : BulletPointView { + bulletPointValue.setTextColor(color); + return this; + } + + fun withText(str: String) : BulletPointView { + bulletPointValue.text = str; + return this; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/others/Checkbox.kt b/app/src/main/java/com/futo/platformplayer/views/others/Checkbox.kt new file mode 100644 index 00000000..e970221f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/others/Checkbox.kt @@ -0,0 +1,31 @@ +package com.futo.platformplayer.views.others + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import androidx.appcompat.widget.AppCompatImageView +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event1 + +class Checkbox : AppCompatImageView { + var value: Boolean = false + set(v) { + field = v; + if (v) { + setImageResource(R.drawable.ic_checkbox_checked); + } else { + setImageResource(R.drawable.ic_checkbox_unchecked); + } + }; + val onValueChanged = Event1(); + + constructor(context : Context, attrs : AttributeSet) : super(context, attrs) { + setImageResource(R.drawable.ic_checkbox_unchecked); + + isClickable = true; + setOnClickListener { + value = !value; + onValueChanged.emit(value); + }; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/others/CircularProgressBar.kt b/app/src/main/java/com/futo/platformplayer/views/others/CircularProgressBar.kt new file mode 100644 index 00000000..0c666cb7 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/others/CircularProgressBar.kt @@ -0,0 +1,90 @@ +package com.futo.platformplayer.views.others + +import android.content.Context +import android.graphics.* +import android.util.AttributeSet +import android.view.View +import androidx.core.content.ContextCompat +import com.futo.platformplayer.R + + +class CircularProgressBar : View { + private val _paintActive = Paint(Paint.ANTI_ALIAS_FLAG); + private val _paintInactive = Paint(Paint.ANTI_ALIAS_FLAG); + private val _path = Path(); + + var progress: Float = 0.0f + set(value) { + field = value; + invalidate(); + }; + + var strokeWidth: Float + get() { + return _paintInactive.strokeWidth; + } + set(value) { + _paintActive.strokeWidth = value; + _paintInactive.strokeWidth = value; + invalidate(); + }; + + var activeColor: Int + get() { + return _paintActive.color; + } + set(value) { + _paintActive.color = value; + invalidate(); + }; + var inactiveColor: Int + get() { + return _paintInactive.color; + } + set(value) { + _paintInactive.color = value; + invalidate(); + }; + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + _paintActive.style = Paint.Style.STROKE; + _paintInactive.style = Paint.Style.STROKE; + + val a = context.theme.obtainStyledAttributes(attrs, R.styleable.ProgressBar, 0, 0); + try { + progress = a.getFraction(R.styleable.ProgressBar_progress, 1, 1, 0.0f); + _paintActive.color = a.getColor(R.styleable.ProgressBar_activeColor, ContextCompat.getColor(context, R.color.colorPrimary)); + _paintInactive.color = a.getColor(R.styleable.ProgressBar_inactiveColor, ContextCompat.getColor(context, R.color.gray_c3)); + } finally { + a.recycle(); + } + + val b = context.theme.obtainStyledAttributes(attrs, R.styleable.CircularProgressBar, 0, 0); + try { + strokeWidth = b.getDimensionPixelSize(R.styleable.CircularProgressBar_strokeWidth, 10).toFloat(); + } finally { + b.recycle(); + } + } + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas); + + val w = width.toFloat(); + val h = height.toFloat(); + val size = Math.min(w, h) - strokeWidth; + val paddingLeft = (w - size) / 2; + val paddingTop = (h - size) / 2; + + _path.reset(); + _path.addArc(paddingLeft, paddingTop, paddingLeft + size, paddingTop + size, 90.0f, 360.0f); + canvas.drawPath(_path, _paintInactive); + + _path.reset(); + _path.addArc(paddingLeft, paddingTop, paddingLeft + size, paddingTop + size, 90.0f, progress * 360.0f); + canvas.drawPath(_path, _paintActive); + } + + companion object { + val TAG = "ProgressBar"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt b/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt new file mode 100644 index 00000000..1d9bffd4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt @@ -0,0 +1,121 @@ +package com.futo.platformplayer.views.others + +import android.animation.ObjectAnimator +import android.content.Context +import android.graphics.Bitmap +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.ImageView +import androidx.constraintlayout.widget.ConstraintLayout +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.images.GlideHelper.Companion.crossfade + +class CreatorThumbnail : ConstraintLayout { + private val _root: ConstraintLayout; + private val _imageChannelThumbnail: ImageView; + private val _imageNewActivity: ImageView; + private val _imageNeoPass: ImageView; + private var _harborAnimator: ObjectAnimator? = null; + private var _imageAnimator: ObjectAnimator? = null; + + var onClick = Event1>(); + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + LayoutInflater.from(context).inflate(R.layout.view_creator_thumbnail, this, true); + + _root = findViewById(R.id.root); + _imageChannelThumbnail = findViewById(R.id.image_channel_thumbnail); + _imageChannelThumbnail.clipToOutline = true; + _imageNewActivity = findViewById(R.id.image_new_activity); + _imageNeoPass = findViewById(R.id.image_neopass); + + if (!isInEditMode) { + setHarborAvailable(false, animate = false); + setNewActivity(false); + } + } + + fun clear() { + _imageChannelThumbnail.setImageResource(R.drawable.placeholder_channel_thumbnail); + setHarborAvailable(false, animate = false); + setNewActivity(false); + } + + fun setThumbnail(url: String?, animate: Boolean) { + if (url == null) { + clear(); + return; + } + + _harborAnimator?.cancel(); + _harborAnimator = null; + + _imageAnimator?.cancel(); + _imageAnimator = null; + + setHarborAvailable(url.startsWith("polycentric://"), animate); + + if (animate) { + Glide.with(_imageChannelThumbnail) + .load(url) + .placeholder(R.drawable.placeholder_channel_thumbnail) + .crossfade() + .into(_imageChannelThumbnail); + } else { + Glide.with(_imageChannelThumbnail) + .load(url) + .placeholder(R.drawable.placeholder_channel_thumbnail) + .into(_imageChannelThumbnail); + } + } + + fun setHarborAvailable(available: Boolean, animate: Boolean) { + _harborAnimator?.cancel(); + _harborAnimator = null; + + if (available) { + _imageNeoPass.visibility = View.VISIBLE; + if (animate) { + _harborAnimator = ObjectAnimator.ofFloat(_imageNeoPass, "alpha", 0.0f, 1.0f).setDuration(100); + _harborAnimator?.start(); + } + } else { + _imageNeoPass.visibility = View.GONE; + } + } + + fun setChannelImageResource(resource: Int?, animate: Boolean) { + setChannelImage(resource?.let { { _imageChannelThumbnail.setImageResource(it) } }, animate); + } + + fun setChannelImageBitmap(bitmap: Bitmap?, animate: Boolean) { + setChannelImage(bitmap?.let { { _imageChannelThumbnail.setImageBitmap(it) } }, animate); + } + + fun setChannelImage(setter: (() -> Unit)?, animate: Boolean) { + _imageAnimator?.cancel(); + _imageAnimator = null; + + if (setter != null) { + _imageChannelThumbnail.visibility = View.VISIBLE; + setter(); + if (animate) { + _imageAnimator = ObjectAnimator.ofFloat(_imageChannelThumbnail, "alpha", 0.0f, 1.0f).setDuration(100); + _imageAnimator?.start(); + } + } else { + _imageChannelThumbnail.visibility = View.GONE; + } + } + + fun setNewActivity(available: Boolean) { + _imageNewActivity.visibility = if (available) View.VISIBLE else View.GONE; + } + + companion object { + private const val TAG = "CreatorThumbnail"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/others/ProgressBar.kt b/app/src/main/java/com/futo/platformplayer/views/others/ProgressBar.kt new file mode 100644 index 00000000..7751d94e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/others/ProgressBar.kt @@ -0,0 +1,114 @@ +package com.futo.platformplayer.views.others + +import android.content.Context +import android.graphics.* +import android.util.AttributeSet +import android.view.View +import androidx.core.content.ContextCompat +import com.futo.platformplayer.R + + +class ProgressBar : View { + private val _paintActive = Paint(Paint.ANTI_ALIAS_FLAG); + private val _paintInactive = Paint(Paint.ANTI_ALIAS_FLAG); + private val _path = Path(); + private val _progressRect = RectF(); + + var progress: Float = 0.0f + set(value) { + field = value; + invalidate(); + }; + + var radiusBottomLeft: Float = 0.0f + set(value) { + field = value; + updateCornerRadii(); + + + }; + var radiusBottomRight: Float = 0.0f + set(value) { + field = value; + updateCornerRadii(); + }; + var radiusTopLeft: Float = 0.0f + set(value) { + field = value; + updateCornerRadii(); + }; + var radiusTopRight: Float = 0.0f + set(value) { + field = value; + updateCornerRadii(); + }; + + var activeColor: Int + get() { + return _paintActive.color; + } + set(value) { + _paintActive.color = value; + invalidate(); + }; + var inactiveColor: Int + get() { + return _paintInactive.color; + } + set(value) { + _paintInactive.color = value; + invalidate(); + }; + + private var _corners: FloatArray = floatArrayOf(); + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + _paintActive.style = Paint.Style.FILL; + _paintInactive.style = Paint.Style.FILL; + + val a = context.theme.obtainStyledAttributes(attrs, R.styleable.ProgressBar, 0, 0); + try { + progress = a.getFraction(R.styleable.ProgressBar_progress, 1, 1, 0.0f); + radiusBottomLeft = a.getDimensionPixelSize(R.styleable.ProgressBar_radiusBottomLeft, 0).toFloat(); + radiusBottomRight = a.getDimensionPixelSize(R.styleable.ProgressBar_radiusBottomRight, 0).toFloat(); + radiusTopLeft = a.getDimensionPixelSize(R.styleable.ProgressBar_radiusTopLeft, 0).toFloat(); + radiusTopRight = a.getDimensionPixelSize(R.styleable.ProgressBar_radiusTopRight, 0).toFloat(); + _paintActive.color = a.getColor(R.styleable.ProgressBar_activeColor, ContextCompat.getColor(context, R.color.colorPrimary)); + _paintInactive.color = a.getColor(R.styleable.ProgressBar_inactiveColor, ContextCompat.getColor(context, R.color.gray_c3)); + } finally { + a.recycle(); + } + } + + private fun updateCornerRadii() { + _corners = floatArrayOf( + radiusTopLeft, radiusTopLeft, + radiusTopRight, radiusTopRight, + radiusBottomRight, radiusBottomRight, + radiusBottomLeft, radiusBottomLeft + ); + + invalidate(); + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas); + + val w = width.toFloat(); + val h = height.toFloat(); + + _path.reset(); + _progressRect.set(0.0f, 0.0f, w, h); + _path.addRoundRect(_progressRect, _corners, Path.Direction.CW); + canvas.drawPath(_path, _paintInactive); + + _path.reset(); + _progressRect.set(0.0f, 0.0f, progress * w, h); + _path.addRoundRect(_progressRect, _corners, Path.Direction.CW); + canvas.drawPath(_path, _paintActive); + } + + companion object { + val TAG = "ProgressBar"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/others/RadioGroupView.kt b/app/src/main/java/com/futo/platformplayer/views/others/RadioGroupView.kt new file mode 100644 index 00000000..22a5d21f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/others/RadioGroupView.kt @@ -0,0 +1,69 @@ +package com.futo.platformplayer.views.others + +import android.content.Context +import android.util.AttributeSet +import android.util.TypedValue +import com.futo.platformplayer.constructs.Event1 +import com.google.android.flexbox.FlexWrap +import com.google.android.flexbox.FlexboxLayout + +class RadioGroupView : FlexboxLayout { + private val _padding_dp: Float = 4.0f; + private val _padding_px: Int; + + val selectedOptions = arrayListOf(); + val onSelectedChange = Event1>(); + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { + flexWrap = FlexWrap.WRAP; + _padding_px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, _padding_dp, context.resources.displayMetrics).toInt(); + + if (isInEditMode) { + setOptions(listOf("Example 1" to 1, "Example 2" to 2, "Example 3" to 3, "Example 4" to 4, "Example 5" to 5), listOf("Example 1", "Example 2"), + multiSelect = true, + atLeastOne = false + ); + } + } + + fun setOptions(options: List>, initiallySelectedOptions: List, multiSelect: Boolean, atLeastOne: Boolean) { + selectedOptions.clear(); + selectedOptions.addAll(initiallySelectedOptions); + + removeAllViews(); + + val radioViews = arrayListOf(); + for (option in options) { + val radioView = RadioView(context); + radioViews.add(radioView); + radioView.setHandleClick(false); + radioView.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + radioView.setInfo(option.first, initiallySelectedOptions.contains(option.second)); + radioView.setPadding(_padding_px, _padding_px, _padding_px, _padding_px); + radioView.onClick.subscribe { + val selected = !radioView.selected; + if (selected) { + if (selectedOptions.size > 0 && !multiSelect) { + for (v in radioViews) { + v.setIsSelected(false); + } + + selectedOptions.clear(); + } + + radioView.setIsSelected(true); + selectedOptions.add(option.second); + } else { + if (selectedOptions.size < 2 && atLeastOne) { + return@subscribe; + } + + radioView.setIsSelected(false); + selectedOptions.remove(option.second); + } + + onSelectedChange.emit(selectedOptions); + }; + addView(radioView); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/others/RadioView.kt b/app/src/main/java/com/futo/platformplayer/views/others/RadioView.kt new file mode 100644 index 00000000..22ff167e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/others/RadioView.kt @@ -0,0 +1,60 @@ +package com.futo.platformplayer.views.others + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 + +class RadioView : LinearLayout { + private val _root: FrameLayout; + private val _textTag: TextView; + private var _text: String = ""; + private var _selected: Boolean = false; + private var _handleClick: Boolean = true; + + val selected get() = _selected; + var onClick = Event0(); + var onSelectedChange = Event1(); + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + LayoutInflater.from(context).inflate(R.layout.view_tag, this, true); + _root = findViewById(R.id.root); + _textTag = findViewById(R.id.text_tag); + _root.setOnClickListener { + onClick.emit(); + if (_handleClick) { + setIsSelected(!_selected) + } + }; + + _root.setBackgroundResource(R.drawable.background_radio_unselected); + _textTag.setTextColor(resources.getColor(R.color.gray_67)); + } + + fun setInfo(text: String, selected: Boolean) { + _text = text; + _textTag.text = text; + setIsSelected(selected); + } + + fun setIsSelected(selected: Boolean) { + val changed = _selected != selected; + if (!changed) { + return; + } + + _selected = selected; + _root.setBackgroundResource(if (selected) R.drawable.background_radio_selected else R.drawable.background_radio_unselected); + _textTag.setTextColor(resources.getColor(if (selected) R.color.white else R.color.gray_67)); + onSelectedChange.emit(_selected); + } + + fun setHandleClick(handleClick: Boolean) { + _handleClick = handleClick; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/others/TagView.kt b/app/src/main/java/com/futo/platformplayer/views/others/TagView.kt new file mode 100644 index 00000000..54af9e94 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/others/TagView.kt @@ -0,0 +1,32 @@ +package com.futo.platformplayer.views.others + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event1 + +class TagView : LinearLayout { + private val _root: FrameLayout; + private val _textTag: TextView; + private var _text: String = ""; + private var _value: Any? = null; + + var onClick = Event1>(); + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + LayoutInflater.from(context).inflate(R.layout.view_tag, this, true); + _root = findViewById(R.id.root); + _textTag = findViewById(R.id.text_tag); + _root.setOnClickListener { _value?.let { onClick.emit(Pair(_text, it)); }; } + } + + fun setInfo(text: String, value: Any) { + _text = text; + _textTag.text = text; + _value = value; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/others/TagsView.kt b/app/src/main/java/com/futo/platformplayer/views/others/TagsView.kt new file mode 100644 index 00000000..277d58a4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/others/TagsView.kt @@ -0,0 +1,40 @@ +package com.futo.platformplayer.views.others + +import android.content.Context +import android.util.AttributeSet +import android.util.TypedValue +import com.futo.platformplayer.constructs.Event1 +import com.google.android.flexbox.FlexWrap +import com.google.android.flexbox.FlexboxLayout + +class TagsView : FlexboxLayout { + private val _padding_dp: Float = 4.0f; + private val _padding_px: Int; + + var onClick = Event1>(); + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { + flexWrap = FlexWrap.WRAP; + _padding_px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, _padding_dp, context.resources.displayMetrics).toInt(); + + if (isInEditMode) { + setTags(listOf("Example 1", "Example 2", "Example 3", "Example 4", "Example 5", "Example 5")); + } + } + + fun setTags(tags: List) { + setPairs(tags.map { t -> Pair(t, t) }); + } + + fun setPairs(tags: List>) { + removeAllViews(); + for (tag in tags) { + val tagView = TagView(context); + tagView.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + tagView.setInfo(tag.first, tag.second); + tagView.setPadding(_padding_px, _padding_px, _padding_px, _padding_px); + tagView.onClick.subscribe { onClick.emit(it) }; + addView(tagView); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/others/Toggle.kt b/app/src/main/java/com/futo/platformplayer/views/others/Toggle.kt new file mode 100644 index 00000000..844b58de --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/others/Toggle.kt @@ -0,0 +1,48 @@ +package com.futo.platformplayer.views.others + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatImageView +import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event1 + +class Toggle : AppCompatImageView { + var value: Boolean = false + private set; + + val onValueChanged = Event1(); + private var _currentDrawable: AnimatedVectorDrawableCompat? = null; + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + isClickable = true; + setOnClickListener { + setValue(!value); + onValueChanged.emit(value); + }; + + setImageResource(R.drawable.toggle_disabled); + + val attrArr = context.obtainStyledAttributes(attrs, R.styleable.Toggle, 0, 0); + val toggleEnabled = attrArr.getBoolean(R.styleable.Toggle_toggleEnabled, false); + setValue(toggleEnabled, false); + scaleType = ScaleType.FIT_CENTER; + } + + fun setValue(v: Boolean, animated: Boolean = true) { + if (value == v) { + return; + } + + value = v; + + _currentDrawable?.stop(); + if (animated) { + _currentDrawable = AnimatedVectorDrawableCompat.create(context, if (v) R.drawable.toggle_animated else R.drawable.toggle_animated_reverse); + setImageDrawable(_currentDrawable); + _currentDrawable?.start(); + } else { + setImageResource(if (v) R.drawable.toggle_enabled else R.drawable.toggle_disabled); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt b/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt new file mode 100644 index 00000000..27c4e68d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt @@ -0,0 +1,47 @@ +package com.futo.platformplayer.views.others + +import android.content.Context +import android.graphics.Color +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event1 + +class ToggleTagView : LinearLayout { + private val _root: FrameLayout; + private val _textTag: TextView; + private var _text: String = ""; + + var isActive: Boolean = false + private set; + + var onClick = Event1(); + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + LayoutInflater.from(context).inflate(R.layout.view_toggle_tag, this, true); + _root = findViewById(R.id.root); + _textTag = findViewById(R.id.text_tag); + _root.setOnClickListener { setToggle(!isActive); onClick.emit(isActive); } + } + + fun setToggle(isActive: Boolean) { + this.isActive = isActive; + if(isActive) { + _root.setBackgroundResource(R.drawable.background_pill_toggled); + _textTag.alpha = 1f; + } + else { + _root.setBackgroundResource(R.drawable.background_pill_untoggled); + _textTag.alpha = 0.5f; + } + } + + fun setInfo(text: String, isActive: Boolean) { + _text = text; + _textTag.text = text; + setToggle(isActive); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/DescriptionOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/DescriptionOverlay.kt new file mode 100644 index 00000000..917edb33 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/DescriptionOverlay.kt @@ -0,0 +1,35 @@ +package com.futo.platformplayer.views.overlays + +import android.content.Context +import android.text.Spanned +import android.util.AttributeSet +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod + +class DescriptionOverlay : LinearLayout { + val onClose = Event0(); + + private val _topbar: OverlayTopbar; + private val _textDescription: TextView; + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + inflate(context, R.layout.overlay_description, this) + _topbar = findViewById(R.id.topbar); + _textDescription = findViewById(R.id.text_description); + + _topbar.onClose.subscribe(this, onClose::emit); + + _textDescription.setPlatformPlayerLinkMovementMethod(context); + } + + fun load(text: Spanned?) { + _textDescription.text = text; + } + + fun cleanup() { + _topbar.onClose.remove(this); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/LiveChatOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/LiveChatOverlay.kt new file mode 100644 index 00000000..8f0e0e09 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/LiveChatOverlay.kt @@ -0,0 +1,417 @@ +package com.futo.platformplayer.views.overlays + +import android.animation.LayoutTransition +import android.content.Context +import android.graphics.Color +import android.graphics.PointF +import android.util.AttributeSet +import android.util.DisplayMetrics +import android.view.View +import android.webkit.CookieManager +import android.webkit.ValueCallback +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Button +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.graphics.blue +import androidx.core.graphics.green +import androidx.core.graphics.red +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.LinearSmoothScroller +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.media.LiveChatManager +import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor +import com.futo.platformplayer.api.media.models.live.ILiveEventChatMessage +import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent +import com.futo.platformplayer.api.media.models.live.LiveEventComment +import com.futo.platformplayer.api.media.models.live.LiveEventDonation +import com.futo.platformplayer.api.media.models.live.LiveEventRaid +import com.futo.platformplayer.api.media.models.live.LiveEventViewCount +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.dp +import com.futo.platformplayer.isHexColor +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.toHumanBitrate +import com.futo.platformplayer.toHumanNumber +import com.futo.platformplayer.views.AnyAdapterView +import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny +import com.futo.platformplayer.views.livechat.LiveChatDonationPill +import com.futo.platformplayer.views.livechat.LiveChatListAdapter +import com.futo.platformplayer.views.livechat.LiveChatMessageListItem +import com.stripe.android.core.utils.encodeToJson +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + + +class LiveChatOverlay : LinearLayout { + val onClose = Event0(); + + private val _closeButton: ImageView; + private val _donationList: LinearLayout; + + private val _overlay: View; + + private val _chatContainer: RecyclerView; + private val _chatWindowContainer: WebView; + private val _overlayHeader: ConstraintLayout; + + private val _overlayDonation: ConstraintLayout; + private val _overlayDonation_AuthorImage: ImageView; + private val _overlayDonation_AuthorName: TextView; + private val _overlayDonation_Text: TextView; + private val _overlayDonation_Amount: TextView; + private val _overlayDonation_AmountContainer: LinearLayout; + + private val _overlayRaid: ConstraintLayout; + private val _overlayRaid_Name: TextView; + private val _overlayRaid_Thumbnail: ImageView; + + private val _overlayRaid_ButtonGo: Button; + private val _overlayRaid_ButtonPrevent: Button; + + private val _textViewers: TextView; + + private val _headerHeightBase = 59; + private val _headerHeightDonations = 94; + + private var _scope: CoroutineScope? = null; + private var _manager: LiveChatManager? = null; + private var _window: ILiveChatWindowDescriptor? = null; + + private val _chatLayoutManager: ChatLayoutManager; + private val _chats = arrayListOf(); + //private val _chatAdapter: AnyAdapterView; + private val _chatAdapter: LiveChatListAdapter; + + private var _detachCounter: Int = 0; + + private var _shownDonations: HashMap = hashMapOf(); + + private var _currentRaid: LiveEventRaid? = null; + + val onRaidNow = Event1(); + val onRaidPrevent = Event1(); + + private val _argJsonSerializer = Json; + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + inflate(context, R.layout.overlay_livechat, this) + + _chatWindowContainer = findViewById(R.id.chatWindowContainer); + _chatWindowContainer.settings.javaScriptEnabled = true; + _chatWindowContainer.settings.domStorageEnabled = true; + _chatWindowContainer.webViewClient = object: WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url); + _window?.let { + for(req in it.removeElements) + view?.evaluateJavascript("document.querySelectorAll(" + _argJsonSerializer.encodeToString(req) + ").forEach(x=>x.remove());") {}; + }; + } + }; + + _chatContainer = findViewById(R.id.chatContainer); + _chatLayoutManager = ChatLayoutManager(context); + //_chatAdapter = _chatContainer.asAny(_chats); + _chatAdapter = LiveChatListAdapter(context, _chats); + _chatContainer.adapter = _chatAdapter; + _chatContainer.layoutManager = _chatLayoutManager; + + _donationList = findViewById(R.id.donation_list); + + _overlay = findViewById(R.id.overlay); + _overlay.setOnClickListener { + hideOverlay(); + } + + _overlayHeader = findViewById(R.id.topbar); + _overlayHeader.layoutTransition = LayoutTransition().apply { + this.enableTransitionType(LayoutTransition.CHANGING); + } + + _textViewers = findViewById(R.id.text_viewers); + + _overlayDonation = findViewById(R.id.overlay_donation); + _overlayDonation_AuthorImage = findViewById(R.id.donation_author_image); + _overlayDonation_AuthorName = findViewById(R.id.donation_author_name); + _overlayDonation_Text = findViewById(R.id.donation_text); + _overlayDonation_Amount = findViewById(R.id.donation_amount); + _overlayDonation_AmountContainer = findViewById(R.id.donation_amount_container) + + _overlayRaid = findViewById(R.id.overlay_raid); + _overlayRaid_Name = findViewById(R.id.raid_name); + _overlayRaid_Thumbnail = findViewById(R.id.raid_thumbnail); + _overlayRaid_ButtonGo = findViewById(R.id.raid_button_go); + _overlayRaid_ButtonPrevent = findViewById(R.id.raid_button_prevent); + + _overlayRaid.visibility = View.GONE; + + _overlayRaid_ButtonGo.setOnClickListener { + _currentRaid?.let { + onRaidNow.emit(it); + } + } + _overlayRaid_ButtonPrevent.setOnClickListener { + _currentRaid?.let { + _currentRaid = null; + _overlayRaid.visibility = View.GONE; + onRaidPrevent.emit(it); + } + } + + + _closeButton = findViewById(R.id.button_close); + _closeButton.setOnClickListener { + close(); + }; + + hideOverlay(); + updateDonationUI(); + } + + fun updateDonationUI() { + _overlayHeader.layoutParams = ConstraintLayout.LayoutParams(_overlayHeader.layoutParams).apply { + if (_shownDonations.size > 0) + this.height = _headerHeightDonations.dp(resources); + else + this.height = _headerHeightBase.dp(resources); + }; + } + + fun close() { + cancel(); + onClose.emit(); + } + + fun load(scope: CoroutineScope, manager: LiveChatManager?, window: ILiveChatWindowDescriptor? = null, viewerCount: Long? = null) { + _scope = scope; + _donationList.removeAllViews(); + _chats.clear(); + //_chatAdapter.notifyContentChanged(); + _chatAdapter.notifyDataSetChanged(); + _manager = manager; + _window = window; + + if(viewerCount != null) + _textViewers.text = viewerCount.toHumanNumber() + " viewers"; + else if(manager != null) + _textViewers.text = manager.viewCount.toHumanNumber() + " viewers"; + else + _textViewers.text = ""; + + if(window != null) { + _chatWindowContainer.visibility = View.VISIBLE; + _chatContainer.visibility = View.GONE; + _chatWindowContainer.loadUrl(window.url); + } + else { + _chatContainer.visibility = View.VISIBLE; + _chatWindowContainer.visibility = View.GONE; + } + + manager?.getHistory()?.let {history -> + for(event in history) + handleLiveEvent(event); + } + setRaid(null); + + //handleLiveEvent(LiveEventDonation("Test", null, "TestDonation", "$50.00", 6000, "#FF0000")) + + manager?.follow(this) { + val comments = arrayListOf() + for(event in it) { + if(event is LiveEventComment) + comments.add(ChatMessage(event, manager, scope)); + else if(event is LiveEventDonation) { + comments.add(ChatMessage(event, manager, scope)); + handleLiveEvent(event); + } + else if(event is LiveEventViewCount) + scope.launch(Dispatchers.Main) { + _textViewers.text = "${event.viewCount.toLong().toHumanNumber()} viewers"; + } + else + handleLiveEvent(event); + } + checkDonations(); + addComments(*comments.toTypedArray()); + } + } + + fun cancel() { + _detachCounter++; + _scope = null; + _chats.clear(); + //_chatAdapter.notifyContentChanged(); + _chatWindowContainer.loadUrl("about:blank"); + _chatAdapter.notifyDataSetChanged(); + _manager?.unfollow(this); + _manager?.stop(); //TODO: Remove this after proper manager gets stopped in videodetail for reuse + _manager = null; + } + + fun handleLiveEvent(liveEvent: IPlatformLiveEvent) { + when(liveEvent::class) { + LiveEventDonation::class -> addDonation(liveEvent as LiveEventDonation); + LiveEventRaid::class -> setRaid(liveEvent as LiveEventRaid); + LiveEventViewCount::class -> setViewCount((liveEvent as LiveEventViewCount).viewCount); + } + } + + fun showOverlay(action: ()->Unit) { + _overlay.visibility = VISIBLE; + action(); + } + fun hideOverlay() { + _overlay.visibility = GONE; + _overlayDonation.visibility = GONE; + } + + fun showDonation(donation: LiveEventDonation) { + showOverlay { + //TODO: Fancy animations + if(donation.thumbnail.isNullOrEmpty()) + _overlayDonation_AuthorImage.visibility = View.GONE; + else { + _overlayDonation_AuthorImage.visibility = View.VISIBLE; + Glide.with(_overlayDonation_AuthorImage) + .load(donation.thumbnail) + .into(_overlayDonation_AuthorImage); + } + _overlayDonation_AuthorName.text = donation.name; + _overlayDonation_Text.text = donation.message; + _overlayDonation_Amount.text = donation.amount.trim(); + _overlayDonation.visibility = VISIBLE; + if(donation.colorDonation != null && donation.colorDonation.isHexColor()) { + val color = Color.parseColor(donation.colorDonation); + _overlayDonation_AmountContainer.background.setTint(color); + + if((color.green > 140 || color.red > 140 || color.blue > 140) && (color.red + color.green + color.blue) > 400) + _overlayDonation_Amount.setTextColor(Color.BLACK) + else + _overlayDonation_Amount.setTextColor(Color.WHITE); + } + else { + _overlayDonation_AmountContainer.background.setTint(Color.parseColor("#2A2A2A")); + _overlayDonation_Amount.setTextColor(Color.WHITE); + } + }; + } + fun addDonation(donation: LiveEventDonation) { + if(donation.hasExpired()) { + Logger.i(TAG, "Donation that is already expired: [${donation.amount}]" + donation.name + ":" + donation.message + " EXPIRE: ${donation.expire}"); + return; + } + else + Logger.i(TAG, "Donation Added: [${donation.amount}]" + donation.name + ":" + donation.message + " EXPIRE: ${donation.expire}"); + val view = LiveChatDonationPill(context, donation); + view.setOnClickListener { + showDonation(donation); + }; + _donationList.addView(view, 0); + synchronized(_shownDonations) { + _shownDonations.put(donation, view); + } + updateDonationUI(); + view.animateExpire(donation.expire); + } + fun checkDonations() { + val expireds = synchronized(_shownDonations) { + val toRemove = _shownDonations.filter { it.key.hasExpired() } + for(remove in toRemove) + _shownDonations.remove(remove.key); + return@synchronized toRemove; + } + for(expired in expireds) { + expired.value.animate() + .alpha(0f) + .setDuration(1000) + .withEndAction({ + _donationList.removeView(expired.value); + updateDonationUI(); + }).start(); + } + } + + + fun addComments(vararg comments: ChatMessage) { + val startLength = _chats.size; + + if(_window == null) { + _chats.addAll(comments); + _chatAdapter.notifyItemRangeInserted(startLength, comments.size); + _chatContainer.smoothScrollToPosition(_chats.size); + } + } + fun setRaid(raid: LiveEventRaid?) { + _currentRaid = raid; + _scope?.launch(Dispatchers.Main) { + _overlayRaid_Name.text = raid?.targetName ?: ""; + Glide.with(_overlayRaid_Thumbnail).clear(_overlayRaid_Thumbnail); + if(raid != null) { + Glide.with(_overlayRaid_Thumbnail) + .load(raid.targetThumbnail) + .into(_overlayRaid_Thumbnail); + _overlayRaid.visibility = View.VISIBLE; + } + else + _overlayRaid.visibility = View.GONE; + } + } + fun setViewCount(viewCount: Int) { + _scope?.launch(Dispatchers.Main) { + _textViewers.text = viewCount.toLong().toHumanNumber() + " viewers"; + } + } + + + class ChatLayoutManager: LinearLayoutManager { + var scrollTime: Long = 1000; + + constructor(context: Context): super(context) { + stackFromEnd = true; + } + override fun smoothScrollToPosition( + recyclerView: RecyclerView, + state: RecyclerView.State?, + position: Int + ) { + val linearSmoothScroller: LinearSmoothScroller = + object : LinearSmoothScroller(recyclerView.context) { + val MILLISECONDS_PER_INCH = 2000f; + //TODO: Make scrollspeed = nextRequest time + override fun computeScrollVectorForPosition(targetPosition: Int): PointF? { + return this@ChatLayoutManager + .computeScrollVectorForPosition(targetPosition); + } + + override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics): Float { + return MILLISECONDS_PER_INCH / displayMetrics.densityDpi + } + } + linearSmoothScroller.targetPosition = position + startSmoothScroll(linearSmoothScroller) + } + } + + class ChatMessage( + val event: ILiveEventChatMessage, + val manager: LiveChatManager, + val scope: CoroutineScope + ); + + companion object { + val TAG = "LiveChatOverlay"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/LoaderOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/LoaderOverlay.kt new file mode 100644 index 00000000..06d43e2c --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/LoaderOverlay.kt @@ -0,0 +1,29 @@ +package com.futo.platformplayer.views.overlays + +import android.content.Context +import android.graphics.drawable.Animatable +import android.util.AttributeSet +import android.view.View +import android.widget.FrameLayout +import android.widget.ImageView +import com.futo.platformplayer.R + +class LoaderOverlay(context: Context, attrs: AttributeSet?) : FrameLayout(context, attrs) { + private val _container: FrameLayout; + private val _loader: ImageView; + + init { + inflate(context, R.layout.overlay_loader, this); + _container = findViewById(R.id.container); + _loader = findViewById(R.id.loader); + } + + fun show() { + this.visibility = View.VISIBLE; + (_loader.drawable as Animatable?)?.start(); + } + fun hide() { + this.visibility = View.GONE; + (_loader.drawable as Animatable?)?.stop(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/OverlayTopbar.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/OverlayTopbar.kt new file mode 100644 index 00000000..89f91265 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/OverlayTopbar.kt @@ -0,0 +1,45 @@ +package com.futo.platformplayer.views.overlays + +import android.content.Context +import android.util.AttributeSet +import android.widget.ImageView +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.views.lists.VideoListEditorView + +class OverlayTopbar : ConstraintLayout { + + private val _name: TextView; + private val _meta: TextView; + + private val _button_close: ImageView; + + val onClose = Event0(); + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + inflate(context, R.layout.overlay_topbar, this); + + _name = findViewById(R.id.text_name); + _meta = findViewById(R.id.text_meta); + _button_close = findViewById(R.id.button_close); + + val attrArr = context.obtainStyledAttributes(attrs, R.styleable.OverlayTopbar, 0, 0); + val attrText = attrArr.getText(R.styleable.OverlayTopbar_title) ?: ""; + _name.text = attrText; + + val attrMetaText = attrArr.getText(R.styleable.OverlayTopbar_metadata) ?: ""; + _meta.text = attrMetaText; + + _button_close.setOnClickListener { + onClose.emit(); + }; + } + + + fun setInfo(name: String, meta: String) { + _name.text = name; + _meta.text = meta; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/QueueEditorOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/QueueEditorOverlay.kt new file mode 100644 index 00000000..34955187 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/QueueEditorOverlay.kt @@ -0,0 +1,40 @@ +package com.futo.platformplayer.views.overlays + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.views.lists.VideoListEditorView + +class QueueEditorOverlay : LinearLayout { + + private val _topbar : OverlayTopbar; + private val _editor : VideoListEditorView; + + val onClose = Event0(); + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + inflate(context, R.layout.overlay_queue, this) + _topbar = findViewById(R.id.topbar); + _editor = findViewById(R.id.editor); + + _topbar.onClose.subscribe(this, onClose::emit); + _editor.onVideoOrderChanged.subscribe { StatePlayer.instance.setQueueWithExisting(it) } + _editor.onVideoRemoved.subscribe { v -> StatePlayer.instance.removeFromQueue(v) } + _editor.onVideoClicked.subscribe { v -> StatePlayer.instance.setQueuePosition(v) } + + _topbar.setInfo("Queue", ""); + } + + fun updateQueue() { + val queue = StatePlayer.instance.getQueue(); + _editor.setVideos(queue, true); + _topbar.setInfo("Queue", "${queue.size} videos"); + } + + fun cleanup() { + _topbar.onClose.remove(this); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/RepliesOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/RepliesOverlay.kt new file mode 100644 index 00000000..122d3d27 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/RepliesOverlay.kt @@ -0,0 +1,82 @@ +package com.futo.platformplayer.views.overlays + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.LinearLayout +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.views.comments.AddCommentView +import com.futo.platformplayer.views.segments.CommentsList +import userpackage.Protocol + +class RepliesOverlay : LinearLayout { + val onClose = Event0(); + + private val _topbar: OverlayTopbar; + private val _commentsList: CommentsList; + private val _addCommentView: AddCommentView; + private var _readonly = false; + private var _onCommentAdded: ((comment: IPlatformComment) -> Unit)? = null; + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + inflate(context, R.layout.overlay_replies, this) + _topbar = findViewById(R.id.topbar); + _commentsList = findViewById(R.id.comments_list); + _addCommentView = findViewById(R.id.add_comment_view); + + _addCommentView.onCommentAdded.subscribe { + _commentsList.addComment(it); + _onCommentAdded?.invoke(it); + } + + _commentsList.onCommentsLoaded.subscribe { count -> + if (_readonly && count == 0) { + UIDialogs.toast(context, "Expected at least one reply but no replies were returned by the server"); + } + } + + _commentsList.onClick.subscribe { c -> + val replyCount = c.replyCount; + var metadata = ""; + if (replyCount != null && replyCount > 0) { + metadata += "$replyCount replies"; + } + + if (c is PolycentricPlatformComment) { + load(false, metadata, c.contextUrl, c.reference, { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) }); + } else { + load(true, metadata, null, null, { StatePlatform.instance.getSubComments(c) }); + } + }; + + _topbar.onClose.subscribe(this, onClose::emit); + _topbar.setInfo("Replies", ""); + } + + fun load(readonly: Boolean, metadata: String, contextUrl: String?, ref: Protocol.Reference?, loader: suspend () -> IPager, onCommentAdded: ((comment: IPlatformComment) -> Unit)? = null) { + _readonly = readonly; + if (readonly) { + _addCommentView.visibility = View.GONE; + } else { + _addCommentView.visibility = View.VISIBLE; + _addCommentView.setContext(contextUrl, ref); + } + + _topbar.setInfo("Replies", metadata); + _commentsList.load(readonly, loader); + _onCommentAdded = onCommentAdded; + } + + fun cleanup() { + _topbar.onClose.remove(this); + _onCommentAdded = null; + _commentsList.cancel(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuButtonList.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuButtonList.kt new file mode 100644 index 00000000..f5a404fc --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuButtonList.kt @@ -0,0 +1,71 @@ +package com.futo.platformplayer.views.overlays.slideup + +import android.content.Context +import android.util.AttributeSet +import android.util.TypedValue +import android.view.Gravity +import android.view.LayoutInflater +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event1 + +class SlideUpMenuButtonList : LinearLayout { + private val _root: LinearLayout; + + val onClick = Event1(); + val buttons: HashMap = hashMapOf(); + var _activeText: String? = null; + + constructor(context: Context, attrs: AttributeSet? = null): super(context, attrs) { + LayoutInflater.from(context).inflate(R.layout.overlay_slide_up_menu_button_list, this, true); + + _root = findViewById(R.id.root); + } + + fun setButtons(texts: List, activeText: String? = null) { + _root.removeAllViews(); + + val marginLeft = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3.0f, resources.displayMetrics).toInt(); + val marginRight = marginLeft; + + buttons.clear(); + for (t in texts) { + val button = LinearLayout(context); + button.layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT).apply { + weight = 1.0f; + marginStart = marginLeft; + marginEnd = marginRight; + }; + + button.background = if (t == activeText) ContextCompat.getDrawable(context, R.drawable.background_slide_up_option_selected) else ContextCompat.getDrawable(context, R.drawable.background_slide_up_option); + button.gravity = Gravity.CENTER; + button.setOnClickListener { + onClick.emit(t); + }; + + button.setPadding(0, 0, 0, 0); + + val text = TextView(context); + text.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); + text.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 8f); + text.text = t; + text.maxLines = 1; + text.setTextColor(ContextCompat.getColor(context, R.color.white)); + text.typeface = ResourcesCompat.getFont(context, R.font.inter_light); + button.addView(text); + + _activeText = activeText; + buttons[t] = button; + _root.addView(button); + } + } + + fun setSelected(text: String) { + buttons[_activeText]?.background = ContextCompat.getDrawable(context, R.drawable.background_slide_up_option); + buttons[text]?.background = ContextCompat.getDrawable(context, R.drawable.background_slide_up_option_selected); + _activeText = text; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuFilters.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuFilters.kt new file mode 100644 index 00000000..4bac1c6b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuFilters.kt @@ -0,0 +1,131 @@ +package com.futo.platformplayer.views.overlays.slideup + +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.lifecycleScope +import com.futo.platformplayer.UISlideOverlays +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.models.FilterGroup +import com.futo.platformplayer.api.media.models.ResultCapabilities +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StatePlatform +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class SlideUpMenuFilters { + val onOK = Event2?, Boolean>(); + + private val _container: ViewGroup; + private var _enabledClientsIds: List; + private val _filterValues: HashMap>; + private val _slideUpMenuOverlay: SlideUpMenuOverlay; + private var _changed: Boolean = false; + private val _lifecycleScope: CoroutineScope; + + var commonCapabilities: ResultCapabilities? = null; + + constructor(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List, filterValues: HashMap>) { + _lifecycleScope = lifecycleScope; + _container = container; + _enabledClientsIds = enabledClientsIds; + _filterValues = filterValues; + _slideUpMenuOverlay = SlideUpMenuOverlay(_container.context, _container, "Filters", "Done", true, listOf()); + _slideUpMenuOverlay.onOK.subscribe { + onOK.emit(_enabledClientsIds, _changed); + _slideUpMenuOverlay.hide(); + } + + updateCommonCapabilities(); + } + + private fun updateCommonCapabilities() { + _lifecycleScope.launch(Dispatchers.IO) { + try { + val caps = StatePlatform.instance.getCommonSearchCapabilities(_enabledClientsIds); + synchronized(_filterValues) { + if (caps != null) { + val keysToRemove = arrayListOf(); + for (pair in _filterValues) { + //Remove filter groups from selected filters that are not selectable anymore + val currentFilter = + caps.filters.firstOrNull { it.idOrName == pair.key }; + if (currentFilter == null) { + keysToRemove.add(pair.key); + } else { + //Remove selected filter values that are not selectable anymore + _filterValues[pair.key] = + pair.value.filter { currentValue -> currentFilter.filters.any { f -> f.idOrName == currentValue } }; + } + } + + keysToRemove.forEach { _filterValues.remove(it) }; + } else { + _filterValues.clear(); + } + } + + commonCapabilities = caps; + + withContext(Dispatchers.Main) { + updateItems(); + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to update common capabilities", e) + } + } + } + + private fun updateItems() { + val caps = commonCapabilities; + val items = arrayListOf(); + + val group = SlideUpMenuRadioGroup(_container.context, "Sources", StatePlatform.instance.getSortedEnabledClient().map { Pair(it.name, it.id) }, + _enabledClientsIds, true, true); + + group.onSelectedChange.subscribe { + _enabledClientsIds = it as List; + updateCommonCapabilities(); + }; + + items.add(group); + + if (caps == null) { + _slideUpMenuOverlay.setItems(items); + return; + } + + for (filterGroup in caps.filters) { + val value: List; + synchronized(_filterValues) { + value = _filterValues[filterGroup.idOrName] ?: listOf(); + } + + val g = SlideUpMenuRadioGroup(_container.context, filterGroup.name, filterGroup.filters.map { Pair(it.idOrName, it.idOrName) }, + value, filterGroup.isMultiSelect, false); + + g.onSelectedChange.subscribe { + synchronized(_filterValues) { + _filterValues[filterGroup.idOrName] = it.map { v -> v as String }; + } + _changed = true; + }; + + items.add(g); + } + + _slideUpMenuOverlay.setItems(items); + } + + fun show() { + _slideUpMenuOverlay.show(); + } + + companion object { + private const val TAG = "SlideUpMenuFilters"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuGroup.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuGroup.kt new file mode 100644 index 00000000..a6d5dc3a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuGroup.kt @@ -0,0 +1,62 @@ +package com.futo.platformplayer.views.overlays.slideup + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.R + +class SlideUpMenuGroup : LinearLayout { + + private lateinit var title: TextView; + private lateinit var itemContainer: LinearLayout; + private var parentClickListener: (()->Unit)? = null; + private val items: List; + + var groupTag: Any? = null; + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + init(); + this.items = listOf(); + } + + constructor(context: Context, titleText: String, tag: Any, items: List) : super(context){ + init(); + title.text = titleText; + groupTag = tag; + this.items = items.toList(); + addItems(items); + } + + constructor(context: Context, titleText: String, tag: Any, vararg items: SlideUpMenuItem) + : this(context, titleText, tag, items.asList()) + + private fun init(){ + LayoutInflater.from(context).inflate(R.layout.overlay_slide_up_menu_group, this, true); + + title = findViewById(R.id.slide_up_menu_group_title); + itemContainer = findViewById(R.id.slide_up_menu_group_items); + } + + fun selectItem(obj: Any?): Boolean { + var didSelect = false; + for(item in items) { + item.setOptionSelected(item.itemTag == obj); + didSelect = didSelect || item.itemTag == obj; + } + return didSelect; + } + + private fun addItems(items: List) { + for (item in items) { + item.setParentClickListener { parentClickListener?.invoke() } + itemContainer.addView(item); + } + } + + fun setParentClickListener(listener: (()->Unit)?) { + parentClickListener = listener; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuItem.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuItem.kt new file mode 100644 index 00000000..0cb71490 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuItem.kt @@ -0,0 +1,68 @@ +package com.futo.platformplayer.views.overlays.slideup + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.ImageView +import android.widget.RelativeLayout +import android.widget.TextView +import com.futo.platformplayer.R + +class SlideUpMenuItem : RelativeLayout { + + private lateinit var _root: RelativeLayout; + private lateinit var _image: ImageView; + private lateinit var _text: TextView; + private lateinit var _subtext: TextView; + + var selectedOption: Boolean = false; + + private var _parentClickListener: (()->Unit)? = null; + + var itemTag: Any? = null; + + constructor(context: Context, attrs: AttributeSet? = null): super(context, attrs) { + init(); + } + + constructor(context: Context, imageRes: Int = 0, mainText: String, subText: String = "", tag: Any, call: (()->Unit)? = null, invokeParent: Boolean = true): super(context){ + init(); + _image.setImageResource(imageRes); + _text.text = mainText; + _subtext.text = subText; + this.itemTag = tag; + + if (call != null) { + setOnClickListener { + call.invoke(); + if(invokeParent) + _parentClickListener?.invoke(); + }; + } + } + + private fun init(){ + LayoutInflater.from(context).inflate(R.layout.overlay_slide_up_menu_option, this, true); + + _root = findViewById(R.id.slide_up_menu_item_root); + _image = findViewById(R.id.slide_up_menu_item_image); + _text = findViewById(R.id.slide_up_menu_item_text); + _subtext = findViewById(R.id.slide_up_menu_item_subtext); + + setOptionSelected(false); + } + + fun setOptionSelected(isSelected: Boolean): Boolean { + selectedOption = isSelected; + if (!isSelected) { + _root.setBackgroundResource(R.drawable.background_slide_up_option); + } else { + _root.setBackgroundResource(R.drawable.background_slide_up_option_selected); + } + return isSelected; + } + + fun setParentClickListener(listener: (()->Unit)?) { + _parentClickListener = listener; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt new file mode 100644 index 00000000..ad94006f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt @@ -0,0 +1,177 @@ +package com.futo.platformplayer.views.overlays.slideup + +import android.animation.Animator +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.RelativeLayout +import android.widget.TextView +import androidx.core.animation.doOnEnd +import androidx.core.view.children +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event0 + +class SlideUpMenuOverlay : RelativeLayout { + + private var _container: ViewGroup? = null; + private lateinit var _textTitle: TextView; + private lateinit var _textCancel: TextView; + private lateinit var _textOK: TextView; + private lateinit var _viewBackground: View; + private lateinit var _viewOverlayContainer: LinearLayout; + private lateinit var _viewContainer: LinearLayout; + private var _animated: Boolean = true; + + private lateinit var _groupItems: List; + + var isVisible = false + private set; + + val onOK = Event0(); + val onCancel = Event0(); + + constructor(context: Context, attrs: AttributeSet? = null): super(context, attrs) { + init(false, null); + _groupItems = listOf(); + } + + constructor(context: Context, parent: ViewGroup, titleText: String, okText: String?, animated: Boolean, items: List): super(context){ + init(animated, okText); + _container = parent; + if(!_container!!.children.contains(this)) { + _container!!.removeAllViews(); + _container!!.addView(this); + } + _textTitle.text = titleText; + _groupItems = items; + + setItems(items); + } + + + constructor(context: Context, parent: ViewGroup, titleText: String, okText: String?, animated: Boolean, vararg items: View?) + : this(context, parent, titleText, okText, animated, items.filterNotNull().toList()) + + fun setItems(items: List) { + _viewContainer.removeAllViews(); + + for (item in items) { + _viewContainer.addView(item); + + if (item is SlideUpMenuGroup) + item.setParentClickListener { hide() }; + else if(item is SlideUpMenuItem) + item.setParentClickListener { hide() }; + + } + } + + private fun init(animated: Boolean, okText: String?){ + LayoutInflater.from(context).inflate(R.layout.overlay_slide_up_menu, this, true); + + _animated = animated; + + _textTitle = findViewById(R.id.overlay_slide_up_menu_title); + _viewContainer = findViewById(R.id.overlay_slide_up_menu_items); + _textCancel = findViewById(R.id.overlay_slide_up_menu_cancel); + _textOK = findViewById(R.id.overlay_slide_up_menu_ok); + setOk(okText); + + _viewBackground = findViewById(R.id.overlay_slide_up_menu_background); + _viewOverlayContainer = findViewById(R.id.overlay_slide_up_menu_ovelay_container); + + _viewBackground.setOnClickListener { + onCancel.emit(); + hide(); + }; + + _textCancel.setOnClickListener { + onCancel.emit(); + hide(); + }; + } + + fun setOk(textOk: String?) { + if (textOk == null) + _textOK.visibility = View.GONE; + else { + _textOK.text = textOk; + _textOK.setOnClickListener { + onOK.emit(); + }; + _textOK.visibility = View.VISIBLE; + } + } + + fun selectOption(groupTag: Any?, itemTag: Any?, multiSelect: Boolean = false, toggle: Boolean = false): Boolean { + var didSelect = false; + for(view in _groupItems) { + if(view is SlideUpMenuGroup && view.groupTag == groupTag) + didSelect = didSelect || view.selectItem(itemTag); + } + if(groupTag == null) + for(item in _groupItems) + if(item is SlideUpMenuItem) { + if(multiSelect) { + if(item.itemTag == itemTag) + didSelect = didSelect || item.setOptionSelected(!toggle || !item.selectedOption); + } + else + didSelect = didSelect || item.setOptionSelected(item.itemTag == itemTag && (!toggle || !item.selectedOption)); + } + return didSelect; + } + + fun show(){ + isVisible = true; + _container?.post { + _container?.visibility = View.VISIBLE; + _container?.bringToFront(); + } + + if (_animated) { + _viewOverlayContainer.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED); + _viewOverlayContainer.translationY = _viewOverlayContainer.measuredHeight.toFloat() + _viewBackground.alpha = 0f; + + val animations = arrayListOf(); + animations.add(ObjectAnimator.ofFloat(_viewBackground, "alpha", 0.0f, 1.0f).setDuration(500)); + animations.add(ObjectAnimator.ofFloat(_viewOverlayContainer, "translationY", _viewOverlayContainer.measuredHeight.toFloat(), 0.0f).setDuration(500)); + + val animatorSet = AnimatorSet(); + animatorSet.playTogether(animations); + animatorSet.start(); + } else { + _viewBackground.alpha = 1.0f; + _viewOverlayContainer.translationY = 0.0f; + } + } + + fun hide(animate: Boolean = true){ + isVisible = false; + if (_animated && animate) { + val animations = arrayListOf(); + animations.add(ObjectAnimator.ofFloat(_viewBackground, "alpha", 1.0f, 0.0f).setDuration(500)); + animations.add(ObjectAnimator.ofFloat(_viewOverlayContainer, "translationY", 0.0f, _viewOverlayContainer.measuredHeight.toFloat()).setDuration(500)); + + val animatorSet = AnimatorSet(); + animatorSet.doOnEnd { + _container?.post { + _container?.visibility = View.GONE; + } + }; + + animatorSet.playTogether(animations); + animatorSet.start(); + } else { + _viewBackground.alpha = 0.0f; + _viewOverlayContainer.translationY = _viewOverlayContainer.measuredHeight.toFloat(); + _container?.visibility = View.GONE; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuRadioGroup.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuRadioGroup.kt new file mode 100644 index 00000000..43858dd2 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuRadioGroup.kt @@ -0,0 +1,37 @@ +package com.futo.platformplayer.views.overlays.slideup + +import android.content.Context +import android.view.LayoutInflater +import android.view.inputmethod.InputMethodManager +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.views.others.RadioGroupView + +class SlideUpMenuRadioGroup : LinearLayout { + private lateinit var _root: LinearLayout; + private lateinit var _radioGroupView: RadioGroupView; + private lateinit var _inputMethodManager: InputMethodManager; + private lateinit var _textHeader: TextView; + + val selectedOptions: List get() = _radioGroupView.selectedOptions; + val onSelectedChange = Event1>(); + val onSelectedPairChange = Event1>(); + + constructor(context: Context, name: String, options: List>, initiallySelectedOptions: List, multiSelect: Boolean, atLeastOne: Boolean): super(context) { + init(name, options, initiallySelectedOptions, multiSelect, atLeastOne); + } + + private fun init(name: String, options: List>, initiallySelectedOptions: List, multiSelect: Boolean, atLeastOne: Boolean) { + LayoutInflater.from(context).inflate(R.layout.overlay_slide_up_menu_radio_group, this, true); + + _textHeader = findViewById(R.id.text_header); + _textHeader.text = name; + + _root = findViewById(R.id.slide_up_menu_text_input_root); + _radioGroupView = findViewById(R.id.radio_group); + _radioGroupView.setOptions(options, initiallySelectedOptions, multiSelect, atLeastOne); + _radioGroupView.onSelectedChange.subscribe { onSelectedChange.emit(it) }; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuTextInput.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuTextInput.kt new file mode 100644 index 00000000..ca4a39a9 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuTextInput.kt @@ -0,0 +1,57 @@ +package com.futo.platformplayer.views.overlays.slideup + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.inputmethod.InputMethodManager +import android.widget.EditText +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.RelativeLayout +import android.widget.TextView +import com.futo.platformplayer.R +import org.w3c.dom.Text + +class SlideUpMenuTextInput : LinearLayout { + private lateinit var _root: LinearLayout; + private lateinit var _editText: EditText; + private lateinit var _inputMethodManager: InputMethodManager; + + val text: String get() = _editText.text.toString(); + + constructor(context: Context, attrs: AttributeSet? = null): super(context, attrs) { + init(); + } + + constructor(context: Context, name: String? = null): super(context) { + init(name); + } + + private fun init(name: String? = null){ + _inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager; + + LayoutInflater.from(context).inflate(R.layout.overlay_slide_up_menu_text_input, this, true); + + _root = findViewById(R.id.slide_up_menu_text_input_root); + _editText = findViewById(R.id.edit_text); + + if (name != null) { + _editText.hint = name; + } + } + + fun activate() { + _editText.requestFocus(); + _inputMethodManager.showSoftInput(_editText, 0); + } + + fun deactivate() { + _editText.clearFocus(); + _inputMethodManager.hideSoftInputFromWindow(_editText.windowToken, 0); + } + + fun clear() { + _editText.text.clear(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuTitle.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuTitle.kt new file mode 100644 index 00000000..25135cfa --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuTitle.kt @@ -0,0 +1,27 @@ +package com.futo.platformplayer.views.overlays.slideup + +import android.content.Context +import android.util.AttributeSet +import android.util.TypedValue +import android.view.Gravity +import android.view.LayoutInflater +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event1 + +class SlideUpMenuTitle : LinearLayout { + private val _title: TextView; + + constructor(context: Context, attrs: AttributeSet? = null): super(context, attrs) { + LayoutInflater.from(context).inflate(R.layout.overlay_slide_up_menu_title, this, true); + + _title = findViewById(R.id.slide_up_menu_group_title); + } + + fun setTitle(title: String) { + _title.text = title; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/pills/PillButton.kt b/app/src/main/java/com/futo/platformplayer/views/pills/PillButton.kt new file mode 100644 index 00000000..014a24b4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/pills/PillButton.kt @@ -0,0 +1,37 @@ +package com.futo.platformplayer.views.pills + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event0 + +class PillButton : LinearLayout { + val icon: ImageView; + val text: TextView; + val onClick = Event0(); + + constructor(context : Context, attrs : AttributeSet) : super(context, attrs) { + LayoutInflater.from(context).inflate(R.layout.pill_button, this, true); + icon = findViewById(R.id.pill_icon); + text = findViewById(R.id.pill_text); + + val attrArr = context.obtainStyledAttributes(attrs, R.styleable.PillButton, 0, 0); + val attrIconRef = attrArr.getResourceId(R.styleable.PillButton_pillIcon, -1); + if(attrIconRef != -1) + icon.setImageResource(attrIconRef); + else + icon.visibility = View.GONE; + + val attrText = attrArr.getText(R.styleable.PillButton_pillText) ?: ""; + text.text = attrText; + + findViewById(R.id.root).setOnClickListener { + onClick.emit(); + }; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/pills/PillRatingLikesDislikes.kt b/app/src/main/java/com/futo/platformplayer/views/pills/PillRatingLikesDislikes.kt new file mode 100644 index 00000000..e0ea140a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/pills/PillRatingLikesDislikes.kt @@ -0,0 +1,145 @@ +package com.futo.platformplayer.views.pills + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.content.ContextCompat +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.constructs.Event3 +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.toHumanNumber +import com.futo.polycentric.core.ProcessHandle + +class PillRatingLikesDislikes : LinearLayout { + private val _textLikes: TextView; + private val _textDislikes: TextView; + private val _seperator: View; + private val _iconLikes: ImageView; + private val _iconDislikes: ImageView; + + private var _likes = 0L; + private var _hasLiked = false; + private var _dislikes = 0L; + private var _hasDisliked = false; + + val onLikeDislikeUpdated = Event3(); + + constructor(context : Context, attrs : AttributeSet?) : super(context, attrs) { + LayoutInflater.from(context).inflate(R.layout.rating_likesdislikes, this, true); + _textLikes = findViewById(R.id.pill_likes); + _textDislikes = findViewById(R.id.pill_dislikes); + _seperator = findViewById(R.id.pill_seperator); + _iconDislikes = findViewById(R.id.pill_dislike_icon); + _iconLikes = findViewById(R.id.pill_like_icon); + + _iconLikes.setOnClickListener { StatePolycentric.instance.requireLogin(context, "Please login to like") { like(it) }; }; + _textLikes.setOnClickListener { StatePolycentric.instance.requireLogin(context, "Please login to like") { like(it) }; }; + _iconDislikes.setOnClickListener { StatePolycentric.instance.requireLogin(context, "Please login to dislike") { dislike(it) }; }; + _textDislikes.setOnClickListener { StatePolycentric.instance.requireLogin(context, "Please login to dislike") { dislike(it) }; }; + } + + fun setRating(rating: IRating, hasLiked: Boolean = false, hasDisliked: Boolean = false) { + when (rating) { + is RatingLikeDislikes -> { + setRating(rating, hasLiked, hasDisliked); + } + is RatingLikes -> { + setRating(rating, hasLiked, hasDisliked); + } + else -> { + throw Exception("Unknown rating type"); + } + } + } + + fun like(processHandle: ProcessHandle) { + if (_hasDisliked) { + _dislikes--; + _hasDisliked = false; + _textDislikes.text = _dislikes.toHumanNumber(); + } + + if (_hasLiked) { + _likes--; + _hasLiked = false; + } else { + _likes++; + _hasLiked = true; + } + + _textLikes.text = _likes.toHumanNumber(); + updateColors(); + onLikeDislikeUpdated.emit(processHandle, _hasLiked, _hasDisliked); + } + + fun dislike(processHandle: ProcessHandle) { + if (_hasLiked) { + _likes--; + _hasLiked = false; + _textLikes.text = _likes.toHumanNumber(); + } + + if (_hasDisliked) { + _dislikes--; + _hasDisliked = false; + } else { + _dislikes++; + _hasDisliked = true; + } + + _textDislikes.text = _dislikes.toHumanNumber(); + updateColors(); + onLikeDislikeUpdated.emit(processHandle, _hasLiked, _hasDisliked); + } + + private fun updateColors() { + if (_hasLiked) { + _textLikes.setTextColor(ContextCompat.getColor(context, R.color.colorPrimary)); + _iconLikes.setColorFilter(ContextCompat.getColor(context, R.color.colorPrimary)); + } else { + _textLikes.setTextColor(ContextCompat.getColor(context, R.color.white)); + _iconLikes.setColorFilter(ContextCompat.getColor(context, R.color.white)); + } + + if (_hasDisliked) { + _textDislikes.setTextColor(ContextCompat.getColor(context, R.color.colorPrimary)); + _iconDislikes.setColorFilter(ContextCompat.getColor(context, R.color.colorPrimary)); + } else { + _textDislikes.setTextColor(ContextCompat.getColor(context, R.color.white)); + _iconDislikes.setColorFilter(ContextCompat.getColor(context, R.color.white)); + } + } + + fun setRating(rating: RatingLikeDislikes, hasLiked: Boolean = false, hasDisliked: Boolean = false) { + _textLikes.text = rating.likes.toHumanNumber(); + _textDislikes.text = rating.dislikes.toHumanNumber(); + _textLikes.visibility = View.VISIBLE; + _textDislikes.visibility = View.VISIBLE; + _seperator.visibility = View.VISIBLE; + _iconDislikes.visibility = View.VISIBLE; + _likes = rating.likes; + _dislikes = rating.dislikes; + _hasLiked = hasLiked; + _hasDisliked = hasDisliked; + updateColors(); + } + fun setRating(rating: RatingLikes, hasLiked: Boolean = false) { + _textLikes.text = rating.likes.toHumanNumber(); + _textLikes.visibility = View.VISIBLE; + _textDislikes.visibility = View.GONE; + _seperator.visibility = View.GONE; + _iconDislikes.visibility = View.GONE; + _likes = rating.likes; + _dislikes = 0; + _hasLiked = hasLiked; + _hasDisliked = false; + updateColors(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/pills/RoundButton.kt b/app/src/main/java/com/futo/platformplayer/views/pills/RoundButton.kt new file mode 100644 index 00000000..cfed052b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/pills/RoundButton.kt @@ -0,0 +1,61 @@ +package com.futo.platformplayer.views.pills + +import android.content.Context +import android.util.AttributeSet +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.states.StateApp + +class RoundButton : LinearLayout { + val icon: ImageView; + val text: TextView; + + val onClick = Event0(); + val handler: ((RoundButton)->Unit)?; + + val iconResource: Int; + val tagRef: Any?; + + constructor(context : Context, iconRes: Int, title: String, tag: Any? = null, handler: ((RoundButton)->Unit)? = null) : super(context) { + LayoutInflater.from(context).inflate(R.layout.button_round, this, true); + this.tagRef = tag; + this.handler = handler; + this.iconResource = iconRes; + + icon = findViewById(R.id.pill_icon); + text = findViewById(R.id.pill_text); + + icon.setImageResource(iconRes); + text.text = title; + + icon.setOnClickListener { + onClick.emit(); + if(handler != null) + handler(this@RoundButton); + }; + } + + constructor(context : Context, attrs : AttributeSet) : super(context, attrs) { + LayoutInflater.from(context).inflate(R.layout.button_round, this, true); + tagRef = null; + handler = null; + iconResource = -1; + + icon = findViewById(R.id.pill_icon); + text = findViewById(R.id.pill_text); + + findViewById(R.id.root).setOnClickListener { + onClick.emit(); + }; + } + + companion object { + val WIDTH = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 55f, StateApp.instance.context.resources.displayMetrics); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/pills/RoundButtonGroup.kt b/app/src/main/java/com/futo/platformplayer/views/pills/RoundButtonGroup.kt new file mode 100644 index 00000000..517b44f8 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/pills/RoundButtonGroup.kt @@ -0,0 +1,91 @@ +package com.futo.platformplayer.views.pills + +import android.content.Context +import android.graphics.Color +import android.text.Layout +import android.util.AttributeSet +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.core.view.children +import com.futo.platformplayer.R + +class RoundButtonGroup : LinearLayout { + var lastWidth = 0; + + private val _lock = Object(); + + private var _buttons: List = listOf(); + private var _numVisible = 0; + + var alwaysShowLastButton = false; + + constructor(context : Context) : super(context) { + orientation = HORIZONTAL; + } + constructor(context : Context, attributes: AttributeSet) : super(context, attributes) { + orientation = HORIZONTAL; + } + + fun getVisibleButtons(): List { + return _buttons.take(_numVisible).toList(); + } + fun getInvisibleButtons(): List { + return _buttons.drop(_numVisible).toList(); + } + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + super.onLayout(changed, l, t, r, b); + val newWidth = width; + if(newWidth != lastWidth) { + lastWidth = newWidth; + setViews(); + } + } + + fun setButtons(vararg buttons: RoundButton) { + _buttons = buttons.toList(); + setViews(); + } + + fun setButtonVisibility(filter: (RoundButton)->Boolean) { + synchronized(_lock) { + for(button in _buttons) + button.visibility = if(filter(button)) View.VISIBLE else View.GONE; + } + } + fun getButtonByTag(tag: Any) : RoundButton? { + synchronized(_lock) { + return _buttons.find { it.tagRef == tag }; + } + } + + private fun setViews() { + if(lastWidth == 0) + return; + + val buttonSpace = ((lastWidth / RoundButton.WIDTH)).toInt(); + _numVisible = buttonSpace - + if(alwaysShowLastButton && buttonSpace < _buttons.size) 1 else 0; + + post { + synchronized(_lock) { + removeAllViews(); + for (i in 0 until buttonSpace) { + if (i < _buttons.size) { + val index = + if (alwaysShowLastButton && i == buttonSpace - 1) _buttons.size - 1 else i; + val button = _buttons[index]; + button.layoutParams = + LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).let { + it.weight = 1f; + return@let it; + }; + addView(button); + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/platform/PlatformIndicator.kt b/app/src/main/java/com/futo/platformplayer/views/platform/PlatformIndicator.kt new file mode 100644 index 00000000..8ef8520c --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/platform/PlatformIndicator.kt @@ -0,0 +1,25 @@ +package com.futo.platformplayer.views.platform + +import android.content.Context +import android.util.AttributeSet +import com.futo.platformplayer.states.StatePlatform + +class PlatformIndicator : androidx.appcompat.widget.AppCompatImageView { + constructor(context : Context, attrs : AttributeSet) : super(context, attrs) { + } + + fun clearPlatform() { + setImageResource(0); + } + fun setPlatformFromClientID(platformType : String?) { + if(platformType == null) + setImageResource(0); + else { + val result = StatePlatform.instance.getPlatformIcon(platformType); + if (result != null) + result.setImageView(this); + else + setImageResource(0); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/platform/PlatformLinkView.kt b/app/src/main/java/com/futo/platformplayer/views/platform/PlatformLinkView.kt new file mode 100644 index 00000000..8a685dbd --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/platform/PlatformLinkView.kt @@ -0,0 +1,55 @@ +package com.futo.platformplayer.views.platform + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.content.ContextCompat.startActivity +import com.futo.platformplayer.R +import com.futo.platformplayer.states.StatePlatform +import com.futo.polycentric.core.ClaimType + + +class PlatformLinkView : LinearLayout { + private var _imagePlatform: ImageView; + private var _textName: TextView; + private var _url: String? = null; + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + LayoutInflater.from(context).inflate(R.layout.view_platform_link, this, true); + + _imagePlatform = findViewById(R.id.image_platform); + _textName = findViewById(R.id.text_name); + + val root: LinearLayout = findViewById(R.id.root); + root.setOnClickListener { + val i = Intent(Intent.ACTION_VIEW); + val uri = Uri.parse(_url); + + //TODO: Check if it is OK that this is commented + /*if (uri.host != null && uri.host!!.endsWith("youtube.com") && uri.path != null && uri.path!!.startsWith("/redirect")) { + val redirectUrl = uri.getQueryParameter("q"); + i.data = Uri.parse(redirectUrl); + } else {*/ + i.data = uri; + //} + startActivity(context, i, null); + }; + } + + fun setPlatform(name: String, url: String) { + val icon = StatePlatform.instance.getClientOrNullByUrl(url)?.icon; + if (icon != null) { + icon.setImageView(_imagePlatform, R.drawable.ic_web_white); + } else { + _imagePlatform.setImageResource(R.drawable.ic_web_white); + } + + _textName.text = name; + _url = url; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt b/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt new file mode 100644 index 00000000..41c38b72 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt @@ -0,0 +1,189 @@ +package com.futo.platformplayer.views.segments + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.R +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.api.media.structures.IAsyncPager +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment +import com.futo.platformplayer.views.adapters.CommentViewHolder +import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader + +class CommentsList : ConstraintLayout { + private val _llmReplies: LinearLayoutManager; + private val _taskLoadComments = if(!isInEditMode) TaskHandler IPager, IPager>(StateApp.instance.scopeGetter, { it(); }) + .success { pager -> onCommentsLoaded(pager); } + .exception { + Logger.w(ChannelFragment.TAG, "Failed to load comments.", it); + UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, ::fetchComments); + } else TaskHandler(IPlatformVideoDetails::class.java, StateApp.instance.scopeGetter); + + private var _nextPageHandler: TaskHandler, List> = TaskHandler, List>(StateApp.instance.scopeGetter, { + if (it is IAsyncPager<*>) + it.nextPageAsync(); + else + it.nextPage(); + + return@TaskHandler it.getResults(); + }).success { + onNextPageLoaded(it); + }.exception { + Logger.w(TAG, "Failed to load next page.", it); + UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadNextPage() }); + }; + + private val _scrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy); + onScrolled(); + } + }; + + private var _loader: (suspend () -> IPager)? = null; + private val _adapterComments: InsertedViewAdapterWithLoader; + private val _recyclerComments: RecyclerView; + private val _comments: ArrayList = arrayListOf(); + private var _commentsPager: IPager? = null; + private var _loading = false; + private val _prependedView: FrameLayout; + private var _readonly: Boolean = false; + + var onClick = Event1(); + var onCommentsLoaded = Event1(); + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { + LayoutInflater.from(context).inflate(R.layout.view_comments_list, this, true); + + _recyclerComments = findViewById(R.id.recycler_comments); + + _prependedView = FrameLayout(context); + _prependedView.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT); + + _adapterComments = InsertedViewAdapterWithLoader(context, arrayListOf(_prependedView), arrayListOf(), + childCountGetter = { _comments.size }, + childViewHolderBinder = { viewHolder, position -> viewHolder.bind(_comments[position], _readonly); }, + childViewHolderFactory = { viewGroup, _ -> + val holder = CommentViewHolder(viewGroup); + holder.onClick.subscribe { c -> onClick.emit(c) }; + return@InsertedViewAdapterWithLoader holder; + } + ); + + _llmReplies = LinearLayoutManager(context); + _recyclerComments.layoutManager = _llmReplies; + _recyclerComments.adapter = _adapterComments; + _recyclerComments.addOnScrollListener(_scrollListener); + } + + fun addComment(comment: IPlatformComment) { + _comments.add(0, comment); + _adapterComments.notifyItemRangeInserted(_adapterComments.childToParentPosition(0), 1); + } + + fun setPrependedView(view: View) { + _prependedView.removeAllViews(); + _prependedView.addView(view); + } + + private fun onScrolled() { + val visibleItemCount = _recyclerComments.childCount; + val firstVisibleItem = _llmReplies.findFirstVisibleItemPosition(); + val visibleThreshold = 15; + if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= _comments.size) { + loadNextPage(); + } + } + + private fun loadNextPage() { + val pager: IPager = _commentsPager ?: return; + + if(pager.hasMorePages()) { + setLoading(true); + _nextPageHandler.run(pager); + } + } + + private fun onNextPageLoaded(comments: List) { + setLoading(false); + + if (comments.isEmpty()) { + return; + } + + val posBefore = _comments.size; + _comments.addAll(comments); + _adapterComments.notifyItemRangeInserted(_adapterComments.childToParentPosition(posBefore), comments.size); + } + + private fun onCommentsLoaded(pager: IPager) { + setLoading(false); + + _comments.addAll(pager.getResults()); + _adapterComments.notifyDataSetChanged(); + _commentsPager = pager; + onCommentsLoaded.emit(_comments.size); + } + + fun load(readonly: Boolean, loader: suspend () -> IPager) { + cancel(); + + _readonly = readonly; + setLoading(true); + _comments.clear(); + _commentsPager = null; + _adapterComments.notifyDataSetChanged(); + + _loader = loader; + fetchComments(); + } + + private fun setLoading(loading: Boolean) { + if (_loading == loading) { + return; + } + + _loading = loading; + _adapterComments.setLoading(loading); + } + + private fun fetchComments() { + val loader = _loader ?: return; + _taskLoadComments.run(loader); + } + + fun clear() { + cancel(); + _comments.clear(); + _commentsPager = null; + _adapterComments.notifyDataSetChanged(); + } + + fun cancel() { + _taskLoadComments.cancel(); + _nextPageHandler.cancel(); + } + + fun replaceComment(c: PolycentricPlatformComment, newComment: PolycentricPlatformComment) { + val index = _comments.indexOf(c); + _comments[index] = newComment; + _adapterComments.notifyItemChanged(_adapterComments.childToParentPosition(index)); + } + + companion object { + private const val TAG = "CommentsList"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/sources/SourceHeaderView.kt b/app/src/main/java/com/futo/platformplayer/views/sources/SourceHeaderView.kt new file mode 100644 index 00000000..612b7168 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/sources/SourceHeaderView.kt @@ -0,0 +1,91 @@ +package com.futo.platformplayer.views.sources + +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.net.Uri +import android.util.AttributeSet +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.states.StatePlugins +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig + +class SourceHeaderView : LinearLayout { + private val _sourceImage: ImageView; + private val _sourceTitle: TextView; + private val _sourceBy: TextView; + private val _sourceAuthorID: TextView; + private val _sourceDescription: TextView; + + private val _sourceVersion: TextView; + private val _sourceRepositoryUrl: TextView; + private val _sourceScriptUrl: TextView; + + private var _config : SourcePluginConfig? = null; + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + inflate(context, R.layout.view_source_header, this); + + _sourceImage = findViewById(R.id.source_image); + _sourceTitle = findViewById(R.id.source_title); + _sourceBy = findViewById(R.id.source_by); + _sourceAuthorID = findViewById(R.id.source_author_id); + _sourceDescription = findViewById(R.id.source_description); + + _sourceVersion = findViewById(R.id.source_version); + _sourceRepositoryUrl = findViewById(R.id.source_repo); + _sourceScriptUrl = findViewById(R.id.source_script); + + _sourceBy.setOnClickListener { + if(!_config?.authorUrl.isNullOrEmpty()) + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(_config?.authorUrl))); + } + _sourceRepositoryUrl.setOnClickListener { + if(!_config?.repositoryUrl.isNullOrEmpty()) + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(_config?.repositoryUrl))); + }; + _sourceScriptUrl.setOnClickListener { + if(!_config?.absoluteScriptUrl.isNullOrEmpty()) + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(_config?.absoluteScriptUrl))); + }; + } + + fun loadConfig(config: SourcePluginConfig) { + _config = config; + + val loadedIcon = StatePlugins.instance.getPluginIconOrNull(config.id); + if(loadedIcon != null) + loadedIcon.setImageView(_sourceImage); + else + Glide.with(_sourceImage) + .load(config.absoluteIconUrl) + .into(_sourceImage); + + _sourceTitle.text = config.name; + _sourceBy.text = config.author + _sourceDescription.text = config.description; + _sourceVersion.text = config.version.toString(); + _sourceScriptUrl.text = config.absoluteScriptUrl; + _sourceRepositoryUrl.text = config.repositoryUrl; + _sourceAuthorID.text = ""; + + if(!config.authorUrl.isNullOrEmpty()) + _sourceBy.setTextColor(resources.getColor(R.color.colorPrimary)); + else + _sourceBy.setTextColor(Color.WHITE); + } + + fun clear() { + _config = null; + _sourceTitle.text = ""; + _sourceBy.text = "" + _sourceDescription.text = ""; + _sourceVersion.text = ""; + _sourceScriptUrl.text = ""; + _sourceRepositoryUrl.text = ""; + _sourceAuthorID.text = ""; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/sources/SourceInfoView.kt b/app/src/main/java/com/futo/platformplayer/views/sources/SourceInfoView.kt new file mode 100644 index 00000000..ec04f7ec --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/sources/SourceInfoView.kt @@ -0,0 +1,55 @@ +package com.futo.platformplayer.views.sources + +import android.content.Context +import android.graphics.Color +import android.util.AttributeSet +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.views.others.BulletPointView + +class SourceInfoView : LinearLayout { + val image: ImageView; + val textTitle: TextView; + val textDescription: TextView; + val bulletPoints: LinearLayout; + + var onClick = Event1>(); + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + inflate(context, R.layout.view_source_info, this); + image = findViewById(R.id.icon); + textTitle = findViewById(R.id.title); + textDescription = findViewById(R.id.description); + bulletPoints = findViewById(R.id.bullet_points); + bulletPoints.removeAllViews(); + } + constructor(context: Context, iconId: Int, title: String, description: String, points: List = listOf(), isLinks: Boolean = false) : super(context) { + inflate(context, R.layout.view_source_info, this); + image = findViewById(R.id.icon); + textTitle = findViewById(R.id.title); + textDescription = findViewById(R.id.description); + bulletPoints = findViewById(R.id.bullet_points); + + image.setImageResource(iconId); + textTitle.text = title; + textDescription.text = description; + + val primaryColor = resources.getColor(R.color.colorPrimary); + + bulletPoints.removeAllViews(); + for(point in points) { + bulletPoints.addView( + BulletPointView(context) + .withText(point) + .withTextColor(if(!isLinks) Color.WHITE else primaryColor)); + } + } + + fun withDescriptionColor(color: Int) : SourceInfoView { + textDescription.setTextColor(color); + return this; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/sources/SourceUnderConstructionView.kt b/app/src/main/java/com/futo/platformplayer/views/sources/SourceUnderConstructionView.kt new file mode 100644 index 00000000..144db1cf --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/sources/SourceUnderConstructionView.kt @@ -0,0 +1,29 @@ +package com.futo.platformplayer.views.sources + +import android.content.Context +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.R +import com.futo.platformplayer.models.ImageVariable + +class SourceUnderConstructionView : LinearLayout { + private val _imageSource: ImageView; + private val _textSource: TextView; + private val _textSourceSubtitle: TextView; + + private val _buttonAdd: LinearLayout; + + constructor(context: Context, name: String, logo: ImageVariable): super(context) { + inflate(context, R.layout.list_source_construction, this); + + _imageSource = findViewById(R.id.image_source); + _textSource = findViewById(R.id.text_source); + _textSourceSubtitle = findViewById(R.id.text_source_subtitle); + _buttonAdd = findViewById(R.id.button_add); + + + logo.setImageView(_imageSource); + _textSource.text = name; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/subscriptions/SubscribeButton.kt b/app/src/main/java/com/futo/platformplayer/views/subscriptions/SubscribeButton.kt new file mode 100644 index 00000000..c0c7f6e4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/subscriptions/SubscribeButton.kt @@ -0,0 +1,123 @@ +package com.futo.platformplayer.views.subscriptions + +import android.content.Context +import android.graphics.drawable.Animatable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.* +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StateSubscriptions +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel + +class SubscribeButton : LinearLayout { + private val _root: FrameLayout; + private val _textSubscribe: TextView; + private val _channelLoader: ImageView; + + var channel : IPlatformChannel? = null + private set; + var url : String? = null + private set; + + private var _isSubscribed: Boolean = false; + + private val _subscribeTask = if (!isInEditMode) { + TaskHandler(StateApp.instance.scopeGetter, StatePlatform.instance::getChannelLive).success(::handleSubscribe) + } else { null }; + + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { + LayoutInflater.from(context).inflate(R.layout.button_subscribe, this, true); + + _textSubscribe = findViewById(R.id.text_subscribe); + _channelLoader = findViewById(R.id.channel_loader); + + _root = findViewById(R.id.root); + _root.visibility = View.INVISIBLE; + _root.setOnClickListener { + if (channel == null && url == null) + return@setOnClickListener; + + var isSubscribed = if(channel != null) + StateSubscriptions.instance.isSubscribed(channel!!) + else StateSubscriptions.instance.isSubscribed(url!!) + + if (isSubscribed) + handleUnSubscribe(channel?.url ?: url!!); + else { + if (channel != null) + handleSubscribe(channel!!); + else if (url != null) { + setIsLoading(true); + _subscribeTask?.run(url!!); + } + } + }; + + setIsLoading(false); + } + + private fun handleSubscribe(channel: IPlatformChannel) { + setIsLoading(false); + StateSubscriptions.instance.addSubscription(channel); + UIDialogs.toast(context, "Subscribed to ${channel.name}"); + setIsSubscribed(true); + } + private fun handleUnSubscribe(url: String) { + setIsLoading(false); + val removed = StateSubscriptions.instance.removeSubscription(url); + if (removed != null) + UIDialogs.toast(context, "Unsubscribed from ${removed!!.channel.name}"); + setIsSubscribed(false); + } + + fun setSubscribeChannel(url: String) { + this.channel = null; + this.url = url; + setIsSubscribed(StateSubscriptions.instance.isSubscribed(url)); + } + fun setSubscribeChannel(channel: IPlatformChannel) { + this.channel = channel; + this.url = null; + setIsSubscribed(StateSubscriptions.instance.isSubscribed(channel)); + } + + private fun setIsLoading(isLoading: Boolean) { + if (isLoading) { + _channelLoader.visibility = View.VISIBLE; + (_channelLoader.drawable as Animatable?)?.start(); + } else { + (_channelLoader.drawable as Animatable?)?.stop(); + _channelLoader.visibility = View.GONE; + } + } + + private fun setIsSubscribed(isSubcribed: Boolean) { + val url = this.channel?.url ?: this.url; + if (url != null) { + if (isSubcribed) { + _textSubscribe.text = resources.getString(R.string.unsubscribe); + _root.setBackgroundResource(R.drawable.background_button_accent); + } + else { + _textSubscribe.text = resources.getString(R.string.subscribe); + _root.setBackgroundResource(R.drawable.background_button_primary); + } + _root.visibility = VISIBLE; + } + else + _root.visibility = INVISIBLE; + + _isSubscribed = isSubcribed; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/subscriptions/SubscriptionBar.kt b/app/src/main/java/com/futo/platformplayer/views/subscriptions/SubscriptionBar.kt new file mode 100644 index 00000000..452f311f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/subscriptions/SubscriptionBar.kt @@ -0,0 +1,67 @@ +package com.futo.platformplayer.views.subscriptions + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.channels.SerializedChannel +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.models.Subscription +import com.futo.platformplayer.states.StateSubscriptions +import com.futo.platformplayer.views.AnyAdapterView +import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny +import com.futo.platformplayer.views.others.ToggleTagView +import com.futo.platformplayer.views.adapters.viewholders.SubscriptionBarViewHolder + +class SubscriptionBar : LinearLayout { + private var _adapterView: AnyAdapterView? = null; + private val _tagsContainer: LinearLayout; + + val onClickChannel = Event1(); + + + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + inflate(context, R.layout.view_subscription_bar, this); + + val subscriptions = StateSubscriptions.instance.getSubscriptions(); + _adapterView = findViewById(R.id.recycler_creators).asAny(subscriptions, orientation = RecyclerView.HORIZONTAL) { + it.onClick.subscribe { c -> + onClickChannel.emit(c.channel); + }; + }; + _tagsContainer = findViewById(R.id.container_tags); + } + + + fun setToggles(vararg buttons: Toggle) { + _tagsContainer.removeAllViews(); + for(button in buttons) { + _tagsContainer.addView(ToggleTagView(context).apply { + this.setInfo(button.name, button.isActive); + this.onClick.subscribe { button.action(it); }; + }); + } + } + + class Toggle { + val name: String; + val icon: Int; + val action: (Boolean)->Unit; + val isActive: Boolean; + + constructor(name: String, icon: Int, isActive: Boolean = false, action: (Boolean)->Unit) { + this.name = name; + this.icon = icon; + this.action = action; + this.isActive = isActive; + } + constructor(name: String, isActive: Boolean = false, action: (Boolean)->Unit) { + this.name = name; + this.icon = 0; + this.action = action; + this.isActive = isActive; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoThumbnailPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoThumbnailPlayer.kt new file mode 100644 index 00000000..e8ef2e6f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoThumbnailPlayer.kt @@ -0,0 +1,118 @@ +package com.futo.platformplayer.views.video + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.ImageButton +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.* +import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.helpers.VideoHelper +import com.futo.platformplayer.video.PlayerManager +import com.google.android.exoplayer2.ui.PlayerControlView +import com.google.android.exoplayer2.ui.StyledPlayerView + + +class FutoThumbnailPlayer : FutoVideoPlayerBase { + companion object { + private const val TAG = "FutoThumbnailVideoPlayer" + private const val PLAYER_STATE_NAME : String = "ThumbnailPlayer"; + } + + //Views + private val videoView : StyledPlayerView; + private val videoControls : PlayerControlView; + private val buttonMute : ImageButton; + private val buttonUnMute : ImageButton; + + private val textDurationInverse : TextView; + private val containerDuration : LinearLayout; + private val containerLive : LinearLayout; + + //Events + private val _evMuteChanged = mutableListOf<(FutoThumbnailPlayer, Boolean)->Unit>(); + + + constructor(context : Context, attrs: AttributeSet? = null) : super(PLAYER_STATE_NAME, context, attrs) { + LayoutInflater.from(context).inflate(R.layout.thumbnail_video_view, this, true); + + videoView = findViewById(R.id.video_player); + videoControls = findViewById(R.id.video_player_controller); + buttonMute = videoControls.findViewById(R.id.thumbnail_player_mute); + buttonUnMute = videoControls.findViewById(R.id.thumbnail_player_unmute); + textDurationInverse = videoControls.findViewById(R.id.exo_duration_inverse); + containerDuration = videoControls.findViewById(R.id.exo_duration_container); + containerLive = videoControls.findViewById(R.id.exo_live_container); + + videoControls.setProgressUpdateListener { position, bufferedPosition -> + if(position < 0) + textDurationInverse.visibility = View.INVISIBLE; + else + textDurationInverse.visibility = View.VISIBLE; + val newText = Math.max(0, ((exoPlayer?.player?.duration ?: 0) - position)).toHumanTime(true); + if(newText != "0:00") + textDurationInverse.text = newText; + } + + buttonMute.setOnClickListener { + mute(); + } + buttonUnMute.setOnClickListener { + unmute(); + } + } + + fun setLive(live : Boolean) { + if(live) { + containerDuration.visibility = GONE; + containerLive.visibility = VISIBLE; + } + else { + containerLive.visibility = GONE; + containerDuration.visibility = VISIBLE; + } + } + + fun setPlayer(player : PlayerManager?){ + changePlayer(player); + player?.attach(videoView, PLAYER_STATE_NAME); + videoControls.player = player?.player; + } + fun setTempDuration(duration : Long, ms : Boolean) { + textDurationInverse.text = duration.toHumanTime(ms); + } + + //Controls + fun mute(){ + this.exoPlayer?.setMuted(true); + this.buttonMute.visibility = View.GONE; + this.buttonUnMute.visibility = View.VISIBLE; + _evMuteChanged.forEach { it(this, false) }; + } + fun unmute(){ + this.exoPlayer?.setMuted(false); + this.buttonMute.visibility = View.VISIBLE; + this.buttonUnMute.visibility = View.GONE; + _evMuteChanged.forEach { it(this, true) }; + } + + + //Events + fun setMuteChangedListener(callback : (FutoThumbnailPlayer, Boolean) -> Unit) { + _evMuteChanged.add(callback); + } + + fun setPreview(video: IPlatformVideoDetails) { + val videoSource = VideoHelper.selectBestVideoSource(video.video, Settings.instance.playback.getPreferredPreviewQualityPixelCount(), PREFERED_VIDEO_CONTAINERS); + val audioSource = VideoHelper.selectBestAudioSource(video.video, PREFERED_AUDIO_CONTAINERS, Settings.instance.playback.getPrimaryLanguage(context)); + setSource(videoSource, audioSource,true, false); + } + override fun onSourceChanged(videoSource: IVideoSource?, audioSource: IAudioSource?, resume: Boolean) { + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt new file mode 100644 index 00000000..603978db --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt @@ -0,0 +1,452 @@ +package com.futo.platformplayer.views.video + +import android.content.Context +import android.content.res.Resources +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.util.Log +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.RelativeLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.setMargins +import com.futo.platformplayer.* +import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.constructs.Event3 +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.views.behavior.GestureControlView +import com.google.android.exoplayer2.ExoPlayer +import com.google.android.exoplayer2.PlaybackParameters +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout +import com.google.android.exoplayer2.ui.PlayerControlView +import com.google.android.exoplayer2.ui.StyledPlayerView +import com.google.android.exoplayer2.ui.TimeBar +import com.google.android.exoplayer2.video.VideoSize +import kotlin.math.abs + + +class FutoVideoPlayer : FutoVideoPlayerBase { + companion object { + private const val TAG = "FutoVideoPlayer" + private const val PLAYER_STATE_NAME : String = "DetailPlayer"; + } + + var isFullScreen: Boolean = false + private set; + + //Views + private val _root: ConstraintLayout; + private val _videoView: StyledPlayerView; + + val videoControls: PlayerControlView; + private val _videoControls_fullscreen: PlayerControlView; + val background: FrameLayout; + private val _layoutControls: FrameLayout; + val gestureControl: GestureControlView; + + //Custom buttons + private val _control_fullscreen: ImageButton; + private val _control_videosettings: ImageButton; + private val _control_minimize: ImageButton; + private val _control_rotate_lock: ImageButton; + private val _control_cast: ImageButton; + private val _control_play: ImageButton; + private val _time_bar: TimeBar; + + private val _control_fullscreen_fullscreen: ImageButton; + private val _control_videosettings_fullscreen: ImageButton; + private val _control_minimize_fullscreen: ImageButton; + private val _control_rotate_lock_fullscreen: ImageButton; + private val _control_play_fullscreen: ImageButton; + private val _time_bar_fullscreen: TimeBar; + private val _overlay_brightness: FrameLayout; + + private val _title_fullscreen: TextView; + private val _author_fullscreen: TextView; + private var _shouldRestartHideJobOnPlaybackStateChange: Boolean = false; + + private var _lastSourceFit: Int? = null; + private var _originalBottomMargin: Int = 0; + + private var _isControlsLocked: Boolean = false; + + private val _time_bar_listener: TimeBar.OnScrubListener; + + var isFitMode : Boolean = false + private set; + + //Events + val onMinimize = Event1(); + val onVideoSettings = Event1(); + val onToggleFullScreen = Event1(); + val onSourceChanged = Event3(); + val onSourceEnded = Event0(); + + val onVideoClicked = Event0(); + val onTimeBarChanged = Event2(); + + constructor(context: Context, attrs: AttributeSet? = null) : super(PLAYER_STATE_NAME, context, attrs) { + LayoutInflater.from(context).inflate(R.layout.video_view, this, true); + _root = findViewById(R.id.videoview_root); + _videoView = findViewById(R.id.video_player); + + val subs = _videoView.subtitleView; + + videoControls = findViewById(R.id.video_player_controller); + _control_fullscreen = videoControls.findViewById(R.id.exo_fullscreen); + _control_videosettings = videoControls.findViewById(R.id.exo_settings); + _control_minimize = videoControls.findViewById(R.id.exo_minimize); + _control_rotate_lock = videoControls.findViewById(R.id.exo_rotate_lock); + _control_cast = videoControls.findViewById(R.id.exo_cast); + _control_play = videoControls.findViewById(com.google.android.exoplayer2.ui.R.id.exo_play); + _time_bar = videoControls.findViewById(com.google.android.exoplayer2.ui.R.id.exo_progress); + + _videoControls_fullscreen = findViewById(R.id.video_player_controller_fullscreen); + _control_fullscreen_fullscreen = _videoControls_fullscreen.findViewById(R.id.exo_fullscreen); + _control_minimize_fullscreen = _videoControls_fullscreen.findViewById(R.id.exo_minimize); + _control_videosettings_fullscreen = _videoControls_fullscreen.findViewById(R.id.exo_settings); + _control_rotate_lock_fullscreen = _videoControls_fullscreen.findViewById(R.id.exo_rotate_lock); + _control_play_fullscreen = videoControls.findViewById(com.google.android.exoplayer2.ui.R.id.exo_play); + _time_bar_fullscreen = _videoControls_fullscreen.findViewById(com.google.android.exoplayer2.ui.R.id.exo_progress); + + _overlay_brightness = findViewById(R.id.overlay_brightness); + + _title_fullscreen = _videoControls_fullscreen.findViewById(R.id.exo_title); + _author_fullscreen = _videoControls_fullscreen.findViewById(R.id.exo_author); + + background = findViewById(R.id.layout_controls_background); + _layoutControls = findViewById(R.id.layout_controls); + gestureControl = findViewById(R.id.gesture_control); + + _videoView?.videoSurfaceView?.let { gestureControl.setupTouchArea(it, _layoutControls, background); }; + gestureControl.onSeek.subscribe { seekFromCurrent(it); }; + gestureControl.onSoundAdjusted.subscribe { setVolume(it) }; + gestureControl.onToggleFullscreen.subscribe { setFullScreen(!isFullScreen) }; + gestureControl.onBrightnessAdjusted.subscribe { + if (it == 1.0f) { + _overlay_brightness.visibility = View.GONE; + } else { + _overlay_brightness.visibility = View.VISIBLE; + _overlay_brightness.setBackgroundColor(Color.valueOf(0.0f, 0.0f, 0.0f, (1.0f - it)).toArgb()); + } + }; + + if(!isInEditMode) { + _videoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM; + val player = StatePlayer.instance.getPlayerOrCreate(context); + //player.modifyState(PLAYER_STATE_NAME, { it.scaleType = MediaPlayer.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING}) + changePlayer(player); + + videoControls.player = player.player; + _videoControls_fullscreen.player = player.player; + } + + val attrShowSettings = if(attrs != null) + context.obtainStyledAttributes(attrs, R.styleable.FutoVideoPlayer, 0, 0).getBoolean(R.styleable.FutoVideoPlayer_showSettings, false) ?: false; + else false; + val attrShowFullScreen = if(attrs != null) + context.obtainStyledAttributes(attrs, R.styleable.FutoVideoPlayer, 0, 0).getBoolean(R.styleable.FutoVideoPlayer_showFullScreen, false) ?: false; + else false; + val attrShowMinimize = if(attrs != null) + context.obtainStyledAttributes(attrs, R.styleable.FutoVideoPlayer, 0, 0).getBoolean(R.styleable.FutoVideoPlayer_showMinimize, false) ?: false; + else false; + + if (!attrShowSettings) + _control_videosettings.visibility = View.GONE; + if (!attrShowFullScreen) + _control_fullscreen.visibility = View.GONE; + if (!attrShowMinimize) + _control_minimize.visibility = View.GONE; + + _time_bar_listener = object : TimeBar.OnScrubListener { + override fun onScrubStart(timeBar: TimeBar, position: Long) { + gestureControl.restartHideJob(); + } + + override fun onScrubMove(timeBar: TimeBar, position: Long) { + gestureControl.restartHideJob(); + } + + override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { + gestureControl.restartHideJob(); + } + }; + + _time_bar.addListener(_time_bar_listener); + _time_bar_fullscreen.addListener(_time_bar_listener); + + _control_fullscreen.setOnClickListener { + setFullScreen(true); + } + _control_videosettings.setOnClickListener { + onVideoSettings.emit(this); + } + _control_minimize.setOnClickListener { + onMinimize.emit(this); + }; + _control_rotate_lock.setOnClickListener { + StatePlayer.instance.rotationLock = !StatePlayer.instance.rotationLock; + updateRotateLock(); + }; + _control_cast.setOnClickListener { + UIDialogs.showCastingDialog(context); + }; + + _control_minimize_fullscreen.setOnClickListener { + onMinimize.emit(this); + }; + _control_fullscreen_fullscreen.setOnClickListener { + setFullScreen(false); + } + _control_videosettings_fullscreen.setOnClickListener { + onVideoSettings.emit(this); + } + _control_rotate_lock_fullscreen.setOnClickListener { + StatePlayer.instance.rotationLock = !StatePlayer.instance.rotationLock; + updateRotateLock(); + }; + + videoControls.setProgressUpdateListener { position, bufferedPosition -> + onTimeBarChanged.emit(position, bufferedPosition); + } + + if(!isInEditMode) { + gestureControl.hideControls(); + } + } + + fun attachPlayer() { + exoPlayer?.attach(_videoView, PLAYER_STATE_NAME); + } + + fun setArtwork(drawable: Drawable?) { + if (drawable != null) { + _videoView.defaultArtwork = drawable; + _videoView.useArtwork = true; + fitHeight(); + } else { + _videoView.defaultArtwork = null; + _videoView.useArtwork = false; + } + } + + fun hideControls(animated: Boolean) { + gestureControl.hideControls(animated); + } + + fun setMetadata(title: String, author: String) { + _title_fullscreen.text = title; + _author_fullscreen.text = author; + } + + fun setPlaybackRate(playbackRate: Float) { + val exoPlayer = exoPlayer?.player; + Logger.i(TAG, "setPlaybackRate playbackRate=$playbackRate exoPlayer=${exoPlayer}"); + + val param = PlaybackParameters(playbackRate); + exoPlayer?.playbackParameters = param; + } + + fun getPlaybackRate(): Float { + return exoPlayer?.player?.playbackParameters?.speed ?: 1.0f; + } + + fun setFullScreen(fullScreen: Boolean) { + if (isFullScreen == fullScreen) { + return; + } + + if (fullScreen) { + val lp = background.layoutParams as ConstraintLayout.LayoutParams; + lp.bottomMargin = 0; + background.layoutParams = lp; + + gestureControl.hideControls(); + //videoControlsBar.visibility = View.GONE; + _videoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT; + fillHeight(); + _videoControls_fullscreen.show(); + videoControls.hide(); + } + else { + val lp = background.layoutParams as ConstraintLayout.LayoutParams; + lp.bottomMargin = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, Resources.getSystem().displayMetrics).toInt(); + background.layoutParams = lp; + + gestureControl.hideControls(); + //videoControlsBar.visibility = View.VISIBLE; + _videoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM; + fitHeight(); + videoControls.show(); + _videoControls_fullscreen.hide(); + } + + gestureControl.setFullscreen(fullScreen); + onToggleFullScreen.emit(fullScreen); + isFullScreen = fullScreen; + } + + fun lockControlsAlpha(locked : Boolean) { + if(locked && _isControlsLocked != locked) { + _isControlsLocked = locked; + _layoutControls.visibility = View.GONE; + } + else if(!locked && _isControlsLocked != locked) + _isControlsLocked = locked; + } + + override fun play() { + super.play(); + } + + override fun onVideoSizeChanged(videoSize: VideoSize) { + _lastSourceFit = null; + if(isFullScreen) + fillHeight(); + else if(_root.layoutParams.height != MATCH_PARENT) + fitHeight(videoSize); + } + + override fun beforeSourceChanged() { + super.beforeSourceChanged(); + attachPlayer(); + } + override fun onSourceChanged(videoSource: IVideoSource?, audioSource: IAudioSource?, resume: Boolean) { + onSourceChanged.emit(videoSource, audioSource, resume); + } + + override fun onPlaybackStateChanged(playbackState: Int) { + Logger.i(TAG, "onPlaybackStateChanged $playbackState"); + val timeLeft = abs(position - duration); + + if (playbackState == ExoPlayer.STATE_ENDED) { + if (abs(position - duration) < 2000) { + onSourceEnded.emit(); + } + + _shouldRestartHideJobOnPlaybackStateChange = true; + } else { + setIsReplay(false); + + if (_shouldRestartHideJobOnPlaybackStateChange) { + gestureControl.restartHideJob(); + _shouldRestartHideJobOnPlaybackStateChange = false; + } + } + } + + fun setIsReplay(isReplay: Boolean) { + if (isReplay) { + _control_play.setImageResource(R.drawable.ic_replay); + _control_play_fullscreen.setImageResource(R.drawable.ic_replay); + } else { + _control_play.setImageResource(R.drawable.ic_play_white_nopad); + _control_play_fullscreen.setImageResource(R.drawable.ic_play_white_nopad); + } + } + + //Sizing + fun fitHeight(videoSize : VideoSize? = null){ + Logger.i(TAG, "Video Fit Height"); + if(_originalBottomMargin != 0) { + val layoutParams = _videoView.layoutParams as ConstraintLayout.LayoutParams; + layoutParams.setMargins(0, 0, 0, _originalBottomMargin); + _videoView.layoutParams = layoutParams; + } + + var h = videoSize?.height ?: lastVideoSource?.height ?: exoPlayer?.player?.videoSize?.height ?: 0; + var w = videoSize?.width ?: lastVideoSource?.width ?: exoPlayer?.player?.videoSize?.width ?: 0; + + if(h == 0 && w == 0) { + Logger.i(TAG, "UNKNOWN VIDEO FIT: (videoSize: ${videoSize != null}, player.videoSize: ${exoPlayer?.player?.videoSize != null})"); + w = 1280; + h = 720; + } + + + if(_lastSourceFit == null){ + val metrics = StateApp.instance.displayMetrics ?: resources.displayMetrics; + + val viewWidth = Math.min(metrics.widthPixels, metrics.heightPixels); //TODO: Get parent width. was this.width + val deviceHeight = Math.max(metrics.widthPixels, metrics.heightPixels); + val maxHeight = deviceHeight * 0.6; + + val determinedHeight = if(w > h) + ((h * (viewWidth.toDouble() / w)).toInt().toInt()) + else + ((h * (viewWidth.toDouble() / w)).toInt().toInt()); + _lastSourceFit = determinedHeight; + _lastSourceFit = Math.max(_lastSourceFit!!, 250); + _lastSourceFit = Math.min(_lastSourceFit!!, maxHeight.toInt()); + if((_lastSourceFit ?: 0) < 300 || (_lastSourceFit ?: 0) > viewWidth) { + Log.d(TAG, "WEIRD HEIGHT DETECTED: ${_lastSourceFit}, Width: ${w}, Height: ${h}, VWidth: ${viewWidth}"); + } + if(_lastSourceFit != determinedHeight) + _videoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT; + else + _videoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM; + } + + val marginBottom = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 7f, resources.displayMetrics).toInt(); + val rootParams = RelativeLayout.LayoutParams(LayoutParams.MATCH_PARENT, _lastSourceFit!! + marginBottom) + rootParams.bottomMargin = marginBottom; + _root.layoutParams = rootParams + isFitMode = true; + } + fun fillHeight(){ + Logger.i(TAG, "Video Fill Height"); + val width = resources.displayMetrics.heightPixels; + val height = resources.displayMetrics.widthPixels; + + val layoutParams = _videoView.layoutParams as ConstraintLayout.LayoutParams; + _originalBottomMargin = if(layoutParams.bottomMargin > 0) layoutParams.bottomMargin else _originalBottomMargin; + layoutParams.setMargins(0); + _videoView.layoutParams = layoutParams; + _videoView.invalidate(); + + val rootParams = RelativeLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); + _root.layoutParams = rootParams; + _root.invalidate(); + isFitMode = false; + } + + //Animated Calls + fun setEndPadding(value: Float) { + setPadding(0, 0, value.toInt(), 0) + } + + fun updateRotateLock() { + if(!Settings.instance.playback.isAutoRotate()) { + _control_rotate_lock.visibility = View.GONE; + _control_rotate_lock_fullscreen.visibility = View.GONE; + } + else { + _control_rotate_lock.visibility = View.VISIBLE; + _control_rotate_lock_fullscreen.visibility = View.VISIBLE; + } + if(StatePlayer.instance.rotationLock) { + _control_rotate_lock_fullscreen.setImageResource(R.drawable.ic_screen_rotation); + _control_rotate_lock.setImageResource(R.drawable.ic_screen_rotation); + } + else { + _control_rotate_lock_fullscreen.setImageResource(R.drawable.ic_screen_lock_rotation); + _control_rotate_lock.setImageResource(R.drawable.ic_screen_lock_rotation); + } + } + + fun setGestureSoundFactor(soundFactor: Float) { + gestureControl.setSoundFactor(soundFactor); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt new file mode 100644 index 00000000..aac8e853 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt @@ -0,0 +1,522 @@ +package com.futo.platformplayer.views.video + +import android.content.Context +import android.net.Uri +import android.util.AttributeSet +import android.widget.RelativeLayout +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor +import com.futo.platformplayer.helpers.VideoHelper +import com.futo.platformplayer.api.media.models.streams.sources.* +import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestAudioSource +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.video.PlayerManager +import com.google.android.exoplayer2.* +import com.google.android.exoplayer2.source.MediaSource +import com.google.android.exoplayer2.source.MergingMediaSource +import com.google.android.exoplayer2.source.ProgressiveMediaSource +import com.google.android.exoplayer2.source.SingleSampleMediaSource +import com.google.android.exoplayer2.source.dash.DashMediaSource +import com.google.android.exoplayer2.source.hls.HlsMediaSource +import com.google.android.exoplayer2.text.CueGroup +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector +import com.google.android.exoplayer2.upstream.DefaultDataSource +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource +import com.google.android.exoplayer2.video.VideoSize +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import kotlin.math.abs + +abstract class FutoVideoPlayerBase : RelativeLayout { + private val TAG = "FutoVideoPlayerBase" + + private val TEMP_DIRECTORY = StateApp.instance.getTempDirectory(); + + private var _mediaSource: MediaSource? = null; + + var lastVideoSource: IVideoSource? = null + private set; + var lastAudioSource: IAudioSource? = null + private set; + + private var _lastVideoMediaSource: MediaSource? = null; + private var _lastAudioMediaSource: MediaSource? = null; + private var _lastSubtitleMediaSource: MediaSource? = null; + private var _shouldPlaybackRestartOnConnectivity: Boolean = false; + private val _referenceObject = Object(); + + var exoPlayer: PlayerManager? = null + private set; + val exoPlayerStateName: String; + + val playing: Boolean get() = exoPlayer?.player?.playWhenReady ?: false; + val position: Long get() = exoPlayer?.player?.currentPosition ?: 0; + val duration: Long get() = exoPlayer?.player?.duration ?: 0; + + var isAudioMode: Boolean = false + private set; + + val onPlayChanged = Event1(); + val onStateChange = Event1(); + val onPositionDiscontinuity = Event1(); + val onDatasourceError = Event1(); + + private var _didCallSourceChange = false; + private var _lastState: Int = -1; + + private var _targetTrackVideoHeight = -1; + private var _targetTrackAudioBitrate = -1; + + private var _toResume = false; + + private val _playerEventListener = object: Player.Listener { + //TODO: Figure out why this is deprecated, and what the alternative is. + override fun onPlaybackStateChanged(playbackState: Int) { + super.onPlaybackStateChanged(playbackState) + this@FutoVideoPlayerBase.onPlaybackStateChanged(playbackState); + + if(_lastState != playbackState) { + _lastState = playbackState; + onStateChange.emit(playbackState); + } + when(playbackState) { + Player.STATE_READY -> { + if(!_didCallSourceChange) { + _didCallSourceChange = true; + onSourceChanged(lastVideoSource, lastAudioSource, _toResume); + } + } + } + } + + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + super.onPlayWhenReadyChanged(playWhenReady, reason) + onPlayChanged.emit(playWhenReady); + } + + override fun onVideoSizeChanged(videoSize: VideoSize) { + super.onVideoSizeChanged(videoSize) + this@FutoVideoPlayerBase.onVideoSizeChanged(videoSize); + } + + override fun onPositionDiscontinuity(oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int) { + super.onPositionDiscontinuity(oldPosition, newPosition, reason); + onPositionDiscontinuity.emit(newPosition.positionMs); + } + + override fun onCues(cueGroup: CueGroup) { + super.onCues(cueGroup) + Logger.i(TAG, "CUE GROUP: ${cueGroup.cues.firstOrNull()?.text}"); + } + + override fun onPlayerError(error: PlaybackException) { + super.onPlayerError(error); + this@FutoVideoPlayerBase.onPlayerError(error); + } + }; + + constructor(stateName: String, context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, defStyleRes: Int = 0) : super(context, attrs, defStyleAttr, defStyleRes) { + this.exoPlayerStateName = stateName; + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow(); + + Logger.i(TAG, "Attached onConnectionAvailable listener."); + StateApp.instance.onConnectionAvailable.subscribe(_referenceObject) { + Logger.i(TAG, "onConnectionAvailable"); + + val pos = position; + val dur = duration; + if (_shouldPlaybackRestartOnConnectivity && abs(pos - dur) > 2000) { + Logger.i(TAG, "Playback ended due to connection loss, resuming playback since connection is restored."); + exoPlayer?.player?.playWhenReady = true; + exoPlayer?.player?.prepare(); + exoPlayer?.player?.play(); + } + }; + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow(); + + Logger.i(TAG, "Detached onConnectionAvailable listener."); + StateApp.instance.onConnectionAvailable.remove(_referenceObject); + } + + fun switchToVideoMode() { + Logger.i(TAG, "Switching to Video Mode"); + isAudioMode = false; + loadSelectedSources(playing, true); + } + fun switchToAudioMode() { + Logger.i(TAG, "Switching to Audio Mode"); + isAudioMode = true; + loadSelectedSources(playing, true); + } + + fun seekTo(ms: Long) { + exoPlayer?.player?.seekTo(ms); + } + fun seekToEnd(ms: Long = 0) { + val duration = Math.max(exoPlayer?.player?.duration ?: 0, 0); + exoPlayer?.player?.seekTo(Math.max(duration - ms, 0)); + } + fun seekFromCurrent(ms: Long) { + val to = Math.max((exoPlayer?.player?.currentPosition ?: 0) + ms, 0); + exoPlayer?.player?.seekTo(Math.min(to, exoPlayer?.player?.duration ?: to)); + } + + fun changePlayer(newPlayer: PlayerManager?) { + exoPlayer?.modifyState(exoPlayerStateName, {state -> state.listener = null}); + newPlayer?.modifyState(exoPlayerStateName, {state -> state.listener = _playerEventListener}); + exoPlayer = newPlayer; + } + + //TODO: Temporary solution, Implement custom track selector without using constraints + fun selectVideoTrack(height: Int) { + _targetTrackVideoHeight = height; + updateTrackSelector(); + } + fun selectAudioTrack(bitrate: Int) { + _targetTrackAudioBitrate = bitrate; + updateTrackSelector(); + } + private fun updateTrackSelector() { + var builder = DefaultTrackSelector.Parameters.Builder(); + if(builder != null){ + if(_targetTrackVideoHeight > 0) + builder = builder + .setMinVideoSize(0, height - 10) + .setMaxVideoSize(9999, height + 10); + if(_targetTrackAudioBitrate > 0) + builder = builder + .setMaxAudioBitrate(_targetTrackAudioBitrate); + + if(exoPlayer?.player?.trackSelector != null) + exoPlayer!!.player.trackSelector!!.parameters = builder.build(); + } + } + + fun setSource(videoSource: IVideoSource?, audioSource: IAudioSource? = null, play: Boolean = false, keepSubtitles: Boolean = false) { + swapSources(videoSource, audioSource,false, play, keepSubtitles); + } + fun swapSources(videoSource: IVideoSource?, audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true, keepSubtitles: Boolean = false): Boolean { + swapSourceInternal(videoSource); + swapSourceInternal(audioSource); + if(!keepSubtitles) + _lastSubtitleMediaSource = null; + return loadSelectedSources(play, resume); + } + fun swapSource(videoSource: IVideoSource?, resume: Boolean = true, play: Boolean = true): Boolean { + swapSourceInternal(videoSource); + return loadSelectedSources(play, resume); + } + fun swapSource(audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true): Boolean { + swapSourceInternal(audioSource); + return loadSelectedSources(play, resume); + } + + fun swapSubtitles(scope: CoroutineScope, subtitles: ISubtitleSource?) { + if(subtitles == null) + clearSubtitles(); + else { + if(SUPPORTED_SUBTITLES.contains(subtitles.format?.lowercase())) { + if (!subtitles.hasFetch) { + _lastSubtitleMediaSource = SingleSampleMediaSource.Factory(DefaultDataSource.Factory(context, DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT))) + .createMediaSource(MediaItem.SubtitleConfiguration.Builder(Uri.parse(subtitles.url)) + .setMimeType(subtitles.format) + .setSelectionFlags(C.SELECTION_FLAG_DEFAULT) + .build(), + C.TIME_UNSET); + loadSelectedSources(true, true); + } else { + scope.launch(Dispatchers.IO) { + try { + val subUri = subtitles.getSubtitlesURI() ?: return@launch; + withContext(Dispatchers.Main) { + try { + _lastSubtitleMediaSource = SingleSampleMediaSource.Factory(DefaultDataSource.Factory(context, DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT))) + .createMediaSource(MediaItem.SubtitleConfiguration.Builder(subUri) + .setMimeType(subtitles.format) + .setSelectionFlags(C.SELECTION_FLAG_DEFAULT) + .build(), + C.TIME_UNSET); + loadSelectedSources(true, true); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to load selected sources after subtitle download.", e) + } + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to get subtitles URI.", e) + } + } + } + } + else + clearSubtitles(); + } + } + private fun clearSubtitles() { + _lastSubtitleMediaSource = null; + loadSelectedSources(true, true); + } + + + private fun swapSourceInternal(videoSource: IVideoSource?) { + when(videoSource) { + is LocalVideoSource -> swapVideoSourceLocal(videoSource); + is JSVideoUrlRangeSource -> swapVideoSourceUrlRange(videoSource); + is IDashManifestSource -> swapVideoSourceDash(videoSource); + is IHLSManifestSource -> swapVideoSourceHLS(videoSource); + is IVideoUrlSource -> swapVideoSourceUrl(videoSource); + null -> _lastVideoMediaSource = null; + else -> throw IllegalArgumentException("Unsupported video source [${videoSource.javaClass.simpleName}]"); + } + lastVideoSource = videoSource; + } + private fun swapSourceInternal(audioSource: IAudioSource?) { + when(audioSource) { + is LocalAudioSource -> swapAudioSourceLocal(audioSource); + is JSAudioUrlRangeSource -> swapAudioSourceUrlRange(audioSource); + is JSHLSManifestAudioSource -> swapAudioSourceHLS(audioSource); + is IAudioUrlSource -> swapAudioSourceUrl(audioSource); + null -> _lastAudioMediaSource = null; + else -> throw IllegalArgumentException("Unsupported video source [${audioSource.javaClass.simpleName}]"); + } + lastAudioSource = audioSource; + } + + //Video loads + private fun swapVideoSourceLocal(videoSource: LocalVideoSource) { + Logger.i(TAG, "Loading VideoSource [Local]"); + val file = File(videoSource.filePath); + if(!file.exists()) + throw IllegalArgumentException("File for this video does not exist"); + _lastVideoMediaSource = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context)) + .createMediaSource(MediaItem.fromUri(Uri.fromFile(file))); + } + private fun swapVideoSourceUrlRange(videoSource: JSVideoUrlRangeSource) { + Logger.i(TAG, "Loading JSVideoUrlRangeSource"); + if(videoSource.hasItag) { + //Temporary workaround for Youtube + try { + _lastVideoMediaSource = VideoHelper.convertItagSourceToChunkedDashSource(videoSource); + if(_lastVideoMediaSource == null) + throw java.lang.IllegalStateException("Dash manifest workaround failed"); + return; + } + //If it fails to create the dash workaround, fallback to standard progressive + catch(ex: Exception) { + Logger.i(TAG, "Dash manifest workaround failed for video, falling back to progressive due to ${ex.message}"); + _lastVideoMediaSource = ProgressiveMediaSource.Factory(videoSource.getHttpDataSourceFactory()) + .createMediaSource(MediaItem.fromUri(videoSource.getVideoUrl())); + return; + } + } + else throw IllegalArgumentException("source without itag data..."); + } + private fun swapVideoSourceUrl(videoSource: IVideoUrlSource) { + Logger.i(TAG, "Loading VideoSource [Url]"); + _lastVideoMediaSource = ProgressiveMediaSource.Factory(DefaultHttpDataSource.Factory() + .setUserAgent(DEFAULT_USER_AGENT)) + .createMediaSource(MediaItem.fromUri(videoSource.getVideoUrl())); + } + private fun swapVideoSourceDash(videoSource: IDashManifestSource) { + Logger.i(TAG, "Loading VideoSource [Dash]"); + _lastVideoMediaSource = if (videoSource != null) DashMediaSource.Factory(DefaultHttpDataSource.Factory() + .setUserAgent(DEFAULT_USER_AGENT)) + .createMediaSource(MediaItem.fromUri(videoSource.url)); + else null; + } + private fun swapVideoSourceHLS(videoSource: IHLSManifestSource) { + Logger.i(TAG, "Loading VideoSource [HLS]"); + _lastVideoMediaSource = HlsMediaSource.Factory(DefaultHttpDataSource.Factory() + .setUserAgent(DEFAULT_USER_AGENT)) + .createMediaSource(MediaItem.fromUri(videoSource.url)); + } + + //Audio loads + private fun swapAudioSourceLocal(audioSource: LocalAudioSource) { + Logger.i(TAG, "Loading AudioSource [Local]"); + val file = File(audioSource.filePath); + if(!file.exists()) + throw IllegalArgumentException("File for this audio does not exist"); + _lastAudioMediaSource = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context)) + .createMediaSource(MediaItem.fromUri(Uri.fromFile(file))); + } + private fun swapAudioSourceUrlRange(audioSource: JSAudioUrlRangeSource) { + Logger.i(TAG, "Loading JSAudioUrlRangeSource"); + if(audioSource.hasItag) { + try { + _lastAudioMediaSource = VideoHelper.convertItagSourceToChunkedDashSource(audioSource); + if(_lastAudioMediaSource == null) + throw java.lang.IllegalStateException("Missing required parameters for dash workaround?"); + return; + } + //If it fails to create the dash workaround, fallback to standard progressive + catch(ex: Exception) { + Logger.i(TAG, "Dash manifest workaround failed for audio, falling back to progressive due to ${ex.message}"); + _lastAudioMediaSource = ProgressiveMediaSource.Factory(audioSource.getHttpDataSourceFactory()) + .createMediaSource(MediaItem.fromUri((audioSource as IAudioUrlSource).getAudioUrl())); + return; + } + } + else throw IllegalArgumentException("source without itag data...") + } + private fun swapAudioSourceUrl(audioSource: IAudioUrlSource) { + Logger.i(TAG, "Loading AudioSource [Url]"); + _lastAudioMediaSource = ProgressiveMediaSource.Factory(DefaultHttpDataSource.Factory() + .setUserAgent(DEFAULT_USER_AGENT)) + .createMediaSource(MediaItem.fromUri(audioSource.getAudioUrl())); + } + private fun swapAudioSourceHLS(audioSource: IHLSManifestAudioSource) { + Logger.i(TAG, "Loading AudioSource [HLS]"); + _lastAudioMediaSource = HlsMediaSource.Factory(DefaultHttpDataSource.Factory() + .setUserAgent(DEFAULT_USER_AGENT)) + .createMediaSource(MediaItem.fromUri(audioSource.url)); + } + + + //Prefered source selection + fun getPreferredVideoSource(video: IPlatformVideoDetails, targetPixels: Int = -1): IVideoSource? { + val usePreview = false; + if(usePreview) { + if(video.preview != null && video.preview is VideoMuxedSourceDescriptor) + return (video.preview as VideoMuxedSourceDescriptor).videoSources.last(); + return null; + } + else if(video.live != null) + return video.live; + else if(video.dash != null) + return video.dash; + else if(video.hls != null) + return video.hls; + else + return VideoHelper.selectBestVideoSource(video.video, targetPixels, PREFERED_VIDEO_CONTAINERS) + } + fun getPreferredAudioSource(video: IPlatformVideoDetails, preferredLanguage: String?): IAudioSource? { + return VideoHelper.selectBestAudioSource(video.video, PREFERED_AUDIO_CONTAINERS, preferredLanguage); + } + + private fun loadSelectedSources(play: Boolean, resume: Boolean): Boolean { + val sourceVideo = if(!isAudioMode || _lastAudioMediaSource == null) _lastVideoMediaSource else null; + val sourceAudio = _lastAudioMediaSource; + val sourceSubs = _lastSubtitleMediaSource; + + val sources = listOf(sourceVideo, sourceAudio, sourceSubs).filter { it != null }.map { it!! }.toTypedArray() + + beforeSourceChanged(); + + _mediaSource = if(sources.size == 1) { + Logger.i(TAG, "Using single source mode") + (sourceVideo ?: sourceAudio); + } + else if(sources.size > 1) { + Logger.i(TAG, "Using multi source mode ${sources.size}") + MergingMediaSource(true, *sources); + } + else { + Logger.i(TAG, "Using no sources loaded"); + stop(); + return false; + } + + reloadMediaSource(play, resume); + return true; + } + + private fun reloadMediaSource(play: Boolean = false, resume: Boolean = true) { + val player = exoPlayer + if (player == null) + return; + + val positionBefore = player.player.currentPosition; + if(_mediaSource != null) { + player.player.setMediaSource(_mediaSource!!); + _toResume = resume; + _didCallSourceChange = false; + player.player.prepare() + player.player.playWhenReady = play; + if(resume) + seekTo(positionBefore); + else + seekTo(0); + this.onSourceChanged(lastVideoSource, lastAudioSource, resume); + } + else + player.player?.stop(); + } + + fun clear() { + exoPlayer?.player?.stop(); + exoPlayer?.player?.clearMediaItems(); + } + + fun stop(){ + exoPlayer?.player?.stop(); + } + fun pause(){ + exoPlayer?.player?.pause(); + } + open fun play(){ + exoPlayer?.player?.play(); + } + + fun setVolume(volume: Float) { + exoPlayer?.setVolume(volume); + } + + protected open fun onPlayerError(error: PlaybackException) { + when (error.errorCode) { + PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> { + onDatasourceError.emit(error); + } + PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED, + PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, + PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT, + PlaybackException.ERROR_CODE_IO_NO_PERMISSION, + PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED -> { + Logger.i(TAG, "IO error, set _shouldPlaybackRestartOnConnectivity=true"); + _shouldPlaybackRestartOnConnectivity = true; + } + } + } + + protected open fun onVideoSizeChanged(videoSize: VideoSize) { + + } + protected open fun beforeSourceChanged() { + + } + protected open fun onSourceChanged(videoSource: IVideoSource?, audioSource: IAudioSource? = null, resume: Boolean = true) { } + + protected open fun onPlaybackStateChanged(playbackState: Int) { + if (_shouldPlaybackRestartOnConnectivity && playbackState == ExoPlayer.STATE_READY) { + Logger.i(TAG, "_shouldPlaybackRestartOnConnectivity=false"); + _shouldPlaybackRestartOnConnectivity = false; + } + + + } + + companion object { + val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0"; + + val PREFERED_VIDEO_CONTAINERS = arrayOf("video/mp4", "video/webm", "video/3gpp"); + val PREFERED_AUDIO_CONTAINERS = arrayOf("audio/mp3", "audio/mp4", "audio/webm", "audio/opus"); + + val SUPPORTED_SUBTITLES = hashSetOf("text/vtt", "application/x-subrip"); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java b/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java new file mode 100644 index 00000000..00a49cf8 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java @@ -0,0 +1,854 @@ +package com.futo.platformplayer.views.video.datasources; + +import static com.google.android.exoplayer2.upstream.HttpUtil.buildRangeRequestHeader; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Util.castNonNull; +import static java.lang.Math.min; + +import android.net.Uri; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import com.futo.platformplayer.api.media.platforms.js.models.JSRequestModifier; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.PlaybackException; +import com.google.android.exoplayer2.upstream.BaseDataSource; +import com.google.android.exoplayer2.upstream.DataSourceException; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.DataSpec.HttpMethod; +import com.google.android.exoplayer2.upstream.HttpDataSource; +import com.google.android.exoplayer2.upstream.HttpUtil; +import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.util.Log; +import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Predicate; +import com.google.common.collect.ForwardingMap; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Sets; +import com.google.common.net.HttpHeaders; +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.io.OutputStream; +import java.lang.reflect.Method; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.NoRouteToHostException; +import java.net.URL; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.zip.GZIPInputStream; + +/* + * Based on the default ExoPlayer DefaultHttpDataSource + */ + +public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { + public static final class Factory implements HttpDataSource.Factory { + + private final RequestProperties defaultRequestProperties; + + @Nullable private TransferListener transferListener; + @Nullable private Predicate contentTypePredicate; + @Nullable private String userAgent; + private int connectTimeoutMs; + private int readTimeoutMs; + private boolean allowCrossProtocolRedirects; + private boolean keepPostFor302Redirects; + @Nullable private JSRequestModifier requestModifier = null; + + /** Creates an instance. */ + public Factory() { + defaultRequestProperties = new RequestProperties(); + connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MILLIS; + readTimeoutMs = DEFAULT_READ_TIMEOUT_MILLIS; + } + + @Override + public Factory setDefaultRequestProperties(Map defaultRequestProperties) { + this.defaultRequestProperties.clearAndSet(defaultRequestProperties); + return this; + } + + /** + * Sets the request modifier that will be used. + * + *

The default is {@code null}, which results in no request modification + * + * @param requestModifier The request modifier that will be used, or {@code null} to use no request modifier + * @return This factory. + */ + public Factory setRequestModifier(@Nullable JSRequestModifier requestModifier) { + this.requestModifier = requestModifier; + return this; + } + + /** + * Sets the user agent that will be used. + * + *

The default is {@code null}, which causes the default user agent of the underlying + * platform to be used. + * + * @param userAgent The user agent that will be used, or {@code null} to use the default user + * agent of the underlying platform. + * @return This factory. + */ + public Factory setUserAgent(@Nullable String userAgent) { + this.userAgent = userAgent; + return this; + } + + /** + * Sets the connect timeout, in milliseconds. + * + *

The default is {@link JSHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS}. + * + * @param connectTimeoutMs The connect timeout, in milliseconds, that will be used. + * @return This factory. + */ + public Factory setConnectTimeoutMs(int connectTimeoutMs) { + this.connectTimeoutMs = connectTimeoutMs; + return this; + } + + /** + * Sets the read timeout, in milliseconds. + * + *

The default is {@link JSHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS}. + * + * @param readTimeoutMs The connect timeout, in milliseconds, that will be used. + * @return This factory. + */ + public Factory setReadTimeoutMs(int readTimeoutMs) { + this.readTimeoutMs = readTimeoutMs; + return this; + } + + /** + * Sets whether to allow cross protocol redirects. + * + *

The default is {@code false}. + * + * @param allowCrossProtocolRedirects Whether to allow cross protocol redirects. + * @return This factory. + */ + public Factory setAllowCrossProtocolRedirects(boolean allowCrossProtocolRedirects) { + this.allowCrossProtocolRedirects = allowCrossProtocolRedirects; + return this; + } + + /** + * Sets a content type {@link Predicate}. If a content type is rejected by the predicate then a + * {@link HttpDataSource.InvalidContentTypeException} is thrown from {@link + * JSHttpDataSource#open(com.google.android.exoplayer2.upstream.DataSpec)}. + * + *

The default is {@code null}. + * + * @param contentTypePredicate The content type {@link Predicate}, or {@code null} to clear a + * predicate that was previously set. + * @return This factory. + */ + public Factory setContentTypePredicate(@Nullable Predicate contentTypePredicate) { + this.contentTypePredicate = contentTypePredicate; + return this; + } + + /** + * Sets the {@link TransferListener} that will be used. + * + *

The default is {@code null}. + * + *

See {@link com.google.android.exoplayer2.upstream.DataSource#addTransferListener(TransferListener)}. + * + * @param transferListener The listener that will be used. + * @return This factory. + */ + public Factory setTransferListener(@Nullable TransferListener transferListener) { + this.transferListener = transferListener; + return this; + } + + /** + * Sets whether we should keep the POST method and body when we have HTTP 302 redirects for a + * POST request. + */ + public Factory setKeepPostFor302Redirects(boolean keepPostFor302Redirects) { + this.keepPostFor302Redirects = keepPostFor302Redirects; + return this; + } + + @Override + public JSHttpDataSource createDataSource() { + JSHttpDataSource dataSource = + new JSHttpDataSource( + userAgent, + connectTimeoutMs, + readTimeoutMs, + allowCrossProtocolRedirects, + defaultRequestProperties, + contentTypePredicate, + keepPostFor302Redirects, + requestModifier); + if (transferListener != null) { + dataSource.addTransferListener(transferListener); + } + return dataSource; + } + } + + /** The default connection timeout, in milliseconds. */ + public static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = 8 * 1000; + /** The default read timeout, in milliseconds. */ + public static final int DEFAULT_READ_TIMEOUT_MILLIS = 8 * 1000; + + private static final String TAG = "JSHttpDataSource"; + private static final int MAX_REDIRECTS = 20; // Same limit as okhttp. + private static final int HTTP_STATUS_TEMPORARY_REDIRECT = 307; + private static final int HTTP_STATUS_PERMANENT_REDIRECT = 308; + private static final long MAX_BYTES_TO_DRAIN = 2048; + + private final boolean allowCrossProtocolRedirects; + private final int connectTimeoutMillis; + private final int readTimeoutMillis; + @Nullable private final String userAgent; + @Nullable private final RequestProperties defaultRequestProperties; + private final RequestProperties requestProperties; + private final boolean keepPostFor302Redirects; + + @Nullable private Predicate contentTypePredicate; + @Nullable private DataSpec dataSpec; + @Nullable private HttpURLConnection connection; + @Nullable private InputStream inputStream; + private boolean opened; + private int responseCode; + private long bytesToRead; + private long bytesRead; + @Nullable private JSRequestModifier requestModifier; + + private JSHttpDataSource( + @Nullable String userAgent, + int connectTimeoutMillis, + int readTimeoutMillis, + boolean allowCrossProtocolRedirects, + @Nullable RequestProperties defaultRequestProperties, + @Nullable Predicate contentTypePredicate, + boolean keepPostFor302Redirects, + @Nullable JSRequestModifier requestModifier) { + super(/* isNetwork= */ true); + this.userAgent = userAgent; + this.connectTimeoutMillis = connectTimeoutMillis; + this.readTimeoutMillis = readTimeoutMillis; + this.allowCrossProtocolRedirects = allowCrossProtocolRedirects; + this.defaultRequestProperties = defaultRequestProperties; + this.contentTypePredicate = contentTypePredicate; + this.requestProperties = new RequestProperties(); + this.keepPostFor302Redirects = keepPostFor302Redirects; + this.requestModifier = requestModifier; + } + + @Override + @Nullable + public Uri getUri() { + return connection == null ? null : Uri.parse(connection.getURL().toString()); + } + + @Override + public int getResponseCode() { + return connection == null || responseCode <= 0 ? -1 : responseCode; + } + + @Override + public Map> getResponseHeaders() { + if (connection == null) { + return ImmutableMap.of(); + } + // connection.getHeaderFields() always contains a null key with a value like + // ["HTTP/1.1 200 OK"]. The response code is available from HttpURLConnection#getResponseCode() + // and the HTTP version is fixed when establishing the connection. + // DataSource#getResponseHeaders() doesn't allow null keys in the returned map, so we need to + // remove it. + // connection.getHeaderFields() returns a special unmodifiable case-insensitive Map + // so we can't just remove the null key or make a copy without the null key. Instead we wrap it + // in a ForwardingMap subclass that ignores and filters out null keys in the read methods. + return new NullFilteringHeadersMap(connection.getHeaderFields()); + } + + @Override + public void setRequestProperty(String name, String value) { + checkNotNull(name); + checkNotNull(value); + requestProperties.set(name, value); + } + + @Override + public void clearRequestProperty(String name) { + checkNotNull(name); + requestProperties.remove(name); + } + + @Override + public void clearAllRequestProperties() { + requestProperties.clear(); + } + + /** Opens the source to read the specified data. */ + @Override + public long open(DataSpec dataSpec) throws HttpDataSourceException { + this.dataSpec = dataSpec; + bytesRead = 0; + bytesToRead = 0; + transferInitializing(dataSpec); + + String responseMessage; + HttpURLConnection connection; + try { + this.connection = makeConnection(dataSpec); + connection = this.connection; + responseCode = connection.getResponseCode(); + responseMessage = connection.getResponseMessage(); + } catch (IOException e) { + closeConnectionQuietly(); + throw HttpDataSourceException.createForIOException( + e, dataSpec, HttpDataSourceException.TYPE_OPEN); + } + + // Check for a valid response code. + if (responseCode < 200 || responseCode > 299) { + Map> headers = connection.getHeaderFields(); + if (responseCode == 416) { + long documentSize = HttpUtil.getDocumentSize(connection.getHeaderField(HttpHeaders.CONTENT_RANGE)); + if (dataSpec.position == documentSize) { + opened = true; + transferStarted(dataSpec); + return dataSpec.length != C.LENGTH_UNSET ? dataSpec.length : 0; + } + } + + @Nullable InputStream errorStream = connection.getErrorStream(); + byte[] errorResponseBody; + try { + errorResponseBody = + errorStream != null ? Util.toByteArray(errorStream) : Util.EMPTY_BYTE_ARRAY; + } catch (IOException e) { + errorResponseBody = Util.EMPTY_BYTE_ARRAY; + } + closeConnectionQuietly(); + @Nullable + IOException cause = responseCode == 416 + ? new DataSourceException(PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE) + : null; + + throw new InvalidResponseCodeException( + responseCode, responseMessage, cause, headers, dataSpec, errorResponseBody); + } + + // Check for a valid content type. + String contentType = connection.getContentType(); + if (contentTypePredicate != null && !contentTypePredicate.apply(contentType)) { + closeConnectionQuietly(); + throw new InvalidContentTypeException(contentType, dataSpec); + } + + // If we requested a range starting from a non-zero position and received a 200 rather than a + // 206, then the server does not support partial requests. We'll need to manually skip to the + // requested position. + long bytesToSkip; + if (requestModifier != null && !requestModifier.getAllowByteSkip()) { + bytesToSkip = 0; + } else { + bytesToSkip = responseCode == 200 && dataSpec.position != 0 ? dataSpec.position : 0; + } + + // Determine the length of the data to be read, after skipping. + boolean isCompressed = isCompressed(connection); + if (!isCompressed) { + if (dataSpec.length != C.LENGTH_UNSET) { + bytesToRead = dataSpec.length; + } else { + long contentLength = + HttpUtil.getContentLength( + connection.getHeaderField(HttpHeaders.CONTENT_LENGTH), + connection.getHeaderField(HttpHeaders.CONTENT_RANGE)); + bytesToRead = + contentLength != C.LENGTH_UNSET ? (contentLength - bytesToSkip) : C.LENGTH_UNSET; + } + } else { + // Gzip is enabled. If the server opts to use gzip then the content length in the response + // will be that of the compressed data, which isn't what we want. Always use the dataSpec + // length in this case. + bytesToRead = dataSpec.length; + } + + try { + inputStream = connection.getInputStream(); + if (isCompressed) { + inputStream = new GZIPInputStream(inputStream); + } + } catch (IOException e) { + closeConnectionQuietly(); + throw new HttpDataSourceException( + e, + dataSpec, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_OPEN); + } + + opened = true; + transferStarted(dataSpec); + + try { + skipFully(bytesToSkip, dataSpec); + } catch (IOException e) { + closeConnectionQuietly(); + + if (e instanceof HttpDataSourceException) { + throw (HttpDataSourceException) e; + } + throw new HttpDataSourceException( + e, + dataSpec, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_OPEN); + } + + return bytesToRead; + } + + @Override + public int read(byte[] buffer, int offset, int length) throws HttpDataSourceException { + try { + return readInternal(buffer, offset, length); + } catch (IOException e) { + throw HttpDataSourceException.createForIOException( + e, castNonNull(dataSpec), HttpDataSourceException.TYPE_READ); + } + } + + @Override + public void close() throws HttpDataSourceException { + try { + @Nullable InputStream inputStream = this.inputStream; + if (inputStream != null) { + long bytesRemaining = + bytesToRead == C.LENGTH_UNSET ? C.LENGTH_UNSET : bytesToRead - bytesRead; + maybeTerminateInputStream(connection, bytesRemaining); + try { + inputStream.close(); + } catch (IOException e) { + throw new HttpDataSourceException( + e, + castNonNull(dataSpec), + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_CLOSE); + } + } + } finally { + inputStream = null; + closeConnectionQuietly(); + if (opened) { + opened = false; + transferEnded(); + } + } + } + + /** Establishes a connection, following redirects to do so where permitted. */ + private HttpURLConnection makeConnection(DataSpec dataSpec) throws IOException { + URL url = new URL(dataSpec.uri.toString()); + @HttpMethod int httpMethod = dataSpec.httpMethod; + @Nullable byte[] httpBody = dataSpec.httpBody; + long position = dataSpec.position; + long length = dataSpec.length; + boolean allowGzip = dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_GZIP); + + if (!allowCrossProtocolRedirects && !keepPostFor302Redirects) { + // HttpURLConnection disallows cross-protocol redirects, but otherwise performs redirection + // automatically. This is the behavior we want, so use it. + return makeConnection( + url, + httpMethod, + httpBody, + position, + length, + allowGzip, + /* followRedirects= */ true, + dataSpec.httpRequestHeaders); + } + + // We need to handle redirects ourselves to allow cross-protocol redirects or to keep the POST + // request method for 302. + int redirectCount = 0; + while (redirectCount++ <= MAX_REDIRECTS) { + HttpURLConnection connection = + makeConnection( + url, + httpMethod, + httpBody, + position, + length, + allowGzip, + /* followRedirects= */ false, + dataSpec.httpRequestHeaders); + int responseCode = connection.getResponseCode(); + String location = connection.getHeaderField("Location"); + if ((httpMethod == DataSpec.HTTP_METHOD_GET || httpMethod == DataSpec.HTTP_METHOD_HEAD) + && (responseCode == HttpURLConnection.HTTP_MULT_CHOICE + || responseCode == HttpURLConnection.HTTP_MOVED_PERM + || responseCode == HttpURLConnection.HTTP_MOVED_TEMP + || responseCode == HttpURLConnection.HTTP_SEE_OTHER + || responseCode == HTTP_STATUS_TEMPORARY_REDIRECT + || responseCode == HTTP_STATUS_PERMANENT_REDIRECT)) { + connection.disconnect(); + url = handleRedirect(url, location, dataSpec); + } else if (httpMethod == DataSpec.HTTP_METHOD_POST + && (responseCode == HttpURLConnection.HTTP_MULT_CHOICE + || responseCode == HttpURLConnection.HTTP_MOVED_PERM + || responseCode == HttpURLConnection.HTTP_MOVED_TEMP + || responseCode == HttpURLConnection.HTTP_SEE_OTHER)) { + connection.disconnect(); + boolean shouldKeepPost = + keepPostFor302Redirects && responseCode == HttpURLConnection.HTTP_MOVED_TEMP; + if (!shouldKeepPost) { + // POST request follows the redirect and is transformed into a GET request. + httpMethod = DataSpec.HTTP_METHOD_GET; + httpBody = null; + } + url = handleRedirect(url, location, dataSpec); + } else { + return connection; + } + } + + // If we get here we've been redirected more times than are permitted. + throw new HttpDataSourceException( + new NoRouteToHostException("Too many redirects: " + redirectCount), + dataSpec, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + HttpDataSourceException.TYPE_OPEN); + } + + /** + * Configures a connection and opens it. + * + * @param url The url to connect to. + * @param httpMethod The http method. + * @param httpBody The body data, or {@code null} if not required. + * @param position The byte offset of the requested data. + * @param length The length of the requested data, or {@link C#LENGTH_UNSET}. + * @param allowGzip Whether to allow the use of gzip. + * @param followRedirects Whether to follow redirects. + * @param requestParameters parameters (HTTP headers) to include in request. + */ + private HttpURLConnection makeConnection( + URL url, + @HttpMethod int httpMethod, + @Nullable byte[] httpBody, + long position, + long length, + boolean allowGzip, + boolean followRedirects, + Map requestParameters) + throws IOException { + Map requestHeaders = new HashMap<>(); + if (defaultRequestProperties != null) { + requestHeaders.putAll(defaultRequestProperties.getSnapshot()); + } + requestHeaders.putAll(requestProperties.getSnapshot()); + requestHeaders.putAll(requestParameters); + + @Nullable String rangeHeader = buildRangeRequestHeader(position, length); + if (rangeHeader != null) { + requestHeaders.put(HttpHeaders.RANGE, rangeHeader); + } + + if (userAgent != null) { + requestHeaders.put(HttpHeaders.USER_AGENT, userAgent); + } + + requestHeaders.put(HttpHeaders.ACCEPT_ENCODING, allowGzip ? "gzip" : "identity"); + + String requestUrl = url.toString(); + if (requestModifier != null) { + JSRequestModifier.IRequest result = requestModifier.modifyRequest(requestUrl, requestHeaders); + requestUrl = result.getUrl(); + requestHeaders = result.getHeaders(); + } + + HttpURLConnection connection = openConnection(new URL(requestUrl)); + connection.setConnectTimeout(connectTimeoutMillis); + connection.setReadTimeout(readTimeoutMillis); + + for (Map.Entry property : requestHeaders.entrySet()) { + connection.setRequestProperty(property.getKey(), property.getValue()); + } + + connection.setInstanceFollowRedirects(followRedirects); + connection.setDoOutput(httpBody != null); + connection.setRequestMethod(DataSpec.getStringForHttpMethod(httpMethod)); + + if (httpBody != null) { + connection.setFixedLengthStreamingMode(httpBody.length); + connection.connect(); + OutputStream os = connection.getOutputStream(); + os.write(httpBody); + os.close(); + } else { + connection.connect(); + } + return connection; + } + + /** Creates an {@link HttpURLConnection} that is connected with the {@code url}. */ + @VisibleForTesting + /* package */ HttpURLConnection openConnection(URL url) throws IOException { + return (HttpURLConnection) url.openConnection(); + } + + /** + * Handles a redirect. + * + * @param originalUrl The original URL. + * @param location The Location header in the response. May be {@code null}. + * @param dataSpec The {@link DataSpec}. + * @return The next URL. + * @throws HttpDataSourceException If redirection isn't possible. + */ + private URL handleRedirect(URL originalUrl, @Nullable String location, DataSpec dataSpec) + throws HttpDataSourceException { + if (location == null) { + throw new HttpDataSourceException( + "Null location redirect", + dataSpec, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + HttpDataSourceException.TYPE_OPEN); + } + // Form the new url. + URL url; + try { + url = new URL(originalUrl, location); + } catch (MalformedURLException e) { + throw new HttpDataSourceException( + e, + dataSpec, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + HttpDataSourceException.TYPE_OPEN); + } + + // Check that the protocol of the new url is supported. + String protocol = url.getProtocol(); + if (!"https".equals(protocol) && !"http".equals(protocol)) { + throw new HttpDataSourceException( + "Unsupported protocol redirect: " + protocol, + dataSpec, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + HttpDataSourceException.TYPE_OPEN); + } + if (!allowCrossProtocolRedirects && !protocol.equals(originalUrl.getProtocol())) { + throw new HttpDataSourceException( + "Disallowed cross-protocol redirect (" + + originalUrl.getProtocol() + + " to " + + protocol + + ")", + dataSpec, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + HttpDataSourceException.TYPE_OPEN); + } + return url; + } + + /** + * Attempts to skip the specified number of bytes in full. + * + * @param bytesToSkip The number of bytes to skip. + * @param dataSpec The {@link DataSpec}. + * @throws IOException If the thread is interrupted during the operation, or if the data ended + * before skipping the specified number of bytes. + */ + private void skipFully(long bytesToSkip, DataSpec dataSpec) throws IOException { + if (bytesToSkip == 0) { + return; + } + byte[] skipBuffer = new byte[4096]; + while (bytesToSkip > 0) { + int readLength = (int) min(bytesToSkip, skipBuffer.length); + int read = castNonNull(inputStream).read(skipBuffer, 0, readLength); + if (Thread.currentThread().isInterrupted()) { + throw new HttpDataSourceException( + new InterruptedIOException(), + dataSpec, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_OPEN); + } + if (read == -1) { + throw new HttpDataSourceException( + dataSpec, + PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, + HttpDataSourceException.TYPE_OPEN); + } + bytesToSkip -= read; + bytesTransferred(read); + } + } + + /** + * Reads up to {@code length} bytes of data and stores them into {@code buffer}, starting at index + * {@code offset}. + * + *

This method blocks until at least one byte of data can be read, the end of the opened range + * is detected, or an exception is thrown. + * + * @param buffer The buffer into which the read data should be stored. + * @param offset The start offset into {@code buffer} at which data should be written. + * @param readLength The maximum number of bytes to read. + * @return The number of bytes read, or {@link C#RESULT_END_OF_INPUT} if the end of the opened + * range is reached. + * @throws IOException If an error occurs reading from the source. + */ + private int readInternal(byte[] buffer, int offset, int readLength) throws IOException { + if (readLength == 0) { + return 0; + } + if (bytesToRead != C.LENGTH_UNSET) { + long bytesRemaining = bytesToRead - bytesRead; + if (bytesRemaining == 0) { + return C.RESULT_END_OF_INPUT; + } + readLength = (int) min(readLength, bytesRemaining); + } + + int read = castNonNull(inputStream).read(buffer, offset, readLength); + if (read == -1) { + return C.RESULT_END_OF_INPUT; + } + + bytesRead += read; + bytesTransferred(read); + return read; + } + + /** + * On platform API levels 19 and 20, okhttp's implementation of {@link InputStream#close} can + * block for a long time if the stream has a lot of data remaining. Call this method before + * closing the input stream to make a best effort to cause the input stream to encounter an + * unexpected end of input, working around this issue. On other platform API levels, the method + * does nothing. + * + * @param connection The connection whose {@link InputStream} should be terminated. + * @param bytesRemaining The number of bytes remaining to be read from the input stream if its + * length is known. {@link C#LENGTH_UNSET} otherwise. + */ + private static void maybeTerminateInputStream( + @Nullable HttpURLConnection connection, long bytesRemaining) { + if (connection == null || Util.SDK_INT < 19 || Util.SDK_INT > 20) { + return; + } + + try { + InputStream inputStream = connection.getInputStream(); + if (bytesRemaining == C.LENGTH_UNSET) { + // If the input stream has already ended, do nothing. The socket may be re-used. + if (inputStream.read() == -1) { + return; + } + } else if (bytesRemaining <= MAX_BYTES_TO_DRAIN) { + // There isn't much data left. Prefer to allow it to drain, which may allow the socket to be + // re-used. + return; + } + String className = inputStream.getClass().getName(); + if ("com.android.okhttp.internal.http.HttpTransport$ChunkedInputStream".equals(className) + || "com.android.okhttp.internal.http.HttpTransport$FixedLengthInputStream" + .equals(className)) { + Class superclass = inputStream.getClass().getSuperclass(); + Method unexpectedEndOfInput = + checkNotNull(superclass).getDeclaredMethod("unexpectedEndOfInput"); + unexpectedEndOfInput.setAccessible(true); + unexpectedEndOfInput.invoke(inputStream); + } + } catch (Exception e) { + // If an IOException then the connection didn't ever have an input stream, or it was closed + // already. If another type of exception then something went wrong, most likely the device + // isn't using okhttp. + } + } + + /** Closes the current connection quietly, if there is one. */ + private void closeConnectionQuietly() { + if (connection != null) { + try { + connection.disconnect(); + } catch (Exception e) { + Log.e(TAG, "Unexpected error while disconnecting", e); + } + connection = null; + } + } + + private static boolean isCompressed(HttpURLConnection connection) { + String contentEncoding = connection.getHeaderField("Content-Encoding"); + return "gzip".equalsIgnoreCase(contentEncoding); + } + + private static class NullFilteringHeadersMap extends ForwardingMap> { + + private final Map> headers; + + public NullFilteringHeadersMap(Map> headers) { + this.headers = headers; + } + + @Override + protected Map> delegate() { + return headers; + } + + @Override + public boolean containsKey(@Nullable Object key) { + return key != null && super.containsKey(key); + } + + @Nullable + @Override + public List get(@Nullable Object key) { + return key == null ? null : super.get(key); + } + + @Override + public Set keySet() { + return Sets.filter(super.keySet(), key -> key != null); + } + + @Override + public Set>> entrySet() { + return Sets.filter(super.entrySet(), entry -> entry.getKey() != null); + } + + @Override + public int size() { + return super.size() - (super.containsKey(null) ? 1 : 0); + } + + @Override + public boolean isEmpty() { + return super.isEmpty() || (super.size() == 1 && super.containsKey(null)); + } + + @Override + public boolean containsValue(@Nullable Object value) { + return super.standardContainsValue(value); + } + + @Override + public boolean equals(@Nullable Object object) { + return object != null && super.standardEquals(object); + } + + @Override + public int hashCode() { + return super.standardHashCode(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/videometa/UpNextView.kt b/app/src/main/java/com/futo/platformplayer/views/videometa/UpNextView.kt new file mode 100644 index 00000000..f217f145 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/videometa/UpNextView.kt @@ -0,0 +1,184 @@ +package com.futo.platformplayer.views.videometa + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import com.bumptech.glide.Glide +import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.toHumanNumber + +class UpNextView : LinearLayout { + private val _layoutContainer: LinearLayout; + private val _textType: TextView; + private val _textTitle: TextView; + private val _imageThumbnail: ImageView; + private val _textMetadata: TextView; + private val _imageChannelThumbnail: ImageView; + private val _textChannelName: TextView; + private val _textPosition: TextView; + private val _textUpNext: TextView; + private val _buttonClear: LinearLayout; + private val _buttonShuffle: LinearLayout; + private val _buttonRepeat: LinearLayout; + private val _buttonView: LinearLayout; + private val _layoutRepeatDivider: FrameLayout; + private val _layoutQueueBox: ConstraintLayout; + private val _layoutEndOfPlaylist: ConstraintLayout; + private val _textEndOfQueue: TextView; + private val _buttonRestartNow: LinearLayout; + + val onNextItem = Event0(); + val onRestartQueue = Event0(); + val onOpenQueueClick = Event0(); + + private val _activeColor = ContextCompat.getColor(context, R.color.gray_0d); + private val _inactiveColor = ContextCompat.getColor(context, R.color.gray_16); + + constructor(context: Context, attrs: AttributeSet? = null): super(context, attrs) { + LayoutInflater.from(context).inflate(R.layout.view_up_next, this, true); + + _layoutContainer = findViewById(R.id.videodetail_queue); + _textType = findViewById(R.id.videodetail_queue_type); + _textTitle = findViewById(R.id.videodetail_queue_title); + _textMetadata = findViewById(R.id.videodetail_queue_meta); + _imageThumbnail = findViewById(R.id.videodetail_queue_thumbnail); + _textChannelName = findViewById(R.id.videodetail_queue_channel_name); + _imageChannelThumbnail = findViewById(R.id.videodetail_queue_channel_image); + _textPosition = findViewById(R.id.videodetail_queue_position); + _textUpNext = findViewById(R.id.videodetail_up_next); + _buttonClear = findViewById(R.id.button_clear); + _buttonShuffle = findViewById(R.id.button_shuffle); + _buttonRepeat = findViewById(R.id.button_repeat); + _buttonView = findViewById(R.id.button_view); + _layoutRepeatDivider = findViewById(R.id.layout_repeat_divider); + _layoutQueueBox = findViewById(R.id.videodetail_queue_box); + _layoutEndOfPlaylist = findViewById(R.id.videodetail_end_of_playlist) + _textEndOfQueue = findViewById(R.id.text_end_of_queue); + _buttonRestartNow = findViewById(R.id.button_restart_now); + + _buttonClear.setOnClickListener { StatePlayer.instance.clearQueue(); }; + _buttonShuffle.setOnClickListener { + StatePlayer.instance.setQueueShuffle(!StatePlayer.instance.queueShuffle); + update(); + }; + + _buttonRepeat.setOnClickListener { + StatePlayer.instance.setQueueRepeat(!StatePlayer.instance.queueRepeat); + update(); + }; + + _buttonView.setOnClickListener { onOpenQueueClick.emit(); }; + + _imageThumbnail.setOnClickListener { onNextItem.emit() }; + _textTitle.setOnClickListener { onNextItem.emit(); }; + + _buttonRestartNow.setOnClickListener { + onRestartQueue.emit(); + }; + } + + private fun updateRepeatButton() { + if (StatePlayer.instance.queueRepeat) + _buttonRepeat.setBackgroundColor(_activeColor); + else + _buttonRepeat.setBackgroundColor(_inactiveColor); + } + + private fun updateShuffleButton() { + if (StatePlayer.instance.queueShuffle) + _buttonShuffle.setBackgroundColor(_activeColor); + else + _buttonShuffle.setBackgroundColor(_inactiveColor); + } + + fun update() + { + updateShuffleButton(); + updateRepeatButton(); + + val isPlaylist = StatePlayer.instance.getQueueLength() > 1; + if (!isPlaylist) { + _layoutContainer.visibility = View.GONE; + return; + } + + val nextItem = StatePlayer.instance.getNextQueueItem(); + + //End of queue + if (nextItem == null) { + if(StatePlayer.instance.getQueueLength() <= 0) { + _layoutContainer.visibility = View.GONE; + return; + } + + _layoutQueueBox.visibility = View.GONE; + _layoutEndOfPlaylist.visibility = View.VISIBLE; + + when (StatePlayer.instance.getQueueType()){ + StatePlayer.TYPE_WATCHLATER -> { + _buttonRestartNow.visibility = View.GONE; + _textEndOfQueue.text = resources.getString(R.string.end_of_watch_later_reached); + } + //TODO: This case doesn't make sense as queue deletes items as they finish + StatePlayer.TYPE_QUEUE -> { + _buttonRestartNow.visibility = View.VISIBLE; + _textEndOfQueue.text = if (StatePlayer.instance.queueRepeat) resources.getString(R.string.the_queue_will_restart_after_the_video_is_finished) else resources.getString(R.string.end_of_queue_reached); + } + StatePlayer.TYPE_PLAYLIST -> { + _buttonRestartNow.visibility = View.VISIBLE; + _textEndOfQueue.text = if (StatePlayer.instance.queueRepeat) resources.getString(R.string.the_playlist_will_restart_after_the_video_is_finished) else resources.getString(R.string.end_of_playlist_reached); + } + } + } + //Next Item + else { + _layoutQueueBox.visibility = View.VISIBLE; + _layoutEndOfPlaylist.visibility = View.GONE; + + _textTitle.text = nextItem.name ?: ""; + + val metadataTokens = mutableListOf(); + if (nextItem.viewCount > 0) { + metadataTokens.add("${nextItem.viewCount.toHumanNumber()} views"); + } + + if (nextItem.datetime != null) { + metadataTokens.add(nextItem.datetime!!.toHumanNowDiffString()) + } + + _textMetadata.text = metadataTokens.joinToString(" • "); + _textChannelName.text = nextItem.author.name ?: ""; + Glide.with(_imageThumbnail) + .load(nextItem.thumbnails.getHQThumbnail()) + .placeholder(R.drawable.placeholder_video_thumbnail) + .into(_imageThumbnail); + Glide.with(_imageChannelThumbnail) + .load(nextItem.author.thumbnail) + .placeholder(R.drawable.placeholder_video_thumbnail) + .into(_imageChannelThumbnail); + } + _layoutContainer.visibility = View.VISIBLE; + _textUpNext.text = StatePlayer.instance.getQueueType(); + _textType.text = if (StatePlayer.instance.queueName != StatePlayer.instance.getQueueType()) { StatePlayer.instance.queueName } else { "" }; + _textPosition.text = "${StatePlayer.instance.getQueueProgress() + 1}/${StatePlayer.instance.getQueueLength()}"; + + val repeatButtonEnabled = StatePlayer.instance.getQueueType() != StatePlayer.TYPE_WATCHLATER; + if (!repeatButtonEnabled) { + _buttonRepeat.visibility = View.GONE; + _layoutRepeatDivider.visibility = View.GONE; + } else { + _buttonRepeat.visibility = View.VISIBLE; + _layoutRepeatDivider.visibility = View.VISIBLE; + } + } +} \ No newline at end of file diff --git a/app/src/main/proto/com/futo/platformplayer/protos/DeviceAuthMessage.proto b/app/src/main/proto/com/futo/platformplayer/protos/DeviceAuthMessage.proto new file mode 100644 index 00000000..f6b090d9 --- /dev/null +++ b/app/src/main/proto/com/futo/platformplayer/protos/DeviceAuthMessage.proto @@ -0,0 +1,82 @@ +syntax = "proto2"; +option optimize_for = LITE_RUNTIME; +package com.futo.platformplayer.protos; + +message CastMessage { + // Always pass a version of the protocol for future compatibility + // requirements. + enum ProtocolVersion { CASTV2_1_0 = 0; } + required ProtocolVersion protocol_version = 1; + // source and destination ids identify the origin and destination of the + // message. They are used to route messages between endpoints that share a + // device-to-device channel. + // + // For messages between applications: + // - The sender application id is a unique identifier generated on behalf of + // the sender application. + // - The receiver id is always the the session id for the application. + // + // For messages to or from the sender or receiver platform, the special ids + // 'sender-0' and 'receiver-0' can be used. + // + // For messages intended for all endpoints using a given channel, the + // wildcard destination_id '*' can be used. + required string source_id = 2; + required string destination_id = 3; + // This is the core multiplexing key. All messages are sent on a namespace + // and endpoints sharing a channel listen on one or more namespaces. The + // namespace defines the protocol and semantics of the message. + required string namespace = 4; + // Encoding and payload info follows. + // What type of data do we have in this message. + enum PayloadType { + STRING = 0; + BINARY = 1; + } + required PayloadType payload_type = 5; + // Depending on payload_type, exactly one of the following optional fields + // will always be set. + optional string payload_utf8 = 6; + optional bytes payload_binary = 7; +} +enum SignatureAlgorithm { + UNSPECIFIED = 0; + RSASSA_PKCS1v15 = 1; + RSASSA_PSS = 2; +} +enum HashAlgorithm { + SHA1 = 0; + SHA256 = 1; +} +// Messages for authentication protocol between a sender and a receiver. +message AuthChallenge { + optional SignatureAlgorithm signature_algorithm = 1 + [default = RSASSA_PKCS1v15]; + optional bytes sender_nonce = 2; + optional HashAlgorithm hash_algorithm = 3 [default = SHA1]; +} +message AuthResponse { + required bytes signature = 1; + required bytes client_auth_certificate = 2; + repeated bytes intermediate_certificate = 3; + optional SignatureAlgorithm signature_algorithm = 4 + [default = RSASSA_PKCS1v15]; + optional bytes sender_nonce = 5; + optional HashAlgorithm hash_algorithm = 6 [default = SHA1]; + optional bytes crl = 7; +} +message AuthError { + enum ErrorType { + INTERNAL_ERROR = 0; + NO_TLS = 1; // The underlying connection is not TLS + SIGNATURE_ALGORITHM_UNAVAILABLE = 2; + } + required ErrorType error_type = 1; +} +message DeviceAuthMessage { + // Request fields + optional AuthChallenge challenge = 1; + // Response fields + optional AuthResponse response = 2; + optional AuthError error = 3; +} diff --git a/app/src/main/res/anim/slide_darken.xml b/app/src/main/res/anim/slide_darken.xml new file mode 100644 index 00000000..28c09f99 --- /dev/null +++ b/app/src/main/res/anim/slide_darken.xml @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_in_up.xml b/app/src/main/res/anim/slide_in_up.xml new file mode 100644 index 00000000..e54c8cae --- /dev/null +++ b/app/src/main/res/anim/slide_in_up.xml @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_lighten.xml b/app/src/main/res/anim/slide_lighten.xml new file mode 100644 index 00000000..d5243935 --- /dev/null +++ b/app/src/main/res/anim/slide_lighten.xml @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_out_up.xml b/app/src/main/res/anim/slide_out_up.xml new file mode 100644 index 00000000..40e7688d --- /dev/null +++ b/app/src/main/res/anim/slide_out_up.xml @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/app/src/main/res/animator/fade.xml b/app/src/main/res/animator/fade.xml new file mode 100644 index 00000000..da874f36 --- /dev/null +++ b/app/src/main/res/animator/fade.xml @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/app/src/main/res/animator/fade_400.xml b/app/src/main/res/animator/fade_400.xml new file mode 100644 index 00000000..79e18249 --- /dev/null +++ b/app/src/main/res/animator/fade_400.xml @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/app/src/main/res/animator/fade_800.xml b/app/src/main/res/animator/fade_800.xml new file mode 100644 index 00000000..b69f0e17 --- /dev/null +++ b/app/src/main/res/animator/fade_800.xml @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/app/src/main/res/animator/rotation_1500_clockwise.xml b/app/src/main/res/animator/rotation_1500_clockwise.xml new file mode 100644 index 00000000..b32fbadb --- /dev/null +++ b/app/src/main/res/animator/rotation_1500_clockwise.xml @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/app/src/main/res/animator/rotation_2000_clockwise.xml b/app/src/main/res/animator/rotation_2000_clockwise.xml new file mode 100644 index 00000000..d787bff1 --- /dev/null +++ b/app/src/main/res/animator/rotation_2000_clockwise.xml @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/app/src/main/res/animator/rotation_2500_counter_clockwise.xml b/app/src/main/res/animator/rotation_2500_counter_clockwise.xml new file mode 100644 index 00000000..8e9c9439 --- /dev/null +++ b/app/src/main/res/animator/rotation_2500_counter_clockwise.xml @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/app/src/main/res/animator/toggle_background.xml b/app/src/main/res/animator/toggle_background.xml new file mode 100644 index 00000000..b142a0fa --- /dev/null +++ b/app/src/main/res/animator/toggle_background.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/animator/toggle_background_reverse.xml b/app/src/main/res/animator/toggle_background_reverse.xml new file mode 100644 index 00000000..cc923483 --- /dev/null +++ b/app/src/main/res/animator/toggle_background_reverse.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/animator/toggle_foreground.xml b/app/src/main/res/animator/toggle_foreground.xml new file mode 100644 index 00000000..ee8b67e9 --- /dev/null +++ b/app/src/main/res/animator/toggle_foreground.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/animator/toggle_foreground_container.xml b/app/src/main/res/animator/toggle_foreground_container.xml new file mode 100644 index 00000000..2c517234 --- /dev/null +++ b/app/src/main/res/animator/toggle_foreground_container.xml @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/app/src/main/res/animator/toggle_foreground_container_reverse.xml b/app/src/main/res/animator/toggle_foreground_container_reverse.xml new file mode 100644 index 00000000..b8e74136 --- /dev/null +++ b/app/src/main/res/animator/toggle_foreground_container_reverse.xml @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/app/src/main/res/animator/toggle_foreground_reverse.xml b/app/src/main/res/animator/toggle_foreground_reverse.xml new file mode 100644 index 00000000..17b245e4 --- /dev/null +++ b/app/src/main/res/animator/toggle_foreground_reverse.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v24/example_banner.jpg b/app/src/main/res/drawable-v24/example_banner.jpg new file mode 100644 index 00000000..1acfcea7 Binary files /dev/null and b/app/src/main/res/drawable-v24/example_banner.jpg differ diff --git a/app/src/main/res/drawable-v24/placeholder_channel_thumbnail.jpg b/app/src/main/res/drawable-v24/placeholder_channel_thumbnail.jpg new file mode 100644 index 00000000..b69aeb81 Binary files /dev/null and b/app/src/main/res/drawable-v24/placeholder_channel_thumbnail.jpg differ diff --git a/app/src/main/res/drawable-v24/placeholder_video_thumbnail.webp b/app/src/main/res/drawable-v24/placeholder_video_thumbnail.webp new file mode 100644 index 00000000..9f90b769 Binary files /dev/null and b/app/src/main/res/drawable-v24/placeholder_video_thumbnail.webp differ diff --git a/app/src/main/res/drawable/background_16_round_4dp.xml b/app/src/main/res/drawable/background_16_round_4dp.xml new file mode 100644 index 00000000..96e7697f --- /dev/null +++ b/app/src/main/res/drawable/background_16_round_4dp.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_1d_round_4dp.xml b/app/src/main/res/drawable/background_1d_round_4dp.xml new file mode 100644 index 00000000..cb3b2fdf --- /dev/null +++ b/app/src/main/res/drawable/background_1d_round_4dp.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_30_round_4dp.xml b/app/src/main/res/drawable/background_30_round_4dp.xml new file mode 100644 index 00000000..580ebe03 --- /dev/null +++ b/app/src/main/res/drawable/background_30_round_4dp.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_big_button.xml b/app/src/main/res/drawable/background_big_button.xml new file mode 100644 index 00000000..e4bafdd8 --- /dev/null +++ b/app/src/main/res/drawable/background_big_button.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_big_button_red.xml b/app/src/main/res/drawable/background_big_button_red.xml new file mode 100644 index 00000000..ff5234a6 --- /dev/null +++ b/app/src/main/res/drawable/background_big_button_red.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_button_accent.xml b/app/src/main/res/drawable/background_button_accent.xml new file mode 100644 index 00000000..3ab7713f --- /dev/null +++ b/app/src/main/res/drawable/background_button_accent.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_button_accent_straight_left.xml b/app/src/main/res/drawable/background_button_accent_straight_left.xml new file mode 100644 index 00000000..e0d3cd37 --- /dev/null +++ b/app/src/main/res/drawable/background_button_accent_straight_left.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/background_button_black.xml b/app/src/main/res/drawable/background_button_black.xml new file mode 100644 index 00000000..3b1cd0d6 --- /dev/null +++ b/app/src/main/res/drawable/background_button_black.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_button_err.xml b/app/src/main/res/drawable/background_button_err.xml new file mode 100644 index 00000000..38184c02 --- /dev/null +++ b/app/src/main/res/drawable/background_button_err.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_button_err_round_4dp.xml b/app/src/main/res/drawable/background_button_err_round_4dp.xml new file mode 100644 index 00000000..b4e1f2c8 --- /dev/null +++ b/app/src/main/res/drawable/background_button_err_round_4dp.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_button_gray_straight_left.xml b/app/src/main/res/drawable/background_button_gray_straight_left.xml new file mode 100644 index 00000000..41ecc289 --- /dev/null +++ b/app/src/main/res/drawable/background_button_gray_straight_left.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/background_button_pred.xml b/app/src/main/res/drawable/background_button_pred.xml new file mode 100644 index 00000000..e92d99cf --- /dev/null +++ b/app/src/main/res/drawable/background_button_pred.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_button_primary.xml b/app/src/main/res/drawable/background_button_primary.xml new file mode 100644 index 00000000..461ba0b2 --- /dev/null +++ b/app/src/main/res/drawable/background_button_primary.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_button_primary_round.xml b/app/src/main/res/drawable/background_button_primary_round.xml new file mode 100644 index 00000000..80d90ac4 --- /dev/null +++ b/app/src/main/res/drawable/background_button_primary_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_button_primary_round_4dp.xml b/app/src/main/res/drawable/background_button_primary_round_4dp.xml new file mode 100644 index 00000000..895fbeff --- /dev/null +++ b/app/src/main/res/drawable/background_button_primary_round_4dp.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_button_primary_straight_left.xml b/app/src/main/res/drawable/background_button_primary_straight_left.xml new file mode 100644 index 00000000..d5dc36b1 --- /dev/null +++ b/app/src/main/res/drawable/background_button_primary_straight_left.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/background_button_round.xml b/app/src/main/res/drawable/background_button_round.xml new file mode 100644 index 00000000..845e8860 --- /dev/null +++ b/app/src/main/res/drawable/background_button_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_button_round_green.xml b/app/src/main/res/drawable/background_button_round_green.xml new file mode 100644 index 00000000..1feaebc5 --- /dev/null +++ b/app/src/main/res/drawable/background_button_round_green.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_button_source.xml b/app/src/main/res/drawable/background_button_source.xml new file mode 100644 index 00000000..e4bafdd8 --- /dev/null +++ b/app/src/main/res/drawable/background_button_source.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_button_transparent_round.xml b/app/src/main/res/drawable/background_button_transparent_round.xml new file mode 100644 index 00000000..c9b25812 --- /dev/null +++ b/app/src/main/res/drawable/background_button_transparent_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_channel_round.xml b/app/src/main/res/drawable/background_channel_round.xml new file mode 100644 index 00000000..a045573b --- /dev/null +++ b/app/src/main/res/drawable/background_channel_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_fade.xml b/app/src/main/res/drawable/background_fade.xml new file mode 100644 index 00000000..acad860d --- /dev/null +++ b/app/src/main/res/drawable/background_fade.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/drawable/background_gesture_controls.xml b/app/src/main/res/drawable/background_gesture_controls.xml new file mode 100644 index 00000000..5dd7365a --- /dev/null +++ b/app/src/main/res/drawable/background_gesture_controls.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_pill.xml b/app/src/main/res/drawable/background_pill.xml new file mode 100644 index 00000000..7112c768 --- /dev/null +++ b/app/src/main/res/drawable/background_pill.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_pill_black.xml b/app/src/main/res/drawable/background_pill_black.xml new file mode 100644 index 00000000..164050d0 --- /dev/null +++ b/app/src/main/res/drawable/background_pill_black.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_pill_toggled.xml b/app/src/main/res/drawable/background_pill_toggled.xml new file mode 100644 index 00000000..a4960fbd --- /dev/null +++ b/app/src/main/res/drawable/background_pill_toggled.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_pill_transparent.xml b/app/src/main/res/drawable/background_pill_transparent.xml new file mode 100644 index 00000000..0e515e7f --- /dev/null +++ b/app/src/main/res/drawable/background_pill_transparent.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_pill_untoggled.xml b/app/src/main/res/drawable/background_pill_untoggled.xml new file mode 100644 index 00000000..c72b9128 --- /dev/null +++ b/app/src/main/res/drawable/background_pill_untoggled.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_radio_selected.xml b/app/src/main/res/drawable/background_radio_selected.xml new file mode 100644 index 00000000..c5fc6bdf --- /dev/null +++ b/app/src/main/res/drawable/background_radio_selected.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_radio_unselected.xml b/app/src/main/res/drawable/background_radio_unselected.xml new file mode 100644 index 00000000..f5ea7dca --- /dev/null +++ b/app/src/main/res/drawable/background_radio_unselected.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_slide_up_option.xml b/app/src/main/res/drawable/background_slide_up_option.xml new file mode 100644 index 00000000..3f0a602f --- /dev/null +++ b/app/src/main/res/drawable/background_slide_up_option.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_slide_up_option_selected.xml b/app/src/main/res/drawable/background_slide_up_option_selected.xml new file mode 100644 index 00000000..166ddbec --- /dev/null +++ b/app/src/main/res/drawable/background_slide_up_option_selected.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_small_button.xml b/app/src/main/res/drawable/background_small_button.xml new file mode 100644 index 00000000..767ee852 --- /dev/null +++ b/app/src/main/res/drawable/background_small_button.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_square.xml b/app/src/main/res/drawable/background_square.xml new file mode 100644 index 00000000..9df3ad52 --- /dev/null +++ b/app/src/main/res/drawable/background_square.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_store.xml b/app/src/main/res/drawable/background_store.xml new file mode 100644 index 00000000..d7cddcfc --- /dev/null +++ b/app/src/main/res/drawable/background_store.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_support.xml b/app/src/main/res/drawable/background_support.xml new file mode 100644 index 00000000..5f5d3e63 --- /dev/null +++ b/app/src/main/res/drawable/background_support.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_thumbnail_duration.xml b/app/src/main/res/drawable/background_thumbnail_duration.xml new file mode 100644 index 00000000..ded5f151 --- /dev/null +++ b/app/src/main/res/drawable/background_thumbnail_duration.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_thumbnail_live.xml b/app/src/main/res/drawable/background_thumbnail_live.xml new file mode 100644 index 00000000..c2f960b5 --- /dev/null +++ b/app/src/main/res/drawable/background_thumbnail_live.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_thumbnail_video_options.xml b/app/src/main/res/drawable/background_thumbnail_video_options.xml new file mode 100644 index 00000000..8a55df12 --- /dev/null +++ b/app/src/main/res/drawable/background_thumbnail_video_options.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_up_next_toggle_active.xml b/app/src/main/res/drawable/background_up_next_toggle_active.xml new file mode 100644 index 00000000..00fc9e42 --- /dev/null +++ b/app/src/main/res/drawable/background_up_next_toggle_active.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/background_videodetail_description.xml b/app/src/main/res/drawable/background_videodetail_description.xml new file mode 100644 index 00000000..1e4df987 --- /dev/null +++ b/app/src/main/res/drawable/background_videodetail_description.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/banner_placeholder.png b/app/src/main/res/drawable/banner_placeholder.png new file mode 100644 index 00000000..46cf5178 Binary files /dev/null and b/app/src/main/res/drawable/banner_placeholder.png differ diff --git a/app/src/main/res/drawable/bottom_gradient.xml b/app/src/main/res/drawable/bottom_gradient.xml new file mode 100644 index 00000000..8ba12ecd --- /dev/null +++ b/app/src/main/res/drawable/bottom_gradient.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/bottom_menu_border.xml b/app/src/main/res/drawable/bottom_menu_border.xml new file mode 100644 index 00000000..2de75eef --- /dev/null +++ b/app/src/main/res/drawable/bottom_menu_border.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/channel_icon_outline.xml b/app/src/main/res/drawable/channel_icon_outline.xml new file mode 100644 index 00000000..f40f031a --- /dev/null +++ b/app/src/main/res/drawable/channel_icon_outline.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/download_for_offline.xml b/app/src/main/res/drawable/download_for_offline.xml new file mode 100644 index 00000000..cdceb5c6 --- /dev/null +++ b/app/src/main/res/drawable/download_for_offline.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/edit_text_background.xml b/app/src/main/res/drawable/edit_text_background.xml new file mode 100644 index 00000000..9996e25e --- /dev/null +++ b/app/src/main/res/drawable/edit_text_background.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/edit_text_cursor.xml b/app/src/main/res/drawable/edit_text_cursor.xml new file mode 100644 index 00000000..bebd2432 --- /dev/null +++ b/app/src/main/res/drawable/edit_text_cursor.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/foreground.png b/app/src/main/res/drawable/foreground.png new file mode 100644 index 00000000..3dced0f0 Binary files /dev/null and b/app/src/main/res/drawable/foreground.png differ diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 00000000..fb1640a2 --- /dev/null +++ b/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_add_400.xml b/app/src/main/res/drawable/ic_add_400.xml new file mode 100644 index 00000000..16eeaf2b --- /dev/null +++ b/app/src/main/res/drawable/ic_add_400.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_add_to_query_16dp.xml b/app/src/main/res/drawable/ic_add_to_query_16dp.xml new file mode 100644 index 00000000..b55e8278 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_to_query_16dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add_white_16dp.xml b/app/src/main/res/drawable/ic_add_white_16dp.xml new file mode 100644 index 00000000..7de8cc9a --- /dev/null +++ b/app/src/main/res/drawable/ic_add_white_16dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_add_white_8dp.xml b/app/src/main/res/drawable/ic_add_white_8dp.xml new file mode 100644 index 00000000..155b3607 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_white_8dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_airplay.xml b/app/src/main/res/drawable/ic_airplay.xml new file mode 100644 index 00000000..eaec1c0f --- /dev/null +++ b/app/src/main/res/drawable/ic_airplay.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_arrow_downward.xml b/app/src/main/res/drawable/ic_arrow_downward.xml new file mode 100644 index 00000000..763ca127 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_downward.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_right.xml b/app/src/main/res/drawable/ic_arrow_right.xml new file mode 100644 index 00000000..388bd2c8 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_right.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_aspect_ratio.xml b/app/src/main/res/drawable/ic_aspect_ratio.xml new file mode 100644 index 00000000..cb30bdd0 --- /dev/null +++ b/app/src/main/res/drawable/ic_aspect_ratio.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_back_nav.xml b/app/src/main/res/drawable/ic_back_nav.xml new file mode 100644 index 00000000..faf8c291 --- /dev/null +++ b/app/src/main/res/drawable/ic_back_nav.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_back_thin_white_16dp.xml b/app/src/main/res/drawable/ic_back_thin_white_16dp.xml new file mode 100644 index 00000000..05e5cc29 --- /dev/null +++ b/app/src/main/res/drawable/ic_back_thin_white_16dp.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_back_white_24dp.xml b/app/src/main/res/drawable/ic_back_white_24dp.xml new file mode 100644 index 00000000..18567759 --- /dev/null +++ b/app/src/main/res/drawable/ic_back_white_24dp.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_block.xml b/app/src/main/res/drawable/ic_block.xml new file mode 100644 index 00000000..3e3b57f0 --- /dev/null +++ b/app/src/main/res/drawable/ic_block.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_brightness.xml b/app/src/main/res/drawable/ic_brightness.xml new file mode 100644 index 00000000..1e8b253c --- /dev/null +++ b/app/src/main/res/drawable/ic_brightness.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_brightness_1.xml b/app/src/main/res/drawable/ic_brightness_1.xml new file mode 100644 index 00000000..1e8b253c --- /dev/null +++ b/app/src/main/res/drawable/ic_brightness_1.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_cast.xml b/app/src/main/res/drawable/ic_cast.xml new file mode 100644 index 00000000..2b040de6 --- /dev/null +++ b/app/src/main/res/drawable/ic_cast.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_cast_white_20dp.xml b/app/src/main/res/drawable/ic_cast_white_20dp.xml new file mode 100644 index 00000000..ae5963b7 --- /dev/null +++ b/app/src/main/res/drawable/ic_cast_white_20dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_cast_white_25dp.xml b/app/src/main/res/drawable/ic_cast_white_25dp.xml new file mode 100644 index 00000000..bd90bc78 --- /dev/null +++ b/app/src/main/res/drawable/ic_cast_white_25dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_cast_white_32dp.xml b/app/src/main/res/drawable/ic_cast_white_32dp.xml new file mode 100644 index 00000000..e382055e --- /dev/null +++ b/app/src/main/res/drawable/ic_cast_white_32dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_chat.xml b/app/src/main/res/drawable/ic_chat.xml new file mode 100644 index 00000000..21e19543 --- /dev/null +++ b/app/src/main/res/drawable/ic_chat.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 00000000..9b10800f --- /dev/null +++ b/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable/ic_checkbox_checked.xml b/app/src/main/res/drawable/ic_checkbox_checked.xml new file mode 100644 index 00000000..ba2339b3 --- /dev/null +++ b/app/src/main/res/drawable/ic_checkbox_checked.xml @@ -0,0 +1,31 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_checkbox_unchecked.xml b/app/src/main/res/drawable/ic_checkbox_unchecked.xml new file mode 100644 index 00000000..418be588 --- /dev/null +++ b/app/src/main/res/drawable/ic_checkbox_unchecked.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_chromecast.xml b/app/src/main/res/drawable/ic_chromecast.xml new file mode 100644 index 00000000..dd934026 --- /dev/null +++ b/app/src/main/res/drawable/ic_chromecast.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_clear_16dp.xml b/app/src/main/res/drawable/ic_clear_16dp.xml new file mode 100644 index 00000000..f73e1da1 --- /dev/null +++ b/app/src/main/res/drawable/ic_clear_16dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_clock_white.xml b/app/src/main/res/drawable/ic_clock_white.xml new file mode 100644 index 00000000..41b952cb --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_white.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_close.xml b/app/src/main/res/drawable/ic_close.xml new file mode 100644 index 00000000..a356ef18 --- /dev/null +++ b/app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_close_thin.xml b/app/src/main/res/drawable/ic_close_thin.xml new file mode 100644 index 00000000..a356ef18 --- /dev/null +++ b/app/src/main/res/drawable/ic_close_thin.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_code.xml b/app/src/main/res/drawable/ic_code.xml new file mode 100644 index 00000000..451c2e86 --- /dev/null +++ b/app/src/main/res/drawable/ic_code.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_code_red.xml b/app/src/main/res/drawable/ic_code_red.xml new file mode 100644 index 00000000..69b9b792 --- /dev/null +++ b/app/src/main/res/drawable/ic_code_red.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_construction.xml b/app/src/main/res/drawable/ic_construction.xml new file mode 100644 index 00000000..694d5f5d --- /dev/null +++ b/app/src/main/res/drawable/ic_construction.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_copy.xml b/app/src/main/res/drawable/ic_copy.xml new file mode 100644 index 00000000..a0f1df78 --- /dev/null +++ b/app/src/main/res/drawable/ic_copy.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_creators.xml b/app/src/main/res/drawable/ic_creators.xml new file mode 100644 index 00000000..e2f6410a --- /dev/null +++ b/app/src/main/res/drawable/ic_creators.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_creators_active.xml b/app/src/main/res/drawable/ic_creators_active.xml new file mode 100644 index 00000000..62d2e3b4 --- /dev/null +++ b/app/src/main/res/drawable/ic_creators_active.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_creators_filled.xml b/app/src/main/res/drawable/ic_creators_filled.xml new file mode 100644 index 00000000..afe50718 --- /dev/null +++ b/app/src/main/res/drawable/ic_creators_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_creators_inactive.xml b/app/src/main/res/drawable/ic_creators_inactive.xml new file mode 100644 index 00000000..df783339 --- /dev/null +++ b/app/src/main/res/drawable/ic_creators_inactive.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_download.xml b/app/src/main/res/drawable/ic_download.xml new file mode 100644 index 00000000..b542e84e --- /dev/null +++ b/app/src/main/res/drawable/ic_download.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_download_off.xml b/app/src/main/res/drawable/ic_download_off.xml new file mode 100644 index 00000000..e500166c --- /dev/null +++ b/app/src/main/res/drawable/ic_download_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_dragdrop_white.xml b/app/src/main/res/drawable/ic_dragdrop_white.xml new file mode 100644 index 00000000..0aa73a1c --- /dev/null +++ b/app/src/main/res/drawable/ic_dragdrop_white.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml new file mode 100644 index 00000000..542d4892 --- /dev/null +++ b/app/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_error.xml b/app/src/main/res/drawable/ic_error.xml new file mode 100644 index 00000000..b52e1a5f --- /dev/null +++ b/app/src/main/res/drawable/ic_error.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_error_pred.xml b/app/src/main/res/drawable/ic_error_pred.xml new file mode 100644 index 00000000..674d803a --- /dev/null +++ b/app/src/main/res/drawable/ic_error_pred.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_expand.xml b/app/src/main/res/drawable/ic_expand.xml new file mode 100644 index 00000000..aa4fb395 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_expand_18dp.xml b/app/src/main/res/drawable/ic_expand_18dp.xml new file mode 100644 index 00000000..338c3df8 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_18dp.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable/ic_expand_white.xml b/app/src/main/res/drawable/ic_expand_white.xml new file mode 100644 index 00000000..c942e0ce --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_white.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_export.xml b/app/src/main/res/drawable/ic_export.xml new file mode 100644 index 00000000..5c9a70ab --- /dev/null +++ b/app/src/main/res/drawable/ic_export.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_fast_forward.xml b/app/src/main/res/drawable/ic_fast_forward.xml new file mode 100644 index 00000000..9686bc47 --- /dev/null +++ b/app/src/main/res/drawable/ic_fast_forward.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_fast_forward_notif.xml b/app/src/main/res/drawable/ic_fast_forward_notif.xml new file mode 100644 index 00000000..44a9b0dd --- /dev/null +++ b/app/src/main/res/drawable/ic_fast_forward_notif.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_fast_rewind.xml b/app/src/main/res/drawable/ic_fast_rewind.xml new file mode 100644 index 00000000..1a093389 --- /dev/null +++ b/app/src/main/res/drawable/ic_fast_rewind.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_fast_rewind_notif.xml b/app/src/main/res/drawable/ic_fast_rewind_notif.xml new file mode 100644 index 00000000..fe6de961 --- /dev/null +++ b/app/src/main/res/drawable/ic_fast_rewind_notif.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_fastforward.xml b/app/src/main/res/drawable/ic_fastforward.xml new file mode 100644 index 00000000..11d1a358 --- /dev/null +++ b/app/src/main/res/drawable/ic_fastforward.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_fastforward_animated.xml b/app/src/main/res/drawable/ic_fastforward_animated.xml new file mode 100644 index 00000000..0c3354e8 --- /dev/null +++ b/app/src/main/res/drawable/ic_fastforward_animated.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_fc.xml b/app/src/main/res/drawable/ic_fc.xml new file mode 100644 index 00000000..bbc5e8fa --- /dev/null +++ b/app/src/main/res/drawable/ic_fc.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_filter_settings_25dp.xml b/app/src/main/res/drawable/ic_filter_settings_25dp.xml new file mode 100644 index 00000000..c9b055a1 --- /dev/null +++ b/app/src/main/res/drawable/ic_filter_settings_25dp.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_forum.xml b/app/src/main/res/drawable/ic_forum.xml new file mode 100644 index 00000000..69628ae7 --- /dev/null +++ b/app/src/main/res/drawable/ic_forum.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_futo_logo.xml b/app/src/main/res/drawable/ic_futo_logo.xml new file mode 100644 index 00000000..a87e04ef --- /dev/null +++ b/app/src/main/res/drawable/ic_futo_logo.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_futo_logo_text.xml b/app/src/main/res/drawable/ic_futo_logo_text.xml new file mode 100644 index 00000000..ad86784d --- /dev/null +++ b/app/src/main/res/drawable/ic_futo_logo_text.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_help.xml b/app/src/main/res/drawable/ic_help.xml new file mode 100644 index 00000000..9326fd6f --- /dev/null +++ b/app/src/main/res/drawable/ic_help.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_history.xml b/app/src/main/res/drawable/ic_history.xml new file mode 100644 index 00000000..57dddfab --- /dev/null +++ b/app/src/main/res/drawable/ic_history.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_history_29dp.xml b/app/src/main/res/drawable/ic_history_29dp.xml new file mode 100644 index 00000000..bd2b2b23 --- /dev/null +++ b/app/src/main/res/drawable/ic_history_29dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_home.xml b/app/src/main/res/drawable/ic_home.xml new file mode 100644 index 00000000..277cdf13 --- /dev/null +++ b/app/src/main/res/drawable/ic_home.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_home_active_25dp.xml b/app/src/main/res/drawable/ic_home_active_25dp.xml new file mode 100644 index 00000000..bde553a1 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_active_25dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_home_filled.xml b/app/src/main/res/drawable/ic_home_filled.xml new file mode 100644 index 00000000..93bd9979 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_home_inactive_25dp.xml b/app/src/main/res/drawable/ic_home_inactive_25dp.xml new file mode 100644 index 00000000..5a1e27db --- /dev/null +++ b/app/src/main/res/drawable/ic_home_inactive_25dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_image.xml b/app/src/main/res/drawable/ic_image.xml new file mode 100644 index 00000000..85385258 --- /dev/null +++ b/app/src/main/res/drawable/ic_image.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_language.xml b/app/src/main/res/drawable/ic_language.xml new file mode 100644 index 00000000..dc53403b --- /dev/null +++ b/app/src/main/res/drawable/ic_language.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_link.xml b/app/src/main/res/drawable/ic_link.xml new file mode 100644 index 00000000..4f80ebb4 --- /dev/null +++ b/app/src/main/res/drawable/ic_link.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_list.xml b/app/src/main/res/drawable/ic_list.xml new file mode 100644 index 00000000..0b57b8de --- /dev/null +++ b/app/src/main/res/drawable/ic_list.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_loader.xml b/app/src/main/res/drawable/ic_loader.xml new file mode 100644 index 00000000..02667f31 --- /dev/null +++ b/app/src/main/res/drawable/ic_loader.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_loader_animated.xml b/app/src/main/res/drawable/ic_loader_animated.xml new file mode 100644 index 00000000..bb84663f --- /dev/null +++ b/app/src/main/res/drawable/ic_loader_animated.xml @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_lock.xml b/app/src/main/res/drawable/ic_lock.xml new file mode 100644 index 00000000..c4d19196 --- /dev/null +++ b/app/src/main/res/drawable/ic_lock.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_login.xml b/app/src/main/res/drawable/ic_login.xml new file mode 100644 index 00000000..532bb678 --- /dev/null +++ b/app/src/main/res/drawable/ic_login.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_logout.xml b/app/src/main/res/drawable/ic_logout.xml new file mode 100644 index 00000000..050e5a53 --- /dev/null +++ b/app/src/main/res/drawable/ic_logout.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_menu.xml b/app/src/main/res/drawable/ic_menu.xml new file mode 100644 index 00000000..760444f3 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_minimize.xml b/app/src/main/res/drawable/ic_minimize.xml new file mode 100644 index 00000000..91d6d03f --- /dev/null +++ b/app/src/main/res/drawable/ic_minimize.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_more.xml b/app/src/main/res/drawable/ic_more.xml new file mode 100644 index 00000000..e27952ab --- /dev/null +++ b/app/src/main/res/drawable/ic_more.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_more_inactive_25dp.xml b/app/src/main/res/drawable/ic_more_inactive_25dp.xml new file mode 100644 index 00000000..3a2510a8 --- /dev/null +++ b/app/src/main/res/drawable/ic_more_inactive_25dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_move_up.xml b/app/src/main/res/drawable/ic_move_up.xml new file mode 100644 index 00000000..fb20a55c --- /dev/null +++ b/app/src/main/res/drawable/ic_move_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_movie.xml b/app/src/main/res/drawable/ic_movie.xml new file mode 100644 index 00000000..af934aae --- /dev/null +++ b/app/src/main/res/drawable/ic_movie.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_music.xml b/app/src/main/res/drawable/ic_music.xml new file mode 100644 index 00000000..52d5ab27 --- /dev/null +++ b/app/src/main/res/drawable/ic_music.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_no_internet_86dp.xml b/app/src/main/res/drawable/ic_no_internet_86dp.xml new file mode 100644 index 00000000..5e9d34d9 --- /dev/null +++ b/app/src/main/res/drawable/ic_no_internet_86dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_paid.xml b/app/src/main/res/drawable/ic_paid.xml new file mode 100644 index 00000000..085d07b5 --- /dev/null +++ b/app/src/main/res/drawable/ic_paid.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_paid_200.xml b/app/src/main/res/drawable/ic_paid_200.xml new file mode 100644 index 00000000..e1f4eb74 --- /dev/null +++ b/app/src/main/res/drawable/ic_paid_200.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/ic_pause.xml new file mode 100644 index 00000000..a6b14ed8 --- /dev/null +++ b/app/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_pause_notif.xml b/app/src/main/res/drawable/ic_pause_notif.xml new file mode 100644 index 00000000..d267e9fd --- /dev/null +++ b/app/src/main/res/drawable/ic_pause_notif.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_pause_white.xml b/app/src/main/res/drawable/ic_pause_white.xml new file mode 100644 index 00000000..a6b14ed8 --- /dev/null +++ b/app/src/main/res/drawable/ic_pause_white.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_peertube.png b/app/src/main/res/drawable/ic_peertube.png new file mode 100644 index 00000000..ff9cf030 Binary files /dev/null and b/app/src/main/res/drawable/ic_peertube.png differ diff --git a/app/src/main/res/drawable/ic_person.xml b/app/src/main/res/drawable/ic_person.xml new file mode 100644 index 00000000..6b390557 --- /dev/null +++ b/app/src/main/res/drawable/ic_person.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_person_add.xml b/app/src/main/res/drawable/ic_person_add.xml new file mode 100644 index 00000000..8d33a6ec --- /dev/null +++ b/app/src/main/res/drawable/ic_person_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_person_search_300w.xml b/app/src/main/res/drawable/ic_person_search_300w.xml new file mode 100644 index 00000000..9285fa41 --- /dev/null +++ b/app/src/main/res/drawable/ic_person_search_300w.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_pin.xml b/app/src/main/res/drawable/ic_pin.xml new file mode 100644 index 00000000..061e46a1 --- /dev/null +++ b/app/src/main/res/drawable/ic_pin.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_play.xml b/app/src/main/res/drawable/ic_play.xml new file mode 100644 index 00000000..4bd70b88 --- /dev/null +++ b/app/src/main/res/drawable/ic_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_play_200w.xml b/app/src/main/res/drawable/ic_play_200w.xml new file mode 100644 index 00000000..d77dc277 --- /dev/null +++ b/app/src/main/res/drawable/ic_play_200w.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_play_notif.xml b/app/src/main/res/drawable/ic_play_notif.xml new file mode 100644 index 00000000..211218a6 --- /dev/null +++ b/app/src/main/res/drawable/ic_play_notif.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_play_white_nopad.xml b/app/src/main/res/drawable/ic_play_white_nopad.xml new file mode 100644 index 00000000..b3e0b907 --- /dev/null +++ b/app/src/main/res/drawable/ic_play_white_nopad.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_playlist.xml b/app/src/main/res/drawable/ic_playlist.xml new file mode 100644 index 00000000..add0b474 --- /dev/null +++ b/app/src/main/res/drawable/ic_playlist.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_playlist_add.xml b/app/src/main/res/drawable/ic_playlist_add.xml new file mode 100644 index 00000000..00af4d6b --- /dev/null +++ b/app/src/main/res/drawable/ic_playlist_add.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_playlist_filled.xml b/app/src/main/res/drawable/ic_playlist_filled.xml new file mode 100644 index 00000000..cb393c70 --- /dev/null +++ b/app/src/main/res/drawable/ic_playlist_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_playlist_select.xml b/app/src/main/res/drawable/ic_playlist_select.xml new file mode 100644 index 00000000..cea13914 --- /dev/null +++ b/app/src/main/res/drawable/ic_playlist_select.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_playlists_active.xml b/app/src/main/res/drawable/ic_playlists_active.xml new file mode 100644 index 00000000..064708f8 --- /dev/null +++ b/app/src/main/res/drawable/ic_playlists_active.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_playlists_inactive_25dp.xml b/app/src/main/res/drawable/ic_playlists_inactive_25dp.xml new file mode 100644 index 00000000..21f502ea --- /dev/null +++ b/app/src/main/res/drawable/ic_playlists_inactive_25dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_qr.xml b/app/src/main/res/drawable/ic_qr.xml new file mode 100644 index 00000000..ed6f49ce --- /dev/null +++ b/app/src/main/res/drawable/ic_qr.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_queue_16dp.xml b/app/src/main/res/drawable/ic_queue_16dp.xml new file mode 100644 index 00000000..20c10efe --- /dev/null +++ b/app/src/main/res/drawable/ic_queue_16dp.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_queue_add.xml b/app/src/main/res/drawable/ic_queue_add.xml new file mode 100644 index 00000000..b900c1b0 --- /dev/null +++ b/app/src/main/res/drawable/ic_queue_add.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 00000000..8704ce8c --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_remove.xml b/app/src/main/res/drawable/ic_remove.xml new file mode 100644 index 00000000..0ac6c716 --- /dev/null +++ b/app/src/main/res/drawable/ic_remove.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_remove_white_8dp.xml b/app/src/main/res/drawable/ic_remove_white_8dp.xml new file mode 100644 index 00000000..fa90362f --- /dev/null +++ b/app/src/main/res/drawable/ic_remove_white_8dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_repeat.xml b/app/src/main/res/drawable/ic_repeat.xml new file mode 100644 index 00000000..6eaf6711 --- /dev/null +++ b/app/src/main/res/drawable/ic_repeat.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_replay.xml b/app/src/main/res/drawable/ic_replay.xml new file mode 100644 index 00000000..1667ee5b --- /dev/null +++ b/app/src/main/res/drawable/ic_replay.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_rewind.xml b/app/src/main/res/drawable/ic_rewind.xml new file mode 100644 index 00000000..94a30226 --- /dev/null +++ b/app/src/main/res/drawable/ic_rewind.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_rewind_animated.xml b/app/src/main/res/drawable/ic_rewind_animated.xml new file mode 100644 index 00000000..6e01f01e --- /dev/null +++ b/app/src/main/res/drawable/ic_rewind_animated.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_save.xml b/app/src/main/res/drawable/ic_save.xml new file mode 100644 index 00000000..cca193ae --- /dev/null +++ b/app/src/main/res/drawable/ic_save.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_schedule.xml b/app/src/main/res/drawable/ic_schedule.xml new file mode 100644 index 00000000..d1e88768 --- /dev/null +++ b/app/src/main/res/drawable/ic_schedule.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_screen_lock_rotation.xml b/app/src/main/res/drawable/ic_screen_lock_rotation.xml new file mode 100644 index 00000000..908a0fd2 --- /dev/null +++ b/app/src/main/res/drawable/ic_screen_lock_rotation.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_screen_rotation.xml b/app/src/main/res/drawable/ic_screen_rotation.xml new file mode 100644 index 00000000..c29250d5 --- /dev/null +++ b/app/src/main/res/drawable/ic_screen_rotation.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_screen_share.xml b/app/src/main/res/drawable/ic_screen_share.xml new file mode 100644 index 00000000..753fd3cc --- /dev/null +++ b/app/src/main/res/drawable/ic_screen_share.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 00000000..33324ba8 --- /dev/null +++ b/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_search_300w.xml b/app/src/main/res/drawable/ic_search_300w.xml new file mode 100644 index 00000000..c8c8f09e --- /dev/null +++ b/app/src/main/res/drawable/ic_search_300w.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_search_thin.xml b/app/src/main/res/drawable/ic_search_thin.xml new file mode 100644 index 00000000..33324ba8 --- /dev/null +++ b/app/src/main/res/drawable/ic_search_thin.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_search_white_16dp.xml b/app/src/main/res/drawable/ic_search_white_16dp.xml new file mode 100644 index 00000000..2406148d --- /dev/null +++ b/app/src/main/res/drawable/ic_search_white_16dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_search_white_20dp.xml b/app/src/main/res/drawable/ic_search_white_20dp.xml new file mode 100644 index 00000000..391a626c --- /dev/null +++ b/app/src/main/res/drawable/ic_search_white_20dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_search_white_25dp.xml b/app/src/main/res/drawable/ic_search_white_25dp.xml new file mode 100644 index 00000000..321be04e --- /dev/null +++ b/app/src/main/res/drawable/ic_search_white_25dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_security.xml b/app/src/main/res/drawable/ic_security.xml new file mode 100644 index 00000000..1cd5d515 --- /dev/null +++ b/app/src/main/res/drawable/ic_security.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_security_pred.xml b/app/src/main/res/drawable/ic_security_pred.xml new file mode 100644 index 00000000..409f286a --- /dev/null +++ b/app/src/main/res/drawable/ic_security_pred.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_security_red.xml b/app/src/main/res/drawable/ic_security_red.xml new file mode 100644 index 00000000..390a51aa --- /dev/null +++ b/app/src/main/res/drawable/ic_security_red.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 00000000..274c3eda --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings_29dp.xml b/app/src/main/res/drawable/ic_settings_29dp.xml new file mode 100644 index 00000000..aa3b3eb3 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings_29dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 00000000..f4721095 --- /dev/null +++ b/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_shuffle.xml b/app/src/main/res/drawable/ic_shuffle.xml new file mode 100644 index 00000000..098d5654 --- /dev/null +++ b/app/src/main/res/drawable/ic_shuffle.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_smart_display.xml b/app/src/main/res/drawable/ic_smart_display.xml new file mode 100644 index 00000000..68758978 --- /dev/null +++ b/app/src/main/res/drawable/ic_smart_display.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_sources.xml b/app/src/main/res/drawable/ic_sources.xml new file mode 100644 index 00000000..bab6553f --- /dev/null +++ b/app/src/main/res/drawable/ic_sources.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_sources_active.xml b/app/src/main/res/drawable/ic_sources_active.xml new file mode 100644 index 00000000..16bb72be --- /dev/null +++ b/app/src/main/res/drawable/ic_sources_active.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_sources_filled.xml b/app/src/main/res/drawable/ic_sources_filled.xml new file mode 100644 index 00000000..be25d0ca --- /dev/null +++ b/app/src/main/res/drawable/ic_sources_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_sources_inactive.xml b/app/src/main/res/drawable/ic_sources_inactive.xml new file mode 100644 index 00000000..cf448d30 --- /dev/null +++ b/app/src/main/res/drawable/ic_sources_inactive.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_stop_notif.xml b/app/src/main/res/drawable/ic_stop_notif.xml new file mode 100644 index 00000000..719526e9 --- /dev/null +++ b/app/src/main/res/drawable/ic_stop_notif.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_store.xml b/app/src/main/res/drawable/ic_store.xml new file mode 100644 index 00000000..8212929a --- /dev/null +++ b/app/src/main/res/drawable/ic_store.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_store_200.xml b/app/src/main/res/drawable/ic_store_200.xml new file mode 100644 index 00000000..5ed83537 --- /dev/null +++ b/app/src/main/res/drawable/ic_store_200.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_subscriptions.xml b/app/src/main/res/drawable/ic_subscriptions.xml new file mode 100644 index 00000000..5e43e1bf --- /dev/null +++ b/app/src/main/res/drawable/ic_subscriptions.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_subscriptions_active_25dp.xml b/app/src/main/res/drawable/ic_subscriptions_active_25dp.xml new file mode 100644 index 00000000..3fcb8c3a --- /dev/null +++ b/app/src/main/res/drawable/ic_subscriptions_active_25dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_subscriptions_filled.xml b/app/src/main/res/drawable/ic_subscriptions_filled.xml new file mode 100644 index 00000000..7bdfd6b4 --- /dev/null +++ b/app/src/main/res/drawable/ic_subscriptions_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_subscriptions_inactive_25dp.xml b/app/src/main/res/drawable/ic_subscriptions_inactive_25dp.xml new file mode 100644 index 00000000..1b359e9e --- /dev/null +++ b/app/src/main/res/drawable/ic_subscriptions_inactive_25dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_thumb_down.xml b/app/src/main/res/drawable/ic_thumb_down.xml new file mode 100644 index 00000000..8de5f492 --- /dev/null +++ b/app/src/main/res/drawable/ic_thumb_down.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_thumb_up.xml b/app/src/main/res/drawable/ic_thumb_up.xml new file mode 100644 index 00000000..fdcf53d4 --- /dev/null +++ b/app/src/main/res/drawable/ic_thumb_up.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_trash.xml b/app/src/main/res/drawable/ic_trash.xml new file mode 100644 index 00000000..d9ba29ee --- /dev/null +++ b/app/src/main/res/drawable/ic_trash.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_trash_18dp.xml b/app/src/main/res/drawable/ic_trash_18dp.xml new file mode 100644 index 00000000..c10d6930 --- /dev/null +++ b/app/src/main/res/drawable/ic_trash_18dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_tune_300.xml b/app/src/main/res/drawable/ic_tune_300.xml new file mode 100644 index 00000000..f41b20d6 --- /dev/null +++ b/app/src/main/res/drawable/ic_tune_300.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_update.xml b/app/src/main/res/drawable/ic_update.xml new file mode 100644 index 00000000..132729bd --- /dev/null +++ b/app/src/main/res/drawable/ic_update.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_update_animated.xml b/app/src/main/res/drawable/ic_update_animated.xml new file mode 100644 index 00000000..1c35237f --- /dev/null +++ b/app/src/main/res/drawable/ic_update_animated.xml @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_update_fail_251dp.xml b/app/src/main/res/drawable/ic_update_fail_251dp.xml new file mode 100644 index 00000000..50c7784c --- /dev/null +++ b/app/src/main/res/drawable/ic_update_fail_251dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_update_success_251dp.xml b/app/src/main/res/drawable/ic_update_success_251dp.xml new file mode 100644 index 00000000..9b10800f --- /dev/null +++ b/app/src/main/res/drawable/ic_update_success_251dp.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable/ic_view_queue.xml b/app/src/main/res/drawable/ic_view_queue.xml new file mode 100644 index 00000000..baa532a4 --- /dev/null +++ b/app/src/main/res/drawable/ic_view_queue.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_visibility_off.xml b/app/src/main/res/drawable/ic_visibility_off.xml new file mode 100644 index 00000000..788c4520 --- /dev/null +++ b/app/src/main/res/drawable/ic_visibility_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_volume_off.xml b/app/src/main/res/drawable/ic_volume_off.xml new file mode 100644 index 00000000..6cfb3622 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_off.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_volume_up.xml b/app/src/main/res/drawable/ic_volume_up.xml new file mode 100644 index 00000000..e80c216b --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_up.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_volume_up_1.xml b/app/src/main/res/drawable/ic_volume_up_1.xml new file mode 100644 index 00000000..c641bd5d --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_up_1.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_watchlist_add.xml b/app/src/main/res/drawable/ic_watchlist_add.xml new file mode 100644 index 00000000..e33063d5 --- /dev/null +++ b/app/src/main/res/drawable/ic_watchlist_add.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_web_white.xml b/app/src/main/res/drawable/ic_web_white.xml new file mode 100644 index 00000000..629fd413 --- /dev/null +++ b/app/src/main/res/drawable/ic_web_white.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_wrench.xml b/app/src/main/res/drawable/ic_wrench.xml new file mode 100644 index 00000000..2dcefe07 --- /dev/null +++ b/app/src/main/res/drawable/ic_wrench.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/neopass.png b/app/src/main/res/drawable/neopass.png new file mode 100644 index 00000000..1ce49de1 Binary files /dev/null and b/app/src/main/res/drawable/neopass.png differ diff --git a/app/src/main/res/drawable/neopass_button.png b/app/src/main/res/drawable/neopass_button.png new file mode 100644 index 00000000..336a8b52 Binary files /dev/null and b/app/src/main/res/drawable/neopass_button.png differ diff --git a/app/src/main/res/drawable/placeholder_profile.xml b/app/src/main/res/drawable/placeholder_profile.xml new file mode 100644 index 00000000..178c5cb9 --- /dev/null +++ b/app/src/main/res/drawable/placeholder_profile.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/player_progress.xml b/app/src/main/res/drawable/player_progress.xml new file mode 100644 index 00000000..946119b2 --- /dev/null +++ b/app/src/main/res/drawable/player_progress.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/player_seekbar_style.xml b/app/src/main/res/drawable/player_seekbar_style.xml new file mode 100644 index 00000000..de717fa3 --- /dev/null +++ b/app/src/main/res/drawable/player_seekbar_style.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/player_thumb.xml b/app/src/main/res/drawable/player_thumb.xml new file mode 100644 index 00000000..5f6518f3 --- /dev/null +++ b/app/src/main/res/drawable/player_thumb.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_outline.xml b/app/src/main/res/drawable/rounded_outline.xml new file mode 100644 index 00000000..86dda574 --- /dev/null +++ b/app/src/main/res/drawable/rounded_outline.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_square_outline.xml b/app/src/main/res/drawable/rounded_square_outline.xml new file mode 100644 index 00000000..aa6e8fd0 --- /dev/null +++ b/app/src/main/res/drawable/rounded_square_outline.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/stripe.png b/app/src/main/res/drawable/stripe.png new file mode 100644 index 00000000..1a7ec0d3 Binary files /dev/null and b/app/src/main/res/drawable/stripe.png differ diff --git a/app/src/main/res/drawable/tab_border.xml b/app/src/main/res/drawable/tab_border.xml new file mode 100644 index 00000000..590864e9 --- /dev/null +++ b/app/src/main/res/drawable/tab_border.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/toggle.xml b/app/src/main/res/drawable/toggle.xml new file mode 100644 index 00000000..53519ee6 --- /dev/null +++ b/app/src/main/res/drawable/toggle.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/toggle_animated.xml b/app/src/main/res/drawable/toggle_animated.xml new file mode 100644 index 00000000..fa2bee89 --- /dev/null +++ b/app/src/main/res/drawable/toggle_animated.xml @@ -0,0 +1,15 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/toggle_animated_reverse.xml b/app/src/main/res/drawable/toggle_animated_reverse.xml new file mode 100644 index 00000000..07421ea8 --- /dev/null +++ b/app/src/main/res/drawable/toggle_animated_reverse.xml @@ -0,0 +1,15 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/toggle_disabled.xml b/app/src/main/res/drawable/toggle_disabled.xml new file mode 100644 index 00000000..e773a8c8 --- /dev/null +++ b/app/src/main/res/drawable/toggle_disabled.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/toggle_enabled.xml b/app/src/main/res/drawable/toggle_enabled.xml new file mode 100644 index 00000000..ba96eda4 --- /dev/null +++ b/app/src/main/res/drawable/toggle_enabled.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/video_thumbnail_outline.xml b/app/src/main/res/drawable/video_thumbnail_outline.xml new file mode 100644 index 00000000..ba0de6f8 --- /dev/null +++ b/app/src/main/res/drawable/video_thumbnail_outline.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/font/inter_black.ttf b/app/src/main/res/font/inter_black.ttf new file mode 100644 index 00000000..5aecf7dc Binary files /dev/null and b/app/src/main/res/font/inter_black.ttf differ diff --git a/app/src/main/res/font/inter_bold.ttf b/app/src/main/res/font/inter_bold.ttf new file mode 100644 index 00000000..8e82c70d Binary files /dev/null and b/app/src/main/res/font/inter_bold.ttf differ diff --git a/app/src/main/res/font/inter_extra_bold.ttf b/app/src/main/res/font/inter_extra_bold.ttf new file mode 100644 index 00000000..cb4b8217 Binary files /dev/null and b/app/src/main/res/font/inter_extra_bold.ttf differ diff --git a/app/src/main/res/font/inter_extra_light.ttf b/app/src/main/res/font/inter_extra_light.ttf new file mode 100644 index 00000000..64aee30a Binary files /dev/null and b/app/src/main/res/font/inter_extra_light.ttf differ diff --git a/app/src/main/res/font/inter_light.ttf b/app/src/main/res/font/inter_light.ttf new file mode 100644 index 00000000..9e265d89 Binary files /dev/null and b/app/src/main/res/font/inter_light.ttf differ diff --git a/app/src/main/res/font/inter_medium.ttf b/app/src/main/res/font/inter_medium.ttf new file mode 100644 index 00000000..b53fb1c4 Binary files /dev/null and b/app/src/main/res/font/inter_medium.ttf differ diff --git a/app/src/main/res/font/inter_regular.ttf b/app/src/main/res/font/inter_regular.ttf new file mode 100644 index 00000000..8d4eebf2 Binary files /dev/null and b/app/src/main/res/font/inter_regular.ttf differ diff --git a/app/src/main/res/font/inter_semibold.ttf b/app/src/main/res/font/inter_semibold.ttf new file mode 100644 index 00000000..c6aeeb16 Binary files /dev/null and b/app/src/main/res/font/inter_semibold.ttf differ diff --git a/app/src/main/res/font/inter_thin.ttf b/app/src/main/res/font/inter_thin.ttf new file mode 100644 index 00000000..7aed55d5 Binary files /dev/null and b/app/src/main/res/font/inter_thin.ttf differ diff --git a/app/src/main/res/layout/activity_add_source.xml b/app/src/main/res/layout/activity_add_source.xml new file mode 100644 index 00000000..ad96c703 --- /dev/null +++ b/app/src/main/res/layout/activity_add_source.xml @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +