Initial source commit.

This commit is contained in:
Koen 2023-09-25 17:18:43 +02:00
parent f37edf0595
commit 5b815f9c16
1031 changed files with 74881 additions and 0 deletions

36
.gitlab-ci.yml Normal file
View file

@ -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

63
.gitmodules vendored Normal file
View file

@ -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

60
CONTRIBUTION.md Normal file
View file

@ -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!

205
README.md Normal file
View file

@ -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.
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/video.jpg" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/video-details.jpg" height="700" /></b></td>
</tr>
<tr>
<td>Video</td>
<td>Video (details)</td>
</tr>
</table>
## 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.
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/sources.jpg" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/sources-disabled.jpg" height="700" /></b></td>
</tr>
<tr>
<td>Sources (all enabled)</td>
<td>Sources (one disabled)</td>
</tr>
</table>
Additional sources can also be installed. These sources are JavaScript sources, created and maintained by the community.
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/source-install.png" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/source-settings.jpg" height="700" /></b></td>
</tr>
<tr>
<td>Install a new source</td>
<td>Configure a source</td>
</tr>
</table>
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.
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/search-list.jpg" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/search-preview.jpg" height="700" /></b></td>
</tr>
<tr>
<td>Search (list)</td>
<td>Search (preview)</td>
</tr>
</table>
### 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.
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/channel.jpg" height="700" /></b></td>
</tr>
<tr>
<td>Channel</td>
</tr>
</table>
### 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.
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/creators.png" height="700" /></b></td>
</tr>
<tr>
<td>Creators</td>
</tr>
</table>
When you subscribe to a creator, you'll be able to find new videos uploaded by them in the subscriptions tab.
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/subscriptions-list.png" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/subscriptions-preview.png" height="700" /></b></td>
</tr>
<tr>
<td>Subscriptions (list)</td>
<td>Subscriptions (preview)</td>
</tr>
</table>
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.
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/settings.jpg" height="700" /></b></td>
</tr>
<tr>
<td>Settings</td>
</tr>
</table>
### 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.
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/playlists.jpg" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/playlist.jpg" height="700" /></b></td>
</tr>
<tr>
<td>Playlists</td>
<td>Playlist</td>
</tr>
</table>
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.
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/downloads.jpg" height="700" /></b></td>
</tr>
<tr>
<td>Downloads</td>
</tr>
</table>
### 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).
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/casting.jpg" height="700" /></b></td>
</tr>
<tr>
<td>Casting</td>
</tr>
</table>
### 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).

1
app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

207
app/build.gradle Normal file
View file

@ -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'
}

21
app/proguard-rules.pro vendored Normal file
View file

@ -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

View file

@ -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]);
}
}
}

View file

@ -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<Settings>(serializedSettingsString));
}
}

View file

@ -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";
}
}

View file

@ -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");
}*/
}

View file

@ -0,0 +1,187 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.FutoVideo"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="@string/authority"
android:exported="false"
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
</provider>
<service android:name=".services.MediaPlaybackService"
android:enabled="true"
android:foregroundServiceType="mediaPlayback" />
<service android:name=".services.DownloadService"
android:enabled="true" />
<service android:name=".services.ExportingService"
android:enabled="true" />
<receiver android:name=".receivers.MediaControlReceiver" />
<activity
android:name=".activities.MainActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout"
android:exported="true"
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar"
android:launchMode="singleTask"
android:resizeableActivity="true"
android:supportsPictureInPicture="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="grayjay" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter android:autoVerify="true">
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.VIEW" />
<data android:host="*" />
<data android:scheme="file" />
<data android:mimeType="application/json" />
</intent-filter>
<intent-filter android:autoVerify="true">
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.VIEW" />
<data android:host="*" />
<data android:scheme="content" />
<data android:mimeType="application/json" />
</intent-filter>
<intent-filter android:autoVerify="true">
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.VIEW" />
<data android:host="*" />
<data android:scheme="file" />
<data android:mimeType="application/zip" />
</intent-filter>
<intent-filter android:autoVerify="true">
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.VIEW" />
<data android:host="*" />
<data android:scheme="content" />
<data android:mimeType="application/zip" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="polycentric" />
</intent-filter>
</activity>
<activity
android:name=".activities.TestActivity"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.SettingsActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.DeveloperActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.ExceptionActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.LoginActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.AddSourceActivity"
android:screenOrientation="portrait"
android:exported="true"
android:theme="@style/Theme.FutoVideo.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="vfuto" />
</intent-filter>
</activity>
<activity
android:name=".activities.AddSourceOptionsActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.PolycentricHomeActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.PolycentricBackupActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.PolycentricCreateProfileActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.PolycentricProfileActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.PolycentricWhyActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.PolycentricImportProfileActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.ManageTabsActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.AddSourceOptionsActivity$QRCaptureActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
</application>
</manifest>

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="92" height="24" viewBox="0 0 92 24" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M91.6364 12C91.6364 18.6274 86.267 24 79.6437 24C73.0203 24 67.651 18.6274 67.651 12C67.651 5.37258 73.0203 0 79.6437 0C86.267 0 91.6364 5.37258 91.6364 12ZM76.1504 14.4215C74.9204 13.1908 74.3054 12.5754 74.3054 11.8107C74.3054 11.046 74.9204 10.4306 76.1504 9.19985L77.1526 8.1968C78.3826 6.96603 78.9979 6.35065 79.7621 6.35065C80.5262 6.35065 81.1412 6.96603 82.3712 8.1968L83.3737 9.19985C84.6037 10.4306 85.2187 11.046 85.2187 11.8107C85.2187 12.5754 84.6037 13.1908 83.3737 14.4215L82.3712 15.4246C81.1412 16.6554 80.5262 17.2707 79.7621 17.2707C78.9979 17.2707 78.3826 16.6554 77.1526 15.4246L76.1504 14.4215ZM16.9128 7.07692C17.2524 7.07692 17.5278 6.80142 17.5278 6.46154V1.84615C17.5278 1.50629 17.2524 1.23077 16.9128 1.23077H0.615009C0.275349 1.23077 0 1.50629 0 1.84615V22.1538C0 22.4937 0.275349 22.7692 0.615009 22.7692H6.15009C6.48976 22.7692 6.7651 22.4937 6.7651 22.1538V16.4923C6.7651 16.1524 7.04044 15.8769 7.38011 15.8769H14.8217C15.1614 15.8769 15.4367 15.6014 15.4367 15.2615V10.6462C15.4367 10.3063 15.1614 10.0308 14.8217 10.0308H7.38011C7.04044 10.0308 6.7651 9.75526 6.7651 9.41539V7.69231C6.7651 7.35243 7.04044 7.07692 7.38011 7.07692H16.9128ZM31.2092 23.1385H31.3015C37.8821 23.1385 41.9104 19.6308 41.9104 12.9538V1.84615C41.9104 1.50629 41.6352 1.23077 41.2954 1.23077H35.7603C35.4208 1.23077 35.1453 1.50629 35.1453 1.84615V12.3385C35.1453 14.6154 34.1613 16.6154 31.3015 16.6154H31.2092C28.3803 16.6154 27.3655 14.6154 27.3655 12.3385V1.84615C27.3655 1.50629 27.0902 1.23077 26.7505 1.23077H21.2154C20.8757 1.23077 20.6004 1.50629 20.6004 1.84615V12.9538C20.6004 19.6308 24.6287 23.1385 31.2092 23.1385ZM44.9845 1.84615C44.9845 1.50629 45.2597 1.23077 45.5995 1.23077H65.4643C65.8041 1.23077 66.0793 1.50629 66.0793 1.84615V6.55385C66.0793 6.89372 65.8041 7.16923 65.4643 7.16923H59.5295C59.1897 7.16923 58.9145 7.44474 58.9145 7.78462V22.1538C58.9145 22.4937 58.6393 22.7692 58.2995 22.7692H52.7644C52.4246 22.7692 52.1494 22.4937 52.1494 22.1538V7.78462C52.1494 7.44474 51.8742 7.16923 51.5344 7.16923H45.5995C45.2597 7.16923 44.9845 6.89372 44.9845 6.55385V1.84615Z" fill="#404040"></path></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -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);
}

