mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-04-19 19:14:51 +00:00
Initial source commit.
This commit is contained in:
parent
f37edf0595
commit
5b815f9c16
1031 changed files with 74881 additions and 0 deletions
36
.gitlab-ci.yml
Normal file
36
.gitlab-ci.yml
Normal 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
63
.gitmodules
vendored
Normal 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
60
CONTRIBUTION.md
Normal 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
205
README.md
Normal 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
1
app/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/build
|
207
app/build.gradle
Normal file
207
app/build.gradle
Normal 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
21
app/proguard-rules.pro
vendored
Normal 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
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}*/
|
||||
}
|
187
app/src/main/AndroidManifest.xml
Normal file
187
app/src/main/AndroidManifest.xml
Normal 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>
|
|
@ -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 |
337
app/src/main/assets/devportal/dev_bridge.js
Normal file
337
app/src/main/assets/devportal/dev_bridge.js
Normal 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);
|
||||
}
|
9
app/src/main/assets/devportal/dev_test.html
Normal file
9
app/src/main/assets/devportal/dev_test.html
Normal file
|
@ -0,0 +1,9 @@
|
|||
<html>
|
||||
<head>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<script src="/source.js"></script>
|
||||
<script src="/dev_bridge.js"></script>
|
||||
</body>
|
||||
</html>
|
897
app/src/main/assets/devportal/index.html
Normal file
897
app/src/main/assets/devportal/index.html
Normal 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>
|
364
app/src/main/assets/devportal/plugin.d.ts
vendored
Normal file
364
app/src/main/assets/devportal/plugin.d.ts
vendored
Normal 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;
|
177
app/src/main/assets/scripts/polyfil.js
Normal file
177
app/src/main/assets/scripts/polyfil.js
Normal 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();
|
691
app/src/main/assets/scripts/source.js
Normal file
691
app/src/main/assets/scripts/source.js
Normal 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;
|
||||
}
|
||||
}
|
BIN
app/src/main/ic_launcher-playstore.png
Normal file
BIN
app/src/main/ic_launcher-playstore.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 40 KiB |
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
275
app/src/main/java/com/futo/platformplayer/Extensions_Network.kt
Normal file
275
app/src/main/java/com/futo/platformplayer/Extensions_Network.kt
Normal 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;
|
||||
}
|
|
@ -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) })
|
||||
}
|
|
@ -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;
|
||||
}
|
131
app/src/main/java/com/futo/platformplayer/Extensions_V8.kt
Normal file
131
app/src/main/java/com/futo/platformplayer/Extensions_V8.kt
Normal 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;
|
||||
}
|
606
app/src/main/java/com/futo/platformplayer/Settings.kt
Normal file
606
app/src/main/java/com/futo/platformplayer/Settings.kt
Normal 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
|
||||
}
|
334
app/src/main/java/com/futo/platformplayer/SettingsDev.kt
Normal file
334
app/src/main/java/com/futo/platformplayer/SettingsDev.kt
Normal 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
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
356
app/src/main/java/com/futo/platformplayer/UIDialogs.kt
Normal file
356
app/src/main/java/com/futo/platformplayer/UIDialogs.kt
Normal 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
|
||||
}
|
||||
}
|
419
app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt
Normal file
419
app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
159
app/src/main/java/com/futo/platformplayer/Utility.kt
Normal file
159
app/src/main/java/com/futo/platformplayer/Utility.kt
Normal 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);
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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() {
|
||||
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -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 = "");
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -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) {}
|
|
@ -0,0 +1,3 @@
|
|||
package com.futo.platformplayer.api.http.server.exceptions
|
||||
|
||||
class KeepAliveTimeoutException(msg: String, ex: Exception) : Exception(msg, ex) {}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package com.futo.platformplayer.api.media
|
||||
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
|
||||
interface IPluginSourced {
|
||||
val sourceConfig: SourcePluginConfig;
|
||||
}
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
) {
|
||||
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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; };
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
package com.futo.platformplayer.api.media.exceptions
|
||||
|
||||
class APIRequestFailedException(msg : String) : IllegalStateException(msg) {
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
package com.futo.platformplayer.api.media.exceptions
|
||||
|
||||
class AlreadyQueuedException(message: String?) : Exception(message) {
|
||||
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
package com.futo.platformplayer.api.media.exceptions
|
||||
|
||||
class ContentNotAvailableYetException(message: String?, val availableWhen: String) : Exception(message) {
|
||||
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
package com.futo.platformplayer.api.media.exceptions
|
||||
|
||||
class NoPlatformClientException(s: String) : IllegalArgumentException("No enabled PlatformClient: $s") {}
|
|
@ -0,0 +1,4 @@
|
|||
package com.futo.platformplayer.api.media.exceptions
|
||||
|
||||
class NotFoundException(message: String?) : Exception(message) {
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
package com.futo.platformplayer.api.media.exceptions
|
||||
|
||||
class UnknownPlatformException(s : String) : IllegalArgumentException("Unknown platform type:$s") {
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
package com.futo.platformplayer.api.media.exceptions.search
|
||||
|
||||
class NoNextPageException(s: String? = null) : IllegalStateException("No next page available:$s") {
|
||||
|
||||
}
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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>;
|
||||
}
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>?;
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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?;
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package com.futo.platformplayer.api.media.models.live
|
||||
|
||||
interface ILiveChatWindowDescriptor {
|
||||
val url: String;
|
||||
val removeElements: List<String>;
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package com.futo.platformplayer.api.media.models.live
|
||||
|
||||
interface ILiveEventChatMessage: IPlatformLiveEvent {
|
||||
|
||||
val name: String;
|
||||
val thumbnail: String?;
|
||||
val message: String;
|
||||
}
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
Loading…
Add table
Reference in a new issue