View file

@ -0,0 +1,9 @@
<html>
<head>
</head>
<body>
<script src="/source.js"></script>
<script src="/dev_bridge.js"></script>
</body>
</html>

View file

@ -0,0 +1,897 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@48,400,0,0" />
<link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/@mdi/font@6.x/css/materialdesignicons.min.css" rel="stylesheet">
<!--<link href="./dependencies/vuetify.min.css" rel="stylesheet">-->
<link href="https://cdn.jsdelivr.net/npm/vuetify@2.7.1/dist/vuetify.min.css" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui">
<style>
html {
overflow: hidden;
}
#topMenu {
width: 100%;
height: 70px;
background-color: black;
}
.menuTab {
display: inline-block;
height: 100%;
padding-left: 20px;
padding-right: 20px;
vertical-align: top;
line-height: 70px;
color: #AAAAAA;
cursor: pointer;
}
.menuTab.active {
background-color: #140a4a;
color: white;
}
#mainContainer {
}
#mainContainer .page {
position: absolute;
top: 70px;
left: 0px;
width: 100%;
height: calc(100% - 70px);
overflow-y: auto;
}
.requestCard {
margin: 10px;
}
.requestCard .title {
margin-left: 10px;
}
.requestCard .description {
font-weight: lighter;
margin-left: 10px;
}
.requestCard .code {
font-weight: lighter;
margin-left: 10px;
background-color: rgba(0,0,0,.3);
font-family: consolas;
padding: 10px;
}
.requestCard .parameter {
position: relative;
width: 100%;
padding: 5px;
margin-bottom: 10px;
border-radius: 20px;
margin: 5px;
}
.requestCard .parameter .name {
position: absolute;
width: 100px;
margin-left: 10px;
text-align: right;
padding: 10px;
top: 0px;
left: 0px;
}
.requestCard .parameter .description {
top: 0px;
padding: 5px;
font-weight: lighter;
margin-left: 120px;
}
.requestCard .parameter input {
margin: 10px;
background-color: #444444;
margin-left: 120px;
color: white;
padding: 5px;
width: calc(100% - 140px);
}
.testResult {
height: calc(100% - 125px);
overflow-y: auto;
white-space: pre-wrap;
background-color: #222222;
padding: 10px;
font-family: consolas, "Courier New";
font-size: 10px;
}
.testResult.exception {
color: red;
}
.property {
font-weight: 300;
margin-bottom: 15px;
}
.property .key {
color: white;
}
.property .value {
color: #999999;
}
.logContainer {
background-color: rgba(0,0,0,.5);
border-radius: 30px;
height: 500px;
font-family: consolas;
font-size: 16px;
padding: 30px;
overflow-y: auto;
}
.logLine {
color: white;
}
.logLine.exception {
color: red;
}
.logLine.system {
color: blue;
}
.logType {
display: inline-block;
font-weight: lighter;
}
.logMsg {
display: inline-block;
font-weight: 300;
white-space: pre-wrap;
}
.pastPluginUrl {
margin-left: auto;
margin-right: auto;
width: 500px;
text-align: center;
margin-top: 10px;
margin-bottom: 10px;
padding: 10px;
background-color: #1e1e1e;
border-radius: 50px;
box-shadow: 0px 1px 2px #131313;
font-weight: lighter;
cursor: pointer;
}
</style>
</head>
<body>
<div id="app">
<v-app>
<v-main>
<div id="topMenu">
<div style="height: 100%; display: inline-block; padding-left: 10px; padding-right: 20px;">
<img src="./dependencies/FutoMainLogo.svg"
style="margin-top: 20px;" />
</div>
<div class="menuTab" :class="{ 'active': page == 'Plugin' }" @click="page = 'Plugin'">
Overview
</div>
<div class="menuTab" :class="{ 'active': page == 'Testing' }" @click="page = 'Testing'">
Testing
</div>
<div class="menuTab" :class="{ 'active': page == 'Integration' }" @click="page = 'Integration'">
Integration
</div>
<div class="menuTab" :class="{ 'active': page == 'Settings' }" @click="page = 'Settings'">
Settings
</div>
<div style="right: 370px; top: 15px; position: absolute" v-if="Plugin?.currentPlugin?.authentication">
<v-btn @click="loginTestPlugin()" v-if="!Plugin.isLoggedIn">
Login
</v-btn>
<v-btn @click="logoutTestPlugin()" v-if="Plugin.isLoggedIn">
Logout
</v-btn>
</div>
<img v-if="Plugin.currentPlugin"
:src="Plugin.currentPluginIcon"
style="right: 300px; top: 10px; width: 50px; width: 50px; position: absolute;" />
<div v-if="Plugin.currentPlugin" style="position: absolute; right: 100px; top: 12px; width: 180px;">
<div>
{{Plugin.currentPlugin.name}}
</div>
<div>
Last updated: {{Plugin.lastLoadTime}}
</div>
</div>
<v-btn class="mx-2" fab dark color="#140a4a" style="position: absolute; right: 10px; top: 5px;"
@click="reloadPlugin()" v-if="Plugin.currentPluginUrl">
<v-icon dark>mdi-refresh</v-icon>
</v-btn>
</div>
<div id="mainContainer">
<div class="page" v-if="page == 'Plugin'">
<div v-if="!Plugin.currentPlugin && !Plugin.currentScript">
<div style="margin-left: auto; margin-right: auto; width: 750px; vertical-align: top; padding-left: 40px;">
<v-card style="width: 450px; margin-top: 80px; display: inline-block;">
<v-card-text>
<div>
<v-text-field label="Plugin Config Json Url"
v-model="Plugin.newPluginUrl"></v-text-field>
<div style="margin-top: -10px;">
<v-switch v-model="Plugin.loadUsingTag"
label="Load using script tag"></v-switch>
<div style="font-size: 11px; margin-top: -20px; color: #888888">
Loading via script tag might give issues reloading script, as it makes the script part of DOM, but does allow debugging via dev console.
</div>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn @click="loadPlugin(Plugin.newPluginUrl)">Load Plugin</v-btn>
</v-card-actions>
</v-card>
<v-card style="width: 250px; margin-top: 80px; display: inline-block; vertical-align: top;">
<v-card-title>
Package Overrides
</v-card-title>
<v-card-text>
<div>
<div style="font-size: 12px; font-weight: lighter;">
Enabling a package override replaces the package with a browser implementation.
This generally improves speed, at the cost of test accuracy.
</div>
<div v-for="(value, name, index) in Plugin.packagesOverridden">
<v-switch v-model="Plugin.packagesOverridden[name]" :label="name" :change="saveOverrides()"></v-switch>
</div>
</div>
</v-card-text>
</v-card>
</div>
<div v-if="pastPluginUrls" style="margin-top: 60px;">
<h2 style="font-weight: lighter; text-align: center;">Past Plugins</h2>
<div class="pastPluginUrl" v-for="pastPluginUrl in pastPluginUrls" @click="this.Plugin.newPluginUrl = pastPluginUrl; loadPlugin(pastPluginUrl)">
{{pastPluginUrl}}
</div>
</div>
</div>
<v-card style="width: 500px; margin-left: auto; margin-right: auto; margin-top: 20px;"
v-if="Plugin.currentPluginError">
<v-card-text>
<div>
<h2 style="color: red">Errors in Plugin</h2>
{{Plugin.currentPluginError}}
</div>
</v-card-text>
</v-card>
<v-card style="width: 500px; margin-left: auto; margin-right: auto; margin-top: 20px;"
v-if="Plugin.currentPlugin && Plugin.currentScript">
<v-card-text>
<div>
<h2>Your plugin is loaded</h2>
You can now use testing methods available on the webapp. <br /> <br />
The information and warnings the user will see when installing the app can be viewed below.
</div>
</v-card-text>
</v-card>
<v-card style="width: 500px; margin-left: auto; margin-right: auto; margin-top: 20px;"
v-if="Plugin.currentPlugin && Plugin.currentScript">
<v-card-text>
<div>
<div style="height: 100px;">
<img :src="Plugin.currentPluginIcon"
style="width: 100px; position: absolute; top: 0px; left: 0px; margin: 10px;" />
<div style="position: absolute; right: 5px; top: 5px;">
Last updated: {{Plugin.lastLoadTime}}
</div>
<div style="width: calc(100% - 100px); height: 100px; position: absolute; top: 0px; right: 0px; padding-top: 40px; padding-left: 40px;">
<h2>{{Plugin.currentPlugin.name}}</h2>
<div>
<span>By </span>
<span v-if="!Plugin.currentPlugin.authorUrl">{{Plugin.currentPlugin.author}}</span>
<span v-if="Plugin.currentPlugin.authorUrl">
<a :href="Plugin.currentPlugin.authorUrl" style="text-decoration: none;">
{{Plugin.currentPlugin.author}}
</a>
</span>
</div>
</div>
</div>
<div style="color: #999999">
{{Plugin.currentPlugin.description}}
</div>
<div style="margin-top: 30px;">
<div class="property">
<div class="key">
Version
</div>
<div class="value">
{{Plugin.currentPlugin.version}}
</div>
</div>
<div class="property">
<div class="key">
Repository URL
</div>
<div class="value">
<a :href="Plugin.currentPlugin.scriptUrl">
{{Plugin.currentPlugin.repositoryUrl}}
</a>
</div>
</div>
<div class="property">
<div class="key">
Script URL
</div>
<div class="value">
<a :href="Plugin.currentPlugin.scriptUrl">
{{Plugin.currentPlugin.scriptUrl}}
</a>
</div>
</div>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<a href="/reference_autocomplete.js" download="ref.js" style="text-decoration: none; margin-right: 10px;">
<v-btn>Ref.js</v-btn>
</a>
<a href="/reference_plugin.d.ts" download="plugin.d.ts" style="text-decoration: none; margin-right: 10px;">
<v-btn>Plugin.d.ts</v-btn>
</a>
<v-btn @click="reloadPlugin()">Reload</v-btn>
</v-card-actions>
</v-card>
<div v-if="Plugin.warnings && Plugin.warnings.length > 0">
<h2 style="text-align: center; margin-top: 40px;">Warnings</h2>
<div style="text-align: center; color: #999999; font-size: 14px;">
These are the warnings a user will see when they attempt to install this plugin
</div>
<v-card style="width: 500px; margin-left: auto; margin-right: auto; margin-top: 20px; min-height: 130px;"
v-for="warning in Plugin.warnings">
<v-card-text>
<div>
<div class="material-symbols-outlined"
style="width: 100px; margin: 10px; color: rgb(194, 83, 83); font-size: 100px; position: absolute; top: 10px; left: 10px;">
security
</div>
<div style="margin-left: 120px;">
<div style="font-size: 18px;">
{{warning.first}}
</div>
<div style="color: #C25353">
{{warning.second}}
</div>
</div>
</div>
</v-card-text>
</v-card>
</div>
</div>
<div class="page" v-if="page == 'Testing'">
<v-card style="margin-top: 20px; margin-bottom: 20px; margin-left: auto; margin-right: auto; width: 500px;" v-if="!Plugin.currentPlugin">
<v-card-title>
No Plugin Loaded
</v-card-title>
</v-card-header>
<v-card-text>
<div>
Load a plugin before doing testing.
</div>
</v-card-text>
</v-card>
<div style="width: 50%" v-if="Plugin.currentPlugin">
<!--Get Home-->
<v-card class="requestCard" v-for="req in Testing.requests">
<v-card-text>
<div class="title">
<span v-if="req.isOptional">(Optional)</span>
<span>{{req.title}}</span>
</div>
<!--
<div v-if="req.title == 'enable'" style="position: absolute; top:3px; right: 18px">
<v-checkbox v-model="Plugin.enableOnReload" label="Enable on Refresh" />
</div> -->
<div class="description">
{{req.description}}
</div>
<div class="code">
{{req.code}}
</div>
<div>
<div class="parameter" v-for="parameter in req.parameters">
<div class="name">
{{parameter.name}}
</div>
<div class="description">
{{parameter.description}}
</div>
<input type="text" :placeholder="parameter.name + ' value'" v-model="parameter.value" />
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn @click="testSource(req)">
Test
</v-btn>
</v-card-actions>
</v-card>
</div>
<div style="position: fixed; right: 0px; top: 70px;width: 50%; height: 100%; background-color: black;" v-if="Plugin.currentPlugin">
<h2 style="padding: 10px; font-weight: lighter;">Results</h2>
<div style="position: absolute; top: 10px; right: 10px;">
<v-btn @click="copyClipboard(Testing.lastResult)" v-if="Testing.lastResult && !Testing.lastResultError">Copy</v-btn>
</div>
<div v-if="!Testing.lastResult && !Testing.lastResultError" style="padding: 10px; font-weight: lighter;">
No test done yet
</div>
<div v-if="Testing.lastResult && !Testing.lastResultError" class="testResult">{{Testing.lastResult}}</div>
<div v-if="Testing.lastResultError" class="testResult exception">{{Testing.lastResultError}}</div>
</div>
</div>
<div class="page" v-if="page == 'Integration'">
<v-card style="margin-top: 20px; margin-bottom: 20px; margin-left: auto; margin-right: auto; width: 500px;" v-if="!Plugin.currentPlugin">
<v-card-title>
No Plugin Loaded
</v-card-title>
</v-card-header>
<v-card-text>
<div>
Load a plugin before doing integration testing.
</div>
</v-card-text>
</v-card>
<v-card style="width: 500px; margin-left: auto; margin-right: auto; margin-top: 20px;"
v-if="Plugin.currentPluginError">
<v-card-text>
<div>
<h2 style="color: red">Errors in Plugin</h2>
Its best to fix errors before doing any integration testing
</div>
</v-card-text>
</v-card>
<v-card style="margin-top: 20px; margin-bottom: 20px; margin-left: auto; margin-right: auto; width: 500px;" v-if="Plugin.currentPlugin">
<v-card-title>
Integration Testing
</v-card-title>
</v-card-header>
<v-card-text>
<div>
<div style="margin-bottom: 10px;">
Integration testing allows you to upload your loaded plugin onto your phone, and get logs below to find any exceptions in actual usage.
</div>
<div v-if="Integration.lastInjectTime">
Last Injected: {{Integration.lastInjectTime}} <br />
Click Inject Plugin again to update to last version.
</div>
<div v-if="!Integration.lastInjectTime">
Plugin is not yet injected. Click "Inject Plugin" to load the plugin on your phone.
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn @click="injectDevPlugin()">Inject Plugin</v-btn>
</v-card-actions>
</v-card>
<v-card style="margin: 20px;" v-if="Plugin.currentPlugin">
<v-card-title>
Device Logs
</v-card-title>
</v-card-header>
<v-card-text>
<div class="logContainer">
<div class="logLine" v-for="line in Integration.logs" :class="{exception: line.type == 'EXCEPTION', system: line.type == 'SYSTEM'}">
<div class="logType" style="vertical-align: top;">
[{{line.type}}]
</div>
<div class="logMsg">{{line.log}}</div>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn>Clear</v-btn>
</v-card-actions>
</v-card>
</div>
<div class="page" v-if="page == 'Settings'">
<v-card style="margin-top: 20px; margin-bottom: 20px; margin-left: auto; margin-right: auto; width: 500px;">
<v-card-title>
Settings
</v-card-title>
</v-card-header>
<v-card-text>
<div>
<div style="height: 30px;">
<v-checkbox label="Enable on Reload" v-model="settings.enableOnReload"></v-checkbox>
</div>
<div style="height: 30px;">
<v-checkbox label="Login on Reload" v-model="settings.loginOnReload">></v-checkbox>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn @click="saveSettings()">Save</v-btn>
</v-card-actions>
</v-card>
</div>
</div>
</v-main>
</v-app>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.14/dist/vue.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vuetify@2.7.1/dist/vuetify.min.js"></script>
<!--<script src="./dependencies/vue.js"></script>-->
<!--<script src="./dependencies/vuetify.js"></script>-->
<script src="./source_docs.js"></script>
<script src="./source.js"></script>
<script src="./dev_bridge.js"></script>
<script>
IS_TESTING = true;
let lastScriptTag = null;
new Vue({
el: '#app',
data: {
page: "Plugin",
pastPluginUrls: [],
settings: {},
Integration: {
lastLogIndex: -1,
lastLogDevID: "",
logs: [],
lastInjectTime: ""
},
Plugin: {
loadUsingTag: false,
newPluginUrl: "",
packagesOverridden: packageOverridesEnabled,
currentPluginUrl: "",
currentPlugin: null,
currentPluginError: "",
currentScript: "",
lastLoadTime: "",
didInitialLoad: false,
enableOnReload: false,
isLoggedIn: false
},
Testing: {
requests: sourceDocs.map(x=>{
x.parameters.forEach(y=>y.value = null);
return x;
}),
lastResult: "",
lastResultError: ""
}
},
mounted() {
let existing = localStorage.getItem("pastPlugins");
if(!existing)
existing = [];
else
existing = JSON.parse(existing);
let settingsStr = localStorage.getItem("settings");
if(!settingsStr)
this.settings = {};
else
this.settings = JSON.parse(settingsStr);
this.pastPluginUrls = existing.slice(0, 5);
this.loadOverrides();
window.onerror = (event, source, lineno, colno, error)=>{
Vue.nextTick(()=>{
if(error)
this.Plugin.currentPluginError = error + " (" + lineno + ", " + colno + ")";
else
this.Plugin.currentPluginError = "There is an error in your script, check console for info";
});
};
setInterval(()=>{
try{
if(!this.Plugin.currentPlugin)
return;
getDevLogs(this.Integration.lastLogIndex, (newLogs)=> {
if(newLogs.length > 0) {
let firstLog = newLogs[0];
let lastLog = newLogs[newLogs.length - 1];
Vue.nextTick(()=>{
let lastDevId = this.Integration.lastLogDevID;
this.Integration.lastLogDevID = lastLog.devId;
this.Integration.lastLogIndex = lastLog.id;
for(i = 0; i < newLogs.length; i++) {
const log = newLogs[i];
console.log("Log", log);
if(lastDevId != log.devId) {
this.Integration.logs = [];
lastDevId = log.devId;
this.Integration.logs.unshift({
id: -1,
devId: lastDevId,
message: "New Dev Session: " + lastDevId
});
}
this.Integration.logs.unshift(log)
};
});
}
});
}
catch(ex) {
console.error("Failed update", ex);
}
}, 1000);
setInterval(()=>{
try{
this.isTestLoggedIn();
}catch(ex){}
}, 2500);
},
methods: {
loadOverrides() {
let overridesExisting = localStorage.getItem("overrides");
if(overridesExisting && overridesExisting != "undefined") {
try{
const overrides = JSON.parse(overridesExisting);
for(override in this.Plugin.packagesOverridden) {
if(overrides[override])
this.Plugin.packagesOverridden[override] = true;
}
}
catch(ex) {
console.error(ex);
}
}
this.Plugin.didInitialLoad = true;
},
saveOverrides() {
if(this.Plugin.packagesOverridden && this.Plugin.didInitialLoad) {
let overridesExisting = JSON.stringify(this.Plugin.packagesOverridden);
localStorage.setItem("overrides", overridesExisting);
}
},
loadPlugin(url) {
Vue.nextTick(()=>{
this.Plugin.currentPluginUrl = url;
this.reloadPlugin();
});
},
loginTestPlugin() {
pluginLoginTestPlugin();
setTimeout(()=>{
reloadPackages();
this.isTestLoggedIn((loggedIn)=>{
if(loggedIn && this.settings.enableOnReload)
this.testSource(this.Testing.requests.find(x=>x.title == 'enable'));
});
}, 1000);
},
logoutTestPlugin() {
pluginLogoutTestPlugin();
},
reloadPlugin() {
const url = this.Plugin.currentPluginUrl;
const pluginResp = httpGETBypass(url, {}, "text/json");
if(pluginResp.code != 200) {
alert("Failed to get plugin, check log")
console.error("Failed to get plugin", pluginResp);
}
else {
this.Plugin.currentPlugin = JSON.parse(pluginResp.body);
this.Plugin.currentPlugin.sourceUrl = url;
}
this.Plugin.currentPluginIcon = new URL(this.Plugin.currentPlugin.iconUrl, url).href
let currentPastPlugins = this.pastPluginUrls;
currentPastPlugins = currentPastPlugins.filter(x=>x.toLowerCase() != url.toLowerCase());
currentPastPlugins.unshift(url);
this.pastPluginUrls = currentPastPlugins;
localStorage.setItem("pastPlugins", JSON.stringify(currentPastPlugins));
try {
this.Plugin.warnings = pluginGetWarnings(this.Plugin.currentPlugin);
}
catch(ex) {
alert("Failed to validate config, check log")
console.error("Failed to validate config", ex);
return;
}
if(!this.Plugin.currentPlugin.scriptUrl) {
alert("Misssing plugin script, check log")
console.error("Failed to get plugin due to missing script");
}
let absScriptUrl = new URL(this.Plugin.currentPlugin.scriptUrl, url).href;
console.log("Loading script (Abs):" + absScriptUrl);
const scriptResp = httpGETBypass(absScriptUrl, {}, "application/js");
if(pluginResp.code != 200) {
alert("Failed to get plugin")
console.error("Failed to get plugin", pluginResp);
}
else {
this.Plugin.currentScript = scriptResp.body;
try{
//TODO: Load packages
const testPluginPackages = pluginUpdateTestPlugin(this.Plugin.currentPlugin);
console.log("Required packages:", testPluginPackages);
applyPackages(testPluginPackages);
if(this.Plugin.loadUsingTag)
{
this.Plugin.currentPluginError = ""
//Create script tag
const scriptUrl = absScriptUrl + "?x=" + new Date().getTime();
if(lastScriptTag)
lastScriptTag.parentNode.removeChild(lastScriptTag);
lastScriptTag = document.createElement('script');
lastScriptTag.src = scriptUrl;
lastScriptTag.crossorigin = "anonymous";
lastScriptTag.onerror = function() {
Vue.nextTick(()=>{
this.Plugin.currentPluginError = "Exception loading script: " + scriptUrl;
});
}
document.getElementsByTagName('body')[0].appendChild(lastScriptTag);
}
else
eval(this.Plugin.currentScript);
const date = new Date();
this.Plugin.lastLoadTime =
(date.getHours()+"").padStart(2, '0') + ":" +
(date.getMinutes()+"").padStart(2, '0') + ":" +
(date.getSeconds()+"").padStart(2, '0');
if(this.settings.loginOnReload) {
this.loginTestPlugin()
}
else if(this.settings.enableOnReload)
this.testSource(this.Testing.requests.find(x=>x.title == 'enable'));
}
catch(ex) {
alert("Failed to load plugin script, check log");
console.error("Failed to load plugin script", ex);
this.Plugin.currentPluginError = "Exception loading script:\n" + ex;
}
}
},
isTestLoggedIn(cb) {
pluginIsLoggedIn((isLoggedIn)=> {
Vue.nextTick(()=>{
const hasChanged = isLoggedIn != isLoggedIn;
this.Plugin.isLoggedIn = isLoggedIn;
if(hasChanged)
clearPackages();
if(cb)
cb(isLoggedIn);
});
}, (ex)=> {
if(cb)
cb(false);
});
},
injectDevPlugin() {
this.Integration.lastLogDevID = uploadDevPlugin(this.Plugin.currentPlugin);
this.Integration.logs = [
{
id: -1,
devId: this.Integration.lastLogDevID,
type: 'SYSTEM',
log: 'New Injected Session [' + this.Integration.lastLogDevID + ']'
}
];
const date = new Date();
this.Integration.lastInjectTime =
(date.getHours()+"").padStart(2, '0') + ":" +
(date.getMinutes()+"").padStart(2, '0') + ":" +
(date.getSeconds()+"").padStart(2, '0');
},
testSource(req) {
const name = req.title;
const parameterVals = req.parameters.map(x=>{
if(x.value && x.value.startsWith && x.value.startsWith("json:"))
return JSON.parse(x.value.substring(5));
return x.value
});
if(name == "enable") {
if(parameterVals.length > 0)
parameterVals[0] = this.Plugin.currentPlugin;
else
parameterVals.push(this.Plugin.currentPlugin);
if(parameterVals.length > 1)
parameterVals[1] = __DEV_SETTINGS;
else
parameterVals.push(__DEV_SETTINGS);
}
const func = source[name];
if(!func)
alert("Test func not found");
try {
const result = func(...parameterVals);
console.log("Result for " + req.title, result);
this.Testing.lastResult = "//Results [" + name + "]\n" +
JSON.stringify(result, null, 3);
this.Testing.lastResultError = "";
}
catch(ex) {
console.error("Failed to run test for " + req.title, ex);
this.Testing.lastResult = ""
if(ex.message)
this.Testing.lastResultError = "//Results [" + name + "]\n\n" +
"Error: " + ex.message + "\n\n" + ex.stack;
else
this.Testing.lastResultError = "//Results [" + name + "]\n\n" +
"Error: " + ex;
}
},
showTestResults(results) {
},
copyClipboard(cpy) {
if(navigator.clipboard)
navigator.clipboard.writeText(cpy);
else {
var textArea = document.createElement("textarea");
textArea.value = cpy;
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
} catch (err) { console.error('Failed to copy', err); }
document.body.removeChild(textArea);
}
},
saveSettings() {
let settingsStr = JSON.stringify(this.settings);
localStorage.setItem("settings", settingsStr);
}
},
vuetify: new Vuetify({
theme: {
dark: true
}
}),
});
function copyTextToClipboard(text) {
if (!navigator.clipboard) {
fallbackCopyTextToClipboard(text);
return;
}
navigator.clipboard.writeText(text).then(function() {
console.log('Async: Copying to clipboard was successful!');
}, function(err) {
console.error('Async: Could not copy text: ', err);
});
}
</script>
</body>
</html>

View file

@ -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<string, string>
}
interface IRequestModifierDef {
allowByteSkip: boolean
}
class RequestModifier {
constructor(obj: IRequestModifierDef) { }
modifyRequest(url: string, headers: Map<string, string>): 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<string>?
}
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<string>);
}
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<T> {
[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<Int, String>)
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;

View file

@ -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();

View file

@ -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<string,string>
}
}
//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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View file

@ -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 <reified T> Collection<Deferred<T>>.awaitFirst(): T?{
val tasks = this;
if (tasks.isEmpty()) {
return null;
}
var result: T? = null;
select<Boolean> {
tasks.forEach { def ->
def.onAwait {
result = it;
true;
}
}
}
return result;
}
suspend inline fun <reified T> Collection<Deferred<T>>.awaitFirstDeferred(): Pair<Deferred<T>, 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 <reified T> Collection<Deferred<T?>>.awaitFirstNotNullDeferred(): Pair<Deferred<T?>, 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;
}

View file

@ -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", "<br />"), HtmlCompat.FROM_HTML_MODE_LEGACY);
}
fun String.fixHtmlLinks(): Spanned {
//TODO: Properly fix whitespace handling.
val doc = Jsoup.parse(replace("\n", "<br />"));
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;
}

View file

@ -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<InetAddress>, 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<Socket> = arrayListOf();
for (i in addresses.indices) {
sockets.add(Socket());
}
val syncObject = Object();
var connectedSocket: Socket? = null;
val threads: ArrayList<Thread> = 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;
}

View file

@ -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) })
}

View file

@ -0,0 +1,15 @@
package com.futo.platformplayer
//Syntax sugaring
inline fun <reified T> Any.assume(): T?{
if(this is T)
return this;
else
return null;
}
inline fun <reified T, R> Any.assume(cb: (T) -> R): R? {
val result = this.assume<T>();
if(result != null)
return cb(result);
return null;
}

View file

@ -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 <R> 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 <R> 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 <reified T> 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 <reified T> V8ValueObject.getOrThrowNullable(config: IV8PluginConfig, key: String, contextName: String): T? = getOrThrow(config, key, contextName, true);
inline fun <reified T> V8ValueObject.getOrThrow(config: IV8PluginConfig, key: String, contextName: String, nullable: Boolean = false): T {
val value = this.get<V8Value>(key);
if(nullable)
return value.orNull { value.expectV8Variant<T>(config, "${contextName}.${key}") } as T
else
return value.expectV8Variant(config, "${contextName}.${key}");
}
inline fun <reified T> V8ValueObject.getOrNull(config: IV8PluginConfig, key: String, contextName: String): T? {
val value = this.get<V8Value>(key);
return value.orNull { value.expectV8Variant<T>(config, "${contextName}.${key}") };
}
inline fun <reified T> V8ValueObject.getOrDefault(config: IV8PluginConfig, key: String, contextName: String, default: T?): T? {
val value = this.get<V8Value>(key);
return value.orNull { value.expectV8Variant<T>(config, "${contextName}.${key}") } ?: default;
}
//Lists
inline fun <reified T> V8ValueObject.getOrThrowNullableList(config: IV8PluginConfig, key: String, contextName: String): List<T>? = getOrThrowList(config, key, contextName, true);
inline fun <reified T> V8ValueObject.getOrThrowList(config: IV8PluginConfig, key: String, contextName: String, nullable: Boolean = false): List<T> {
val value = this.get<V8Value>(key);
val array = if(nullable)
value.orNull { value.expectV8Variant<V8ValueArray>(config, "${contextName}.${key}") }
else
value.expectV8Variant<V8ValueArray>(config, "${contextName}.${key}");
if(array == null)
return listOf();
return array.expectV8Variants(config, contextName, false);
}
inline fun <reified T> V8ValueObject.getOrNullList(config: IV8PluginConfig, key: String, contextName: String): List<T>? {
val value = this.get<V8Value>(key);
val array = value.orNull { value.expectV8Variant<V8ValueArray>(config, "${contextName}.${key}") }
?: return null;
return array.expectV8Variants(config, contextName, true);
}
inline fun <reified T> V8ValueObject.getOrDefaultList(config: IV8PluginConfig, key: String, contextName: String, default: List<T>?): List<T>? {
val value = this.get<V8Value>(key);
val array = value.orNull { value.expectV8Variant<V8ValueArray>(config, "${contextName}.${key}") }
?: return default;
return array.expectV8Variants<T>(config, contextName, true);
}
inline fun <reified T> V8ValueArray.expectV8Variants(config: IV8PluginConfig, contextName: String, nullable: Boolean): List<T> {
val array = this;
if(nullable)
return array.keys
.map { Pair(it, array.get<V8Value>(it)) }
.map { kv-> kv.second.orNull { it.expectV8Variant<T>(config, contextName + "[${kv.first}]", ) } as T };
else
return array.keys
.map { Pair(it, array.get<V8Value>(it)) }
.map { kv-> kv.second.orNull { it.expectV8Variant<T>(config, contextName + "[${kv.first}]", ) } as T };
}
inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextName: String): T {
return when(T::class) {
String::class -> this.expectOrThrow<V8ValueString>(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<V8ValueInteger>(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<V8ValueLong>(config, contextName).value.toLong() as T
};
V8ValueObject::class -> this.expectOrThrow<V8ValueObject>(config, contextName) as T
V8ValueArray::class -> this.expectOrThrow<V8ValueArray>(config, contextName) as T;
Boolean::class -> this.expectOrThrow<V8ValueBoolean>(config, contextName).value as T;
Float::class -> this.expectOrThrow<V8ValueDouble>(config, contextName).value.toFloat() as T;
Double::class -> this.expectOrThrow<V8ValueDouble>(config, contextName).value as T;
HashMap::class -> this.expectOrThrow<V8ValueObject>(config, contextName).let { V8ObjectToHashMap(it) } as T;
Map::class -> this.expectOrThrow<V8ValueObject>(config, contextName).let { V8ObjectToHashMap(it) } as T;
List::class -> this.expectOrThrow<V8ValueArray>(config, contextName).let { V8ArrayToStringList(it) } as T;
else -> throw NotImplementedError("Type ${T::class.simpleName} not implemented conversion");
}
}
fun V8ArrayToStringList(obj: V8ValueArray): List<String> = obj.keys.map { obj.getString(it) };
fun V8ObjectToHashMap(obj: V8ValueObject?): HashMap<String, String> {
if(obj == null)
return hashMapOf();
val map = hashMapOf<String, String>();
for(prop in obj.ownPropertyNames.keys.map { obj.ownPropertyNames.get<V8Value>(it).toString() })
map.put(prop, obj.getString(prop));
return map;
}

View file

@ -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<MenuBottomBarSetting> = 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<Settings>();
}
fun replace(text: String) {
FragmentedStorage.replace<Settings>(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
}

View file

@ -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<V8ValueInteger>("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<V8ValueString>("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<IPlatformContent>? = 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<SourcePluginConfig>(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<SettingsDev>();
}
}
//endregion
}

View file

@ -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);
}
}
}

View file

@ -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<AlertDialog>();
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<Descriptor?>, 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<ImageView>(R.id.dialog_icon).apply {
this.setImageResource(icon);
}
view.findViewById<TextView>(R.id.dialog_text).apply {
this.text = text;
};
view.findViewById<TextView>(R.id.dialog_text_details).apply {
if(textDetails == null)
this.visibility = View.GONE;
else
this.text = textDetails;
};
view.findViewById<TextView>(R.id.dialog_text_code).apply {
if(code == null)
this.visibility = View.GONE;
else
this.text = code;
};
view.findViewById<LinearLayout>(R.id.dialog_buttons).apply {
val buttons = actions.map<Action, TextView> { 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<String>, 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<Action> = 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
}
}

View file

@ -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<View>();
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<View>();
var menu: SlideUpMenuOverlay? = null;
var targetPxSize: Long = 0;
var targetBitrate: Long = 0;
val resolutions = listOf(
Triple<String, String, Long>("None", "None", -1),
Triple<String, String, Long>("480P", "720x480", 720*480),
Triple<String, String, Long>("720P", "1280x720", 1280*720),
Triple<String, String, Long>("1080P", "1920x1080", 1920*1080),
Triple<String, String, Long>("1440P", "2560x1440", 2560*1440),
Triple<String, String, Long>("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<View>();
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<SlideUpMenuItem>();
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<View>();
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<SlideUpMenuItem>();
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<String>, filterValues: HashMap<String, List<String>>): SlideUpMenuFilters {
val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues);
overlay.show();
return overlay;
}
fun showMoreButtonOverlay(container: ViewGroup, buttonGroup: RoundButtonGroup, ignoreTags: List<Any> = listOf(), onPinnedbuttons: ((List<RoundButton>)->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<Pair<String, Any>>, onOrdered: (List<Any>)->Unit) {
val selection: MutableList<Any> = 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();
}
}
}

View file

@ -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);
}

View file

@ -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()
}
}

View file

@ -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() {
}
}

View file

@ -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)
}
}

View file

@ -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";
}
}

View file

@ -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<SourcePluginConfig>(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<SourcePluginAuthConfig>(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));
}
}
}

View file

@ -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<Pair<MainFragment, Any?>> = LinkedList();
lateinit var fragCurrent : MainFragment private set;
private var _parameterCurrent: Any? = null;
var fragBeforeOverlay : MainFragment? = null; private set;
val onNavigated = Event1<MainFragment>();
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<SubscriptionStorage>();
FragmentedStorage.get<Settings>();
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<out String>, 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<List<String>>(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<String>()
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 <reified T : Fragment> 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;
}
}
}

View file

@ -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<TabViewHolderData, TabViewHolder>;
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";
}
}

View file

@ -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<ImageButton>(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<Pair<SignedEvent, StorageTypeCRDTSetItem>>();
val crdtItems = arrayListOf<Pair<SignedEvent, StorageTypeCRDTItem>>();
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<SignedEvent>();
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";
}
}

View file

@ -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<ImageButton>(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;
}
};
}
}

View file

@ -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<ImageButton>(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<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
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";
}
}

View file

@ -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<ImageButton>(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";
}
}

View file

@ -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<ImageButton>(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";
}
}

View file

@ -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<ImageButton>(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";
}
}

View file

@ -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<ReadOnlyTextField>()?.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;
}
}
}

View file

@ -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";
}
}

View file

@ -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<String, String>? {
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<String, String> = 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<String, String> = HashMap<String, String>()) : Response {
return execute(Request(url, "GET", null, headers));
}
fun head(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
return execute(Request(url, "HEAD", null, headers));
}
fun post(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
return execute(Request(url, "POST", ByteArray(0), headers));
}
fun post(url : String, body : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
return post(url, body.toByteArray(), headers);
}
fun post(url : String, body : ByteArray, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
return execute(Request(url, "POST", body, headers));
}
fun requestMethod(method: String, url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
return execute(Request(url, method, null, headers));
}
fun requestMethod(method: String, url : String, body: String?, headers : MutableMap<String, String> = HashMap<String, String>()) : 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<String, String>;
val onCallCreated = Event1<Call>();
constructor(url : String, method : String, body : ByteArray?, headers : MutableMap<String, String> = HashMap<String, String>()) {
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<String, List<String>>;
val body : ResponseBody?;
val isOk : Boolean get() = code >= 200 && code < 300;
constructor(code : Int, url : String, msg : String, headers : Map<String, List<String>>, body : ResponseBody?) {
this.code = code;
this.url = url;
this.message = msg;
this.headers = headers;
this.body = body;
}
fun getHeader(key: String): List<String>? {
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<String, String> {
val map = HashMap<String, String>();
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";
}
}

View file

@ -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 = "");

View file

@ -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<String, String>();
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<String, String>) {
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 <reified T> 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 <reified T> 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",
);
}
}

View file

@ -0,0 +1,25 @@
package com.futo.platformplayer.api.http.server
class HttpHeaders : HashMap<String, String> {
constructor() : super(){}
constructor(vararg headers: Pair<String,String>) :
super(headers.map{ Pair(it.first.lowercase(), it.second) }.toMap()) { }
constructor(headers: Map<String,String>) :
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);
}
}

View file

@ -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<String, String>;
var status: Int = 0;
constructor(status: Int, headers: Map<String, String>) {
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",
);
}
}

View file

@ -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<HttpHandler>();
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<Method, HttpGET>(it, it.getAnnotation(HttpGET::class.java)!!) }
.toList();
val postMethods = obj::class.java.declaredMethods
.filter { it.getAnnotation(HttpPOST::class.java) != null }
.map { Pair<Method, HttpPOST>(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<Field, HttpGET>(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<InetAddress>) : String = getAddress(addresses.map { it.address }.toList());
fun getAddress(addresses: List<ByteArray> = 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<InetAddress> {
val addresses = arrayListOf<InetAddress>();
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";
}
}

View file

@ -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) {}

View file

@ -0,0 +1,3 @@
package com.futo.platformplayer.api.http.server.exceptions
class KeepAliveTimeoutException(msg: String, ex: Exception) : Exception(msg, ex) {}

View file

@ -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);
}
}

View file

@ -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")
}
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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<String>();
private val _injectRequestHeader = mutableListOf<Pair<String, String>>();
private val _ignoreResponseHeaders = mutableListOf<String>();
private var _injectHost = false;
private var _injectReferer = false;
private val _client = ManagedHttpClient();
override fun handle(context: HttpContext) {
val proxyHeaders = HashMap<String, String>();
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<String>) : HttpProxyHandler {
_ignoreRequestHeaders.addAll(ignored.map { it.lowercase() });
return this;
}
fun withIgnoredResponseHeaders(ignored: List<String>) : 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;
}
}

View file

@ -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<String, IPlatformContentDetails>;
override val capabilities: PlatformClientCapabilities
get() = _client.capabilities;
constructor(client : IPlatformClient, cacheSize : Int = 10 * 1024 * 1024) {
this._client = client;
this._cache = LruCache<String, IPlatformContentDetails>(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<String, List<String>>?
): IPager<IPlatformContent> = _client.getChannelContents(channelUrl);
override fun getChannelUrlByClaim(claimType: Int, claimValues: Map<Int, String>): String? = _client.getChannelUrlByClaim(claimType, claimValues)
override fun searchSuggestions(query: String): Array<String> = _client.searchSuggestions(query);
override fun getSearchCapabilities(): ResultCapabilities = _client.getSearchCapabilities();
override fun search(
query: String,
type: String?,
order: String?,
filters: Map<String, List<String>>?
): IPager<IPlatformContent> = _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<String, List<String>>?
): IPager<IPlatformContent> = _client.searchChannelContents(channelUrl, query, type, order, filters);
override fun searchChannels(query: String) = _client.searchChannels(query);
override fun getComments(url: String): IPager<IPlatformComment> = _client.getComments(url);
override fun getSubComments(comment: IPlatformComment): IPager<IPlatformComment> = _client.getSubComments(comment);
override fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor? = _client.getLiveChatWindow(url);
override fun getLiveEvents(url: String): IPager<IPlatformLiveEvent>? = _client.getLiveEvents(url);
override fun getHome(): IPager<IPlatformContent> = _client.getHome();
override fun getUserSubscriptions(): Array<String> { return arrayOf(); };
override fun searchPlaylists(query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = _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<String> { return arrayOf(); };
override fun isClaimTypeSupported(claimType: Int): Boolean {
return _client.isClaimTypeSupported(claimType);
}
}

View file

@ -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<IPlatformContent>
//Search
/**
* Gets search suggestion for the provided query string
*/
fun searchSuggestions(query: String): Array<String>;
/**
* 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<String, List<String>>? = null): IPager<IPlatformContent>;
/**
* 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<String, List<String>>? = null): IPager<IPlatformContent>;
/**
* Searches for channels and returns a channel pager
*/
fun searchChannels(query: String): IPager<PlatformAuthorLink>;
//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<String, List<String>>? = null): IPager<IPlatformContent>;
/**
* Gets the channel url associated with a claimType
*/
fun getChannelUrlByClaim(claimType: Int, claimValues: Map<Int, String>): 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<IPlatformComment>;
/**
* Gets the replies to a comment
*/
fun getSubComments(comment: IPlatformComment): IPager<IPlatformComment>;
/**
* Gets the live events of a livestream
*/
fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor?;
/**
* Gets the live events of a livestream
*/
fun getLiveEvents(url: String): IPager<IPlatformLiveEvent>?
//Playlists
/**
* Search for Playlists and returns a Playlist pager
*/
fun searchPlaylists(query: String, type: String? = null, order: String? = null, filters: Map<String, List<String>>? = null): IPager<IPlatformContent>;
/**
* 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<String>;
/**
* Retrieves the subscriptions of the currently logged in user
*/
fun getUserSubscriptions(): Array<String>;
fun isClaimTypeSupported(claimType: Int): Boolean;
}

View file

@ -0,0 +1,7 @@
package com.futo.platformplayer.api.media
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
interface IPluginSourced {
val sourceConfig: SourcePluginConfig;
}

View file

@ -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<IPlatformLiveEvent>?;
private val _history: ArrayList<IPlatformLiveEvent> = arrayListOf();
private var _startCounter = 0;
private val _followers: HashMap<Any, (List<IPlatformLiveEvent>) -> Unit> = hashMapOf();
var viewCount: Long = 0
private set;
constructor(scope: CoroutineScope, pager: IPager<IPlatformLiveEvent>, 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<IPlatformLiveEvent> {
synchronized(_history) {
return _history.toList();
}
}
fun follow(tag: Any, eventHandler: (List<IPlatformLiveEvent>) -> 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<IPlatformLiveEvent>) {
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<String, Drawable>(); //TODO: Replace with LRUCache
private val _cache_urls = HashMap<String, String>();
private val _client = ManagedHttpClient();
private val _download_drawable =
BatchedTaskHandler<String, Drawable?>(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];
}
}
}
}

View file

@ -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
) {
}

View file

@ -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<JSClient, Int> = hashMapOf();
private var _poolCounter = 0;
var isDead: Boolean = false
private set;
val onDead = Event2<JSClient, PlatformClientPool>();
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";
}
}

View file

@ -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<String>(config, "value", contextName),
config.id,
value.getOrDefault(config, "claimType", contextName, 0) ?: 0,
value.getOrDefault(config, "claimFieldType", contextName, -1) ?: -1);
}
}
}

View file

@ -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; };
}
}

View file

@ -0,0 +1,4 @@
package com.futo.platformplayer.api.media.exceptions
class APIRequestFailedException(msg : String) : IllegalStateException(msg) {
}

View file

@ -0,0 +1,5 @@
package com.futo.platformplayer.api.media.exceptions
class AlreadyQueuedException(message: String?) : Exception(message) {
}

View file

@ -0,0 +1,5 @@
package com.futo.platformplayer.api.media.exceptions
class ContentNotAvailableYetException(message: String?, val availableWhen: String) : Exception(message) {
}

View file

@ -0,0 +1,3 @@
package com.futo.platformplayer.api.media.exceptions
class NoPlatformClientException(s: String) : IllegalArgumentException("No enabled PlatformClient: $s") {}

View file

@ -0,0 +1,4 @@
package com.futo.platformplayer.api.media.exceptions
class NotFoundException(message: String?) : Exception(message) {
}

View file

@ -0,0 +1,4 @@
package com.futo.platformplayer.api.media.exceptions
class UnknownPlatformException(s : String) : IllegalArgumentException("Unknown platform type:$s") {
}

View file

@ -0,0 +1,5 @@
package com.futo.platformplayer.api.media.exceptions.search
class NoNextPageException(s: String? = null) : IllegalStateException("No next page available:$s") {
}

View file

@ -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<String>(config, "thumbnail", context, null),
if(value.has("subscribers")) value.getOrThrow(config,"subscribers", context) else null
);
}
}
}

View file

@ -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<String> = listOf(),
val sorts: List<String> = listOf(),
val filters: List<FilterGroup> = 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<V8ValueArray>(config, "types", contextName).toArray().map { it.expectV8Variant(config, "Capabilities.types") },
value.getOrThrow<V8ValueArray>(config, "sorts", contextName).toArray().map { it.expectV8Variant(config, "Capabilities.sorts"); },
value.getOrDefault<V8ValueArray>(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<FilterCapability> = 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<V8ValueArray>(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")
);
}
}
}

View file

@ -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<Thumbnail>;
constructor() { sources = arrayOf(); }
constructor(thumbnails : Array<Thumbnail>) {
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<V8ValueArray>(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"));
}
}
};

View file

@ -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<String, String>;
val urlAlternatives: List<String>;
fun getContents(client: IPlatformClient): IPager<IPlatformContent>;
}

View file

@ -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<String, String>,
override val urlAlternatives: List<String> = listOf()
) : IPlatformChannel {
fun toJson(): String {
return Json.encodeToString(this);
}
fun fromJson(str: String): SerializedChannel {
return Serializer.json.decodeFromString<SerializedChannel>(str);
}
fun fromJsonArray(str: String): Array<SerializedChannel> {
return Serializer.json.decodeFromString<Array<SerializedChannel>>(str);
}
override fun getContents(client: IPlatformClient): IPager<IPlatformContent> {
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
);
}
}
}

View file

@ -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<IPlatformComment>?;
}

View file

@ -0,0 +1,12 @@
package com.futo.platformplayer.api.media.models.comments
import com.futo.platformplayer.api.media.structures.IPager
class NoCommentsPager : IPager<IPlatformComment> {
override fun hasMorePages(): Boolean = false;
override fun nextPage() { }
override fun getResults(): List<IPlatformComment> {
return listOf();
}
}

View file

@ -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<IPlatformComment> {
return NoCommentsPager();
}
}

View file

@ -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<IPlatformComment> {
return NoCommentsPager();
}
fun cloneWithUpdatedReplyCount(replyCount: Int?): PolycentricPlatformComment {
return PolycentricPlatformComment(contextUrl, author, message, rating, date, reference, replyCount);
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}

View file

@ -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<IPlatformComment>?;
fun getPlaybackTracker(): IPlaybackTracker?;
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -0,0 +1,6 @@
package com.futo.platformplayer.api.media.models.live
interface ILiveChatWindowDescriptor {
val url: String;
val removeElements: List<String>;
}

View file

@ -0,0 +1,8 @@
package com.futo.platformplayer.api.media.models.live
interface ILiveEventChatMessage: IPlatformLiveEvent {
val name: String;
val thumbnail: String?;
val message: String;
}

View file

@ -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<Int>(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}");
}
}
}
}

View file

@ -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<String>;
constructor(name: String, thumbnail: String?, message: String, colorName: String? = null, badges: List<String>? = 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<String>(config, "colorName", contextName, null);
val badges = obj.getOrDefault<List<String>>(config, "badges", contextName, null);
return LiveEventComment(
obj.getOrThrow(config, "name", contextName),
obj.getOrThrow(config, "thumbnail", contextName, true),
obj.getOrThrow(config, "message", contextName),
colorName, badges);
}
}
}

View file

@ -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<String?>(config, "colorDonation", contextName, null));
}
}
}

View file

@ -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<String, String>;
constructor(emojis: HashMap<String, String>) {
this.emojis = emojis;
}
companion object {
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventEmojis {
val contextName = "LiveEventEmojis"
return LiveEventEmojis(
obj.getOrThrow(config, "emojis", contextName));
}
}
}

View file

@ -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));
}
}
}

View file

@ -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 };
}
}
}

View file

@ -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));
}
}
}

View file

@ -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;
}

Some files were not shown because too many files have changed in this diff Show more