mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-29 11:03:01 +02:00
Compare commits
120 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 748551af2a | |||
| 9ce41bc8d0 | |||
| 8cf542e201 | |||
| 88950843b3 | |||
| 4a08058322 | |||
| 7b76ba1539 | |||
| 6492278e7d | |||
| 9de9440160 | |||
| 372af6cf47 | |||
| 29d08c8554 | |||
| cfeceabe5b | |||
| a51f609a92 | |||
| 15a655f196 | |||
| c6525f1caa | |||
| e147fdd77e | |||
| 6a8ac0bfaa | |||
| 772bff6bc0 | |||
| b6b04054b9 | |||
| 1ea794459c | |||
| c27f5e4096 | |||
| 8469f17b4c | |||
| 067abc415b | |||
| d692533f20 | |||
| 31a6ea0f39 | |||
| 5ba2f2be75 | |||
| 8e4ad54de1 | |||
| 6139696714 | |||
| 8536861e09 | |||
| 71262da3c2 | |||
| 60cd5976cc | |||
| 3ca6a1fd70 | |||
| 0d8c8de450 | |||
| 8ba2fe9972 | |||
| 7a7ef533cc | |||
| 5385549a43 | |||
| 04deffc66e | |||
| 852f563c9a | |||
| c84cea9ea1 | |||
| 5c162083d5 | |||
| 3230e7c0b4 | |||
| 8437825dd1 | |||
| 0fbe0bb438 | |||
| 34d2e62314 | |||
| 1075ded170 | |||
| 80bb15f3fb | |||
| 27a86a67f0 | |||
| 284b2a24f8 | |||
| 854d1506a6 | |||
| 811fd4e73e | |||
| 335988aa67 | |||
| 29a54fbed4 | |||
| 3a11d0d9d1 | |||
| bda534e485 | |||
| 09fd4c0881 | |||
| 1667866a35 | |||
| 035125d0f8 | |||
| 1bb0cdc405 | |||
| 86019c80a1 | |||
| 8c640d3def | |||
| 7ed1e8a28b | |||
| 3dcfe8c340 | |||
| 042ced81ef | |||
| b37f48380b | |||
| 0a02169782 | |||
| f12e4390f3 | |||
| 82ab45d04e | |||
| 7f77c39296 | |||
| 99eee4f6ee | |||
| 68886502d1 | |||
| 26461c21c4 | |||
| 300466f722 | |||
| 961710cc8b | |||
| eba995f87d | |||
| a67244e79a | |||
| 70502a7651 | |||
| 36b4f5b41d | |||
| def39ba397 | |||
| 49d59f4466 | |||
| 1c9becc2ba | |||
| 1cde591061 | |||
| 8ac18f053c | |||
| 56bdae9ff1 | |||
| 74ddfe9f0e | |||
| acb9500e2a | |||
| 45f621763a | |||
| 0abc65a9bd | |||
| 6d6309973e | |||
| 92ec085d25 | |||
| 767a8befaa | |||
| 09763320dd | |||
| 27fb2997f9 | |||
| 0f46bc5888 | |||
| dccf4fcf3c | |||
| da7fef1ecd | |||
| 58a89a00ef | |||
| f2efc603ba | |||
| efe074d272 | |||
| 8a9efd3a0f | |||
| 251302b9c3 | |||
| 5cdac1405e | |||
| 894e400819 | |||
| 565ea7cb8b | |||
| 9fa3e22d2e | |||
| 5548783337 | |||
| 0dca8798cb | |||
| d902306fe4 | |||
| baa2a4fcf3 | |||
| 8be7ad9f68 | |||
| 992cbcb3a0 | |||
| e0857aea9b | |||
| 50cd0723c9 | |||
| 4c4b322682 | |||
| 7cff8568c0 | |||
| 801c646a09 | |||
| df4ec87613 | |||
| b08a79b7cb | |||
| 396e9f9f43 | |||
| 0e5a87a911 | |||
| 64d72f6d10 | |||
| 09bc180d4f |
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:ea10d3c5562c9f449a4e89e9c3dfcf881ed79a952f3409bc005bcc62c2cf4b81
|
|
||||||
size 65512557
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:22c06ca0d1a5808b2fc0a12227d5915b3126bc0b9b1305cf6bab855f2ec6fcbb
|
||||||
|
size 36133152
|
||||||
+10
-9
@@ -181,16 +181,16 @@ dependencies {
|
|||||||
implementation 'com.google.code.gson:gson:2.13.2' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
|
implementation 'com.google.code.gson:gson:2.13.2' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
|
||||||
|
|
||||||
//JS
|
//JS
|
||||||
implementation 'com.caoccao.javet:javet-v8-android:5.0.1'
|
implementation 'com.caoccao.javet:javet-v8-android:4.1.5'
|
||||||
|
|
||||||
//Exoplayer
|
//Exoplayer
|
||||||
implementation 'androidx.media3:media3-exoplayer:1.8.0'
|
implementation 'androidx.media3:media3-exoplayer:1.9.0'
|
||||||
implementation 'androidx.media3:media3-exoplayer-dash:1.8.0'
|
implementation 'androidx.media3:media3-exoplayer-dash:1.9.0'
|
||||||
implementation 'androidx.media3:media3-ui:1.8.0'
|
implementation 'androidx.media3:media3-ui:1.9.0'
|
||||||
implementation 'androidx.media3:media3-exoplayer-hls:1.8.0'
|
implementation 'androidx.media3:media3-exoplayer-hls:1.9.0'
|
||||||
implementation 'androidx.media3:media3-exoplayer-rtsp:1.8.0'
|
implementation 'androidx.media3:media3-exoplayer-rtsp:1.9.0'
|
||||||
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.8.0'
|
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.9.0'
|
||||||
implementation 'androidx.media3:media3-transformer:1.8.0'
|
implementation 'androidx.media3:media3-transformer:1.9.0'
|
||||||
implementation 'androidx.navigation:navigation-fragment-ktx:2.9.6'
|
implementation 'androidx.navigation:navigation-fragment-ktx:2.9.6'
|
||||||
implementation 'androidx.navigation:navigation-ui-ktx:2.9.6'
|
implementation 'androidx.navigation:navigation-ui-ktx:2.9.6'
|
||||||
implementation 'androidx.media:media:1.7.1'
|
implementation 'androidx.media:media:1.7.1'
|
||||||
@@ -206,6 +206,7 @@ dependencies {
|
|||||||
implementation 'com.google.zxing:core:3.5.3'
|
implementation 'com.google.zxing:core:3.5.3'
|
||||||
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
||||||
implementation 'com.caverock:androidsvg-aar:1.4'
|
implementation 'com.caverock:androidsvg-aar:1.4'
|
||||||
|
implementation 'androidx.webkit:webkit:1.15.0'
|
||||||
|
|
||||||
//Protobuf
|
//Protobuf
|
||||||
implementation 'com.google.protobuf:protobuf-javalite:4.33.0'
|
implementation 'com.google.protobuf:protobuf-javalite:4.33.0'
|
||||||
@@ -232,7 +233,7 @@ dependencies {
|
|||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
|
||||||
|
|
||||||
//Rust casting SDK
|
//Rust casting SDK
|
||||||
implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.3.1') {
|
implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.4.0') {
|
||||||
// Polycentricandroid includes this
|
// Polycentricandroid includes this
|
||||||
exclude group: 'net.java.dev.jna'
|
exclude group: 'net.java.dev.jna'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.FutoVideo"
|
android:theme="@style/Theme.FutoVideo"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
|
tools:replace="android:enableOnBackInvokedCallback"
|
||||||
|
android:enableOnBackInvokedCallback="false"
|
||||||
tools:targetApi="31"
|
tools:targetApi="31"
|
||||||
android:largeHeap="true">
|
android:largeHeap="true">
|
||||||
<provider
|
<provider
|
||||||
@@ -58,9 +60,10 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.MainActivity"
|
android:name=".activities.MainActivity"
|
||||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
|
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
||||||
|
android:windowSoftInputMode="adjustPan"
|
||||||
android:launchMode="singleInstance"
|
android:launchMode="singleInstance"
|
||||||
android:resizeableActivity="true"
|
android:resizeableActivity="true"
|
||||||
android:supportsPictureInPicture="true">
|
android:supportsPictureInPicture="true">
|
||||||
@@ -249,5 +252,21 @@
|
|||||||
android:name=".activities.QRCodeFullscreenActivity"
|
android:name=".activities.QRCodeFullscreenActivity"
|
||||||
android:screenOrientation="sensorPortrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||||
|
<service
|
||||||
|
android:name=".UpdateDownloadService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".UpdateActionReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".activities.InstallUpdateActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@style/Theme.App.TransparentNoUi"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:finishOnTaskLaunch="true" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -415,6 +415,8 @@ class VideoUrlSource {
|
|||||||
this.url = obj.url;
|
this.url = obj.url;
|
||||||
if(obj.requestModifier)
|
if(obj.requestModifier)
|
||||||
this.requestModifier = obj.requestModifier;
|
this.requestModifier = obj.requestModifier;
|
||||||
|
this.language = obj?.language;
|
||||||
|
this.original = obj?.original;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
class VideoUrlWidevineSource extends VideoUrlSource {
|
class VideoUrlWidevineSource extends VideoUrlSource {
|
||||||
@@ -512,6 +514,8 @@ class HLSSource {
|
|||||||
this.language = obj.language;
|
this.language = obj.language;
|
||||||
if(obj.requestModifier)
|
if(obj.requestModifier)
|
||||||
this.requestModifier = obj.requestModifier;
|
this.requestModifier = obj.requestModifier;
|
||||||
|
this.language = obj?.language;
|
||||||
|
this.original = obj?.original;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
class DashSource {
|
class DashSource {
|
||||||
@@ -525,6 +529,8 @@ class DashSource {
|
|||||||
this.language = obj.language;
|
this.language = obj.language;
|
||||||
if(obj.requestModifier)
|
if(obj.requestModifier)
|
||||||
this.requestModifier = obj.requestModifier;
|
this.requestModifier = obj.requestModifier;
|
||||||
|
this.language = obj?.language;
|
||||||
|
this.original = obj?.original;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
class DashWidevineSource extends DashSource {
|
class DashWidevineSource extends DashSource {
|
||||||
@@ -550,6 +556,7 @@ class DashManifestRawSource {
|
|||||||
this.language = obj.language ?? Language.UNKNOWN;
|
this.language = obj.language ?? Language.UNKNOWN;
|
||||||
if(obj.requestModifier)
|
if(obj.requestModifier)
|
||||||
this.requestModifier = obj.requestModifier;
|
this.requestModifier = obj.requestModifier;
|
||||||
|
this.original = obj?.original;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.content.Intent
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.futo.platformplayer.activities.IWithResultLauncher
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.activities.ManageTabsActivity
|
import com.futo.platformplayer.activities.ManageTabsActivity
|
||||||
import com.futo.platformplayer.activities.PolycentricHomeActivity
|
import com.futo.platformplayer.activities.PolycentricHomeActivity
|
||||||
@@ -387,7 +388,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@DropdownFieldOptionsId(R.array.audio_languages)
|
@DropdownFieldOptionsId(R.array.audio_languages)
|
||||||
var primaryLanguage: Int = 0;
|
var primaryLanguage: Int = 0;
|
||||||
|
|
||||||
fun getPrimaryLanguage(context: Context): String? {
|
fun getPrimaryLanguage(context: Context? = null): String? {
|
||||||
return when(primaryLanguage) {
|
return when(primaryLanguage) {
|
||||||
0 -> "en";
|
0 -> "en";
|
||||||
1 -> "es";
|
1 -> "es";
|
||||||
@@ -400,10 +401,11 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
8 -> "id";
|
8 -> "id";
|
||||||
9 -> "hi";
|
9 -> "hi";
|
||||||
10 -> "ar";
|
10 -> "ar";
|
||||||
11 -> "tu";
|
11 -> "tr";
|
||||||
12 -> "ru";
|
12 -> "ru";
|
||||||
13 -> "pt";
|
13 -> "pt";
|
||||||
14 -> "zh";
|
14 -> "zh";
|
||||||
|
15 -> "it";
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -725,11 +727,6 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||||
var allowLinkLocalIpv4: Boolean = false;
|
var allowLinkLocalIpv4: Boolean = false;
|
||||||
|
|
||||||
@AdvancedField
|
|
||||||
@FormField(R.string.experimental_cast, FieldForm.TOGGLE, R.string.experimental_cast_description, 6)
|
|
||||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
|
||||||
var experimentalCasting: Boolean = true
|
|
||||||
|
|
||||||
/*TODO: Should we have a different casting quality?
|
/*TODO: Should we have a different casting quality?
|
||||||
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
||||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||||
@@ -801,6 +798,9 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@FormField(R.string.check_disabled_plugin_updates, FieldForm.TOGGLE, R.string.check_disabled_plugin_updates_description, -1)
|
@FormField(R.string.check_disabled_plugin_updates, FieldForm.TOGGLE, R.string.check_disabled_plugin_updates_description, -1)
|
||||||
var checkDisabledPluginsForUpdates: Boolean = false;
|
var checkDisabledPluginsForUpdates: Boolean = false;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
|
@FormField(R.string.clear_cookies_after_login, FieldForm.TOGGLE, R.string.clear_cookies_after_login_desc, 0)
|
||||||
|
var clearCookiesAfterLogin: Boolean = true;
|
||||||
@AdvancedField
|
@AdvancedField
|
||||||
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
|
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
|
||||||
var clearCookiesOnLogout: Boolean = true;
|
var clearCookiesOnLogout: Boolean = true;
|
||||||
@@ -810,6 +810,12 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
val cookieManager: CookieManager = CookieManager.getInstance();
|
val cookieManager: CookieManager = CookieManager.getInstance();
|
||||||
cookieManager.removeAllCookies(null);
|
cookieManager.removeAllCookies(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun shouldClearWebviewCookies(): Boolean {
|
||||||
|
return clearCookiesAfterLogin;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/*@FormField(R.string.reinstall_embedded_plugins, FieldForm.BUTTON, R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1)
|
/*@FormField(R.string.reinstall_embedded_plugins, FieldForm.BUTTON, R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1)
|
||||||
fun reinstallEmbedded() {
|
fun reinstallEmbedded() {
|
||||||
StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) {
|
StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) {
|
||||||
@@ -875,9 +881,9 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@DropdownFieldOptionsId(R.array.auto_update_when_array)
|
@DropdownFieldOptionsId(R.array.auto_update_when_array)
|
||||||
var check: Int = 0;
|
var check: Int = 0;
|
||||||
|
|
||||||
@FormField(R.string.background_download, FieldForm.DROPDOWN, R.string.configure_if_background_download_should_be_used, 1)
|
@FormField(R.string.background_download, FieldForm.TOGGLE, R.string.configure_if_background_download_should_be_used, 1)
|
||||||
@DropdownFieldOptionsId(R.array.background_download)
|
//@DropdownFieldOptionsId(R.array.background_download)
|
||||||
var backgroundDownload: Int = 0;
|
var shouldBackgroundDownload: Boolean = false;
|
||||||
|
|
||||||
@FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2)
|
@FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2)
|
||||||
@DropdownFieldOptionsId(R.array.when_download)
|
@DropdownFieldOptionsId(R.array.when_download)
|
||||||
@@ -957,18 +963,31 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
class Backup {
|
class Backup {
|
||||||
@Serializable(with = OffsetDateTimeSerializer::class)
|
@Serializable(with = OffsetDateTimeSerializer::class)
|
||||||
var lastAutoBackupTime: OffsetDateTime = OffsetDateTime.MIN;
|
var lastAutoBackupTime: OffsetDateTime = OffsetDateTime.MIN;
|
||||||
var didAskAutoBackup: Boolean = true;
|
var didAskAutoBackup: Boolean = false;
|
||||||
|
var autoBackupEnabled: Boolean = false
|
||||||
var autoBackupPassword: String? = null;
|
var autoBackupPassword: String? = null;
|
||||||
fun shouldAutomaticBackup() = autoBackupPassword != null;
|
fun shouldAutomaticBackup() = autoBackupEnabled
|
||||||
|
|
||||||
@FormField(R.string.automatic_backup, FieldForm.READONLYTEXT, -1, 0)
|
@FormField(R.string.automatic_backup, FieldForm.READONLYTEXT, -1, 0)
|
||||||
val automaticBackupText get() = if(!shouldAutomaticBackup()) "None" else "Every Day";
|
val automaticBackupText get() = if(!shouldAutomaticBackup()) "None" else "Every Day";
|
||||||
|
|
||||||
@FormField(R.string.set_automatic_backup, FieldForm.BUTTON, R.string.configure_daily_backup_in_case_of_catastrophic_failure, 1)
|
@FormField(R.string.set_automatic_backup, FieldForm.BUTTON, R.string.configure_daily_backup_in_case_of_catastrophic_failure, 1)
|
||||||
fun configureAutomaticBackup() {
|
fun configureAutomaticBackup() {
|
||||||
UIDialogs.showAutomaticBackupDialog(StateApp.instance.activity!!, autoBackupPassword != null) {
|
StateApp.instance.activity?.let { activity ->
|
||||||
SettingsFragment.currentView?.reloadSettings();
|
if(!Settings.instance.storage.isStorageMainValid(activity)) {
|
||||||
};
|
UIDialogs.toast("Missing general directory")
|
||||||
|
StateApp.instance.changeExternalGeneralDirectory(activity) {
|
||||||
|
UIDialogs.showAutomaticBackupDialog(activity) {
|
||||||
|
SettingsFragment.currentView?.reloadSettings()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
UIDialogs.showAutomaticBackupDialog(activity) {
|
||||||
|
SettingsFragment.currentView?.reloadSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@FormField(R.string.restore_automatic_backup, FieldForm.BUTTON, R.string.restore_a_previous_automatic_backup, 2)
|
@FormField(R.string.restore_automatic_backup, FieldForm.BUTTON, R.string.restore_a_previous_automatic_backup, 2)
|
||||||
fun restoreAutomaticBackup() {
|
fun restoreAutomaticBackup() {
|
||||||
@@ -1052,6 +1071,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
|
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
|
||||||
var polycentricLocalCache: Boolean = true;
|
var polycentricLocalCache: Boolean = true;
|
||||||
|
var showPrivacyModeDialog: Boolean = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19)
|
@FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import android.widget.LinearLayout
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
@@ -165,27 +166,42 @@ class UIDialogs {
|
|||||||
dialog.show()
|
dialog.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showAutomaticBackupDialog(context: Context, skipRestoreCheck: Boolean = false, onClosed: (()->Unit)? = null) {
|
fun showAutomaticBackupDialog(context: Context, onClosed: (() -> Unit)? = null) {
|
||||||
val dialogAction: ()->Unit = {
|
val dialogAction: () -> Unit = {
|
||||||
val dialog = AutomaticBackupDialog(context);
|
val dialog = AutomaticBackupDialog(context)
|
||||||
registerDialogOpened(dialog);
|
registerDialogOpened(dialog)
|
||||||
dialog.setOnDismissListener { registerDialogClosed(dialog); onClosed?.invoke() };
|
dialog.setOnDismissListener {
|
||||||
dialog.show();
|
registerDialogClosed(dialog)
|
||||||
};
|
onClosed?.invoke()
|
||||||
if(StateBackup.hasAutomaticBackup() && !skipRestoreCheck)
|
}
|
||||||
UIDialogs.showDialog(context, R.drawable.ic_move_up, context.getString(R.string.an_old_backup_is_available), context.getString(R.string.would_you_like_to_restore_this_backup), null, 0,
|
dialog.show()
|
||||||
UIDialogs.Action(context.getString(R.string.cancel), {}), //To nothing
|
}
|
||||||
UIDialogs.Action(context.getString(R.string.override), {
|
|
||||||
dialogAction();
|
if (!Settings.instance.backup.autoBackupEnabled && StateBackup.hasAutomaticBackup()) {
|
||||||
|
UIDialogs.showDialog(
|
||||||
|
context,
|
||||||
|
R.drawable.ic_move_up,
|
||||||
|
context.getString(R.string.an_old_backup_is_available),
|
||||||
|
context.getString(R.string.would_you_like_to_restore_this_backup),
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
UIDialogs.Action(context.getString(R.string.cancel), {}),
|
||||||
|
UIDialogs.Action(context.getString(R.string.continue_anyway), {
|
||||||
|
dialogAction()
|
||||||
}, UIDialogs.ActionStyle.DANGEROUS),
|
}, UIDialogs.ActionStyle.DANGEROUS),
|
||||||
UIDialogs.Action(context.getString(R.string.restore), {
|
UIDialogs.Action(context.getString(R.string.restore), {
|
||||||
UIDialogs.showAutomaticRestoreDialog(context, StateApp.instance.scope);
|
val scope = (context as? androidx.lifecycle.LifecycleOwner)?.lifecycleScope
|
||||||
|
?: StateApp.instance.scopeOrNull
|
||||||
|
?: StateApp.instance.scope
|
||||||
|
|
||||||
|
UIDialogs.showAutomaticRestoreDialog(context, scope)
|
||||||
}, UIDialogs.ActionStyle.PRIMARY)
|
}, UIDialogs.ActionStyle.PRIMARY)
|
||||||
);
|
)
|
||||||
else {
|
} else {
|
||||||
dialogAction();
|
dialogAction()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showAutomaticRestoreDialog(context: Context, scope: CoroutineScope) {
|
fun showAutomaticRestoreDialog(context: Context, scope: CoroutineScope) {
|
||||||
val dialog = AutomaticRestoreDialog(context, scope);
|
val dialog = AutomaticRestoreDialog(context, scope);
|
||||||
registerDialogOpened(dialog);
|
registerDialogOpened(dialog);
|
||||||
@@ -370,17 +386,19 @@ class UIDialogs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null) {
|
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null): AlertDialog {
|
||||||
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
|
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
|
||||||
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
||||||
showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction)
|
return showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction).apply {
|
||||||
|
setOnDismissListener { dismissAction?.invoke() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null) {
|
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null): AlertDialog {
|
||||||
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
|
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
|
||||||
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
||||||
val doNotAskAgain = Action(context.getString(R.string.do_not_ask_again), doNotAskAgainAction ?: {}, ActionStyle.NONE)
|
val doNotAskAgain = Action(context.getString(R.string.do_not_ask_again), doNotAskAgainAction ?: {}, ActionStyle.NONE)
|
||||||
showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction)
|
return showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) {
|
fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) {
|
||||||
@@ -403,13 +421,6 @@ class UIDialogs {
|
|||||||
dialog.setMaxVersion(lastVersion);
|
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) {
|
fun showMigrateDialog(context: Context, store: ManagedStore<*>, onConcluded: ()->Unit) {
|
||||||
if(!store.hasMissingReconstructions())
|
if(!store.hasMissingReconstructions())
|
||||||
onConcluded();
|
onConcluded();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.content.ContentResolver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
@@ -74,6 +75,8 @@ import kotlinx.coroutines.withContext
|
|||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
|
||||||
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuButtonList
|
||||||
|
import kotlin.collections.toList
|
||||||
|
|
||||||
class UISlideOverlays {
|
class UISlideOverlays {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -573,6 +576,51 @@ class UISlideOverlays {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
val allLanguages = videoSources?.map { it.language }?.distinct() ?: listOf();
|
||||||
|
val langResCombinations = if(videoSources != null) allLanguages.flatMap {
|
||||||
|
lang -> videoSources
|
||||||
|
.filter { v -> v.language == lang }
|
||||||
|
.map { it.height * it.width }
|
||||||
|
.distinct()
|
||||||
|
.map { res -> Pair(res, lang) }
|
||||||
|
} else listOf();
|
||||||
|
var videoSourceItems = mutableListOf<SlideUpMenuItem>();
|
||||||
|
var selectedLanguage: String? = null;
|
||||||
|
val languageFilters = if(allLanguages.filter { it != null }.count() > 1)
|
||||||
|
SlideUpMenuButtonList(container.context, null, "language_filter", true).apply {
|
||||||
|
var languageFilterLabels = allLanguages.filterNotNull().toList();
|
||||||
|
val english = languageFilterLabels.find { it?.lowercase() == "en" };
|
||||||
|
val originalLanguage = videoSources?.find { it.original == true }?.language;
|
||||||
|
val primaryLanguage = Settings.instance.playback.getPrimaryLanguage();
|
||||||
|
val hasPrimaryLanguage = videoSources?.any { it.language == primaryLanguage } ?: false;
|
||||||
|
|
||||||
|
if(english != null)
|
||||||
|
languageFilterLabels = listOf(english).plus(languageFilterLabels.filter { it != english }).toList();
|
||||||
|
if(primaryLanguage != null && languageFilterLabels.contains(primaryLanguage))
|
||||||
|
languageFilterLabels = listOf(primaryLanguage).plus(languageFilterLabels.filter { it != primaryLanguage }).toList();
|
||||||
|
if(originalLanguage != null)
|
||||||
|
languageFilterLabels = listOf(originalLanguage).plus(languageFilterLabels.filter { it != originalLanguage }).toList();
|
||||||
|
Log.i(TAG, "Language filtesr: ${languageFilterLabels.joinToString(", ")}");
|
||||||
|
selectedLanguage = originalLanguage ?: (if(hasPrimaryLanguage) primaryLanguage else null);
|
||||||
|
setButtons(languageFilterLabels, selectedLanguage);
|
||||||
|
onClick.subscribe { selected ->
|
||||||
|
setSelected(selected);
|
||||||
|
|
||||||
|
videoSourceItems.forEach {
|
||||||
|
val item = it.itemTag;
|
||||||
|
if(item is IVideoSource) {
|
||||||
|
if(item.language == selected)
|
||||||
|
it.visibility = View.VISIBLE;
|
||||||
|
else
|
||||||
|
it.visibility = View.GONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else null;
|
||||||
|
|
||||||
|
if(languageFilters != null) items.add(languageFilters)
|
||||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
|
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
|
||||||
listOf((if (audioSources != null) listOf(SlideUpMenuItem(
|
listOf((if (audioSources != null) listOf(SlideUpMenuItem(
|
||||||
container.context,
|
container.context,
|
||||||
@@ -609,7 +657,13 @@ class UISlideOverlays {
|
|||||||
menu?.setOk(container.context.getString(R.string.download));
|
menu?.setOk(container.context.getString(R.string.download));
|
||||||
},
|
},
|
||||||
invokeParent = false
|
invokeParent = false
|
||||||
)
|
).apply {
|
||||||
|
videoSourceItems.add(this);
|
||||||
|
if(selectedLanguage != null) {
|
||||||
|
if(it.language != selectedLanguage)
|
||||||
|
this.visibility = View.GONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is JSDashManifestRawSource -> {
|
is JSDashManifestRawSource -> {
|
||||||
@@ -629,7 +683,13 @@ class UISlideOverlays {
|
|||||||
menu?.setOk(container.context.getString(R.string.download));
|
menu?.setOk(container.context.getString(R.string.download));
|
||||||
},
|
},
|
||||||
invokeParent = false
|
invokeParent = false
|
||||||
)
|
).apply {
|
||||||
|
videoSourceItems.add(this);
|
||||||
|
if(selectedLanguage != null) {
|
||||||
|
if(it.language != selectedLanguage)
|
||||||
|
this.visibility = View.GONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is IHLSManifestSource -> {
|
is IHLSManifestSource -> {
|
||||||
@@ -643,7 +703,13 @@ class UISlideOverlays {
|
|||||||
showHlsPicker(video, it, it.url, container)
|
showHlsPicker(video, it, it.url, container)
|
||||||
},
|
},
|
||||||
invokeParent = false
|
invokeParent = false
|
||||||
)
|
).apply {
|
||||||
|
videoSourceItems.add(this);
|
||||||
|
if(selectedLanguage != null) {
|
||||||
|
if(it.language != selectedLanguage)
|
||||||
|
this.visibility = View.GONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
|
import com.futo.platformplayer.dialogs.AutoUpdateDialog
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class UpdateActionReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
when (intent.action) {
|
||||||
|
UpdateNotificationManager.ACTION_UPDATE_YES -> handleUpdateYes(context, intent)
|
||||||
|
UpdateNotificationManager.ACTION_UPDATE_NO -> handleUpdateNo(context)
|
||||||
|
UpdateNotificationManager.ACTION_UPDATE_NEVER -> handleUpdateNever(context)
|
||||||
|
UpdateNotificationManager.ACTION_DOWNLOAD_CANCEL -> handleDownloadCancel(context, intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleUpdateYes(context: Context, intent: Intent) {
|
||||||
|
AutoUpdateDialog.currentDialog?.dismiss()
|
||||||
|
|
||||||
|
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
||||||
|
if (version == 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
|
||||||
|
|
||||||
|
val serviceIntent = Intent(context, UpdateDownloadService::class.java).apply {
|
||||||
|
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
|
||||||
|
}
|
||||||
|
ContextCompat.startForegroundService(context, serviceIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleUpdateNo(context: Context) {
|
||||||
|
AutoUpdateDialog.currentDialog?.dismiss()
|
||||||
|
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleUpdateNever(context: Context) {
|
||||||
|
AutoUpdateDialog.currentDialog?.dismiss()
|
||||||
|
Settings.instance.autoUpdate.check = 1
|
||||||
|
Settings.instance.save()
|
||||||
|
|
||||||
|
UpdateNotificationManager.cancelAll(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDownloadCancel(context: Context, intent: Intent) {
|
||||||
|
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
||||||
|
|
||||||
|
val cancelIntent = Intent(context, UpdateDownloadService::class.java).apply {
|
||||||
|
putExtra(UpdateDownloadService.EXTRA_CANCEL, true)
|
||||||
|
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
|
||||||
|
}
|
||||||
|
ContextCompat.startForegroundService(context, cancelIntent)
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_DOWNLOADING)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StateUpdate
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class UpdateCheckWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) {
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
if (!Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
|
||||||
|
Logger.i(TAG, "Auto-update disabled, skipping worker run")
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val client = ManagedHttpClient()
|
||||||
|
val latestVersion = StateUpdate.Companion.instance.downloadVersionCode(client)
|
||||||
|
|
||||||
|
if (latestVersion == null) {
|
||||||
|
Logger.w(TAG, "Failed to fetch latest version in worker")
|
||||||
|
return@withContext Result.retry()
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentVersion = BuildConfig.VERSION_CODE
|
||||||
|
Logger.i(TAG, "Worker check: current=$currentVersion, latest=$latestVersion")
|
||||||
|
|
||||||
|
if (latestVersion <= currentVersion) {
|
||||||
|
return@withContext Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateNotificationManager.showUpdateAvailableNotification(applicationContext, latestVersion)
|
||||||
|
|
||||||
|
if (StateApp.instance.isMainActive) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
StateApp.withContext { ctx ->
|
||||||
|
try {
|
||||||
|
UIDialogs.showUpdateAvailableDialog(ctx, latestVersion, false)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Logger.w(TAG, "Failed to show in-app update dialog from worker", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Result.success()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Logger.w(TAG, "Exception in UpdateCheckWorker", t)
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "UpdateCheckWorker"
|
||||||
|
const val UNIQUE_WORK_NAME = "updateCheck"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.SystemClock
|
||||||
|
import com.futo.platformplayer.UIDialogs.ActionStyle
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.models.ImageVariable
|
||||||
|
import com.futo.platformplayer.states.AnnouncementType
|
||||||
|
import com.futo.platformplayer.states.SessionAnnouncement
|
||||||
|
import com.futo.platformplayer.states.StateAnnouncement
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StateUpdate
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
|
class UpdateDownloadService : Service() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "UpdateDownloadService"
|
||||||
|
const val EXTRA_VERSION = "version"
|
||||||
|
const val EXTRA_CANCEL = "cancel"
|
||||||
|
private const val MAX_RETRIES = 5
|
||||||
|
private const val INITIAL_BACKOFF_MS = 5_000L
|
||||||
|
private const val BUFFER_SIZE = 8 * 1024
|
||||||
|
private const val MIN_PROGRESS_UPDATE_INTERVAL_MS = 500L
|
||||||
|
|
||||||
|
var updateDownloadedDialog: Dialog? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private val job = SupervisorJob()
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + job)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var isDownloading: Boolean = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var cancelRequested: Boolean = false
|
||||||
|
|
||||||
|
private var lastProgressUpdateElapsedMs: Long = 0L
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (intent == null) {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent.getBooleanExtra(EXTRA_CANCEL, false)) {
|
||||||
|
cancelRequested = true
|
||||||
|
Logger.i(TAG, "Download cancel requested")
|
||||||
|
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
val version = intent.getIntExtra(EXTRA_VERSION, 0)
|
||||||
|
if (version == 0) {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDownloading) {
|
||||||
|
Logger.i(TAG, "Download already in progress, ignoring new start")
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
isDownloading = true
|
||||||
|
cancelRequested = false
|
||||||
|
|
||||||
|
val notification = UpdateNotificationManager.buildDownloadProgressNotification(this, version, 0, true)
|
||||||
|
startForeground(UpdateNotificationManager.NOTIF_ID_DOWNLOADING, notification)
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
downloadApk(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean, onProgress: ((Int) -> Unit)? = null) {
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
val force = progress == 100 && !indeterminate
|
||||||
|
|
||||||
|
if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) {
|
||||||
|
lastProgressUpdateElapsedMs = now
|
||||||
|
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate);
|
||||||
|
|
||||||
|
if(onProgress != null)
|
||||||
|
onProgress.invoke(progress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun downloadApk(version: Int) {
|
||||||
|
val apkFile = StateUpdate.getApkFile(this, version)
|
||||||
|
val partialFile = StateUpdate.getPartialApkFile(this, version)
|
||||||
|
|
||||||
|
var announcement: SessionAnnouncement? = null;
|
||||||
|
try {
|
||||||
|
if (apkFile.exists() && apkFile.length() > 0L) {
|
||||||
|
Logger.i(TAG, "APK already downloaded at ${apkFile.absolutePath}")
|
||||||
|
onDownloadComplete(version, apkFile)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
announcement = StateAnnouncement.instance.registerLoading("Downloading new version [${version}]", "New version is being downloaded..",
|
||||||
|
ImageVariable.fromResource(R.drawable.foreground));
|
||||||
|
}
|
||||||
|
catch(ex: Exception){
|
||||||
|
Logger.e(TAG, "Failed to set progress announcement", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
var backoffMs = INITIAL_BACKOFF_MS
|
||||||
|
|
||||||
|
for (attempt in 0 until MAX_RETRIES) {
|
||||||
|
if (cancelRequested) {
|
||||||
|
Logger.i(TAG, "Download cancelled before attempt ${attempt + 1}")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
performDownload(StateUpdate.APK_URL, partialFile, version, {
|
||||||
|
try {
|
||||||
|
if (announcement != null)
|
||||||
|
announcement?.setProgress(it);
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!cancelRequested) {
|
||||||
|
if (apkFile.exists()) {
|
||||||
|
apkFile.delete()
|
||||||
|
}
|
||||||
|
if (!partialFile.renameTo(apkFile)) {
|
||||||
|
throw IllegalStateException("Failed to rename partial APK file")
|
||||||
|
}
|
||||||
|
onDownloadComplete(version, apkFile)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
if (cancelRequested) {
|
||||||
|
Logger.i(TAG, "Download cancelled by user", t)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt == MAX_RETRIES - 1) {
|
||||||
|
Logger.e(TAG, "Download failed after ${attempt + 1} attempts", t)
|
||||||
|
UpdateNotificationManager.showDownloadFailedNotification(this, version, t)
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
Logger.w(TAG, "Download attempt ${attempt + 1} failed, retrying in ${backoffMs / 1000}s", t)
|
||||||
|
delay(backoffMs)
|
||||||
|
backoffMs *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if (announcement != null) {
|
||||||
|
StateAnnouncement.instance.closeAnnouncement(announcement.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(ex: Throwable){}
|
||||||
|
isDownloading = false
|
||||||
|
cancelRequested = false
|
||||||
|
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performDownload(url: String, partialFile: File, version: Int, onProgress: ((Int)->Unit)? = null) {
|
||||||
|
var startOffset = if (partialFile.exists()) partialFile.length() else 0L
|
||||||
|
Logger.i(TAG, "Starting download. url=$url, existingBytes=$startOffset")
|
||||||
|
|
||||||
|
var connection: HttpURLConnection? = null
|
||||||
|
try {
|
||||||
|
connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
|
connectTimeout = 15_000
|
||||||
|
readTimeout = 30_000
|
||||||
|
if (startOffset > 0L) {
|
||||||
|
setRequestProperty("Range", "bytes=$startOffset-")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.connect()
|
||||||
|
val responseCode = connection.responseCode
|
||||||
|
|
||||||
|
if (responseCode == HttpURLConnection.HTTP_OK && startOffset > 0L) {
|
||||||
|
Logger.w(TAG, "Server ignored Range header, restarting download from scratch")
|
||||||
|
partialFile.delete()
|
||||||
|
startOffset = 0L
|
||||||
|
} else if (responseCode != HttpURLConnection.HTTP_OK &&
|
||||||
|
responseCode != HttpURLConnection.HTTP_PARTIAL) {
|
||||||
|
throw IllegalStateException("Unexpected HTTP response code $responseCode")
|
||||||
|
}
|
||||||
|
|
||||||
|
val contentLength = connection.contentLengthLong
|
||||||
|
val totalBytes = if (contentLength > 0L) startOffset + contentLength else -1L
|
||||||
|
|
||||||
|
val buffer = ByteArray(BUFFER_SIZE)
|
||||||
|
var downloaded = 0L
|
||||||
|
var lastProgress = -1
|
||||||
|
|
||||||
|
connection.inputStream.use { input ->
|
||||||
|
FileOutputStream(partialFile, startOffset > 0L).use { output ->
|
||||||
|
while (!cancelRequested) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read == -1) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
downloaded += read
|
||||||
|
|
||||||
|
if (totalBytes > 0L) {
|
||||||
|
val progress = (((startOffset + downloaded) * 100L) / totalBytes).toInt()
|
||||||
|
if (progress != lastProgress) {
|
||||||
|
lastProgress = progress
|
||||||
|
val safeProgress = when {
|
||||||
|
progress < 0 -> 0
|
||||||
|
progress > 100 -> 100
|
||||||
|
else -> progress
|
||||||
|
}
|
||||||
|
throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false, onProgress)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throttledUpdateDownloadProgress(version, progress = 0, indeterminate = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelRequested && totalBytes > 0L) {
|
||||||
|
val finalProgress = 100
|
||||||
|
throttledUpdateDownloadProgress(version, finalProgress, indeterminate = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelRequested) {
|
||||||
|
throw CancellationException("Download cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalBytes > 0L && startOffset + downloaded < totalBytes) {
|
||||||
|
throw IllegalStateException("Download incomplete: expected=$totalBytes, got=${startOffset + downloaded}")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
connection?.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onDownloadComplete(version: Int, apkFile: File) {
|
||||||
|
Logger.i(TAG, "Download complete for version=$version, file=${apkFile.absolutePath}")
|
||||||
|
UpdateNotificationManager.showDownloadCompleteNotification(this, version, apkFile)
|
||||||
|
|
||||||
|
if (StateApp.instance.isMainActive) {
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
|
StateApp.withContext { ctx ->
|
||||||
|
try {
|
||||||
|
updateDownloadedDialog = UIDialogs.showDialog(ctx, R.drawable.foreground,
|
||||||
|
"Update downloaded",
|
||||||
|
"Would you like to install it now?", null, 0,
|
||||||
|
UIDialogs.Action("Not now", {
|
||||||
|
updateDownloadedDialog = null
|
||||||
|
}, ActionStyle.NONE, true),
|
||||||
|
UIDialogs.Action("Install", {
|
||||||
|
UpdateNotificationManager.cancelAll(ctx)
|
||||||
|
UpdateInstaller.startInstall(ctx, version, apkFile)
|
||||||
|
}, ActionStyle.PRIMARY, true));
|
||||||
|
|
||||||
|
try {
|
||||||
|
StateAnnouncement.instance.registerAnnouncement("install-update-apk", "Grayjay v${version} is ready!", "You can now install the new Grayjay version.",
|
||||||
|
AnnouncementType.SESSION,
|
||||||
|
OffsetDateTime.now(), "update", "Install", {
|
||||||
|
UpdateNotificationManager.cancelAll(ctx)
|
||||||
|
UpdateInstaller.startInstall(ctx, version, apkFile)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
|
||||||
|
updateDownloadedDialog = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.PendingIntent.FLAG_MUTABLE
|
||||||
|
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
import android.app.PendingIntent.getBroadcast
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.graphics.drawable.Animatable
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.view.View
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.receivers.InstallReceiver
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.io.InputStream
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.futo.platformplayer.dialogs.AutoUpdateDialog
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
|
||||||
|
object UpdateInstaller {
|
||||||
|
private const val TAG = "UpdateInstaller"
|
||||||
|
|
||||||
|
@SuppressLint("RequestInstallPackagesPolicy")
|
||||||
|
fun startInstall(context: Context, version: Int, apkFile: File) {
|
||||||
|
if (!apkFile.exists()) {
|
||||||
|
Logger.w(TAG, "APK file does not exist: ${apkFile.absolutePath}")
|
||||||
|
UIDialogs.toast(context, "Update file missing")
|
||||||
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "APK file does not exist.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BuildConfig.IS_PLAYSTORE_BUILD) {
|
||||||
|
UIDialogs.toast(context, "Updates are managed by the Play Store")
|
||||||
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Updates are managed by the Play Store.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val pm = context.packageManager
|
||||||
|
if (!pm.canRequestPackageInstalls()) {
|
||||||
|
UIDialogs.toast(context, "Allow this app to install updates, then try again")
|
||||||
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Install update permission was missing.")
|
||||||
|
|
||||||
|
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
||||||
|
data = "package:${context.packageName}".toUri()
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to check unknown sources permission", t)
|
||||||
|
}
|
||||||
|
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
var inputStream: InputStream? = null
|
||||||
|
var session: PackageInstaller.Session? = null
|
||||||
|
try {
|
||||||
|
|
||||||
|
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
|
||||||
|
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||||
|
val sessionId = packageInstaller.createSession(params)
|
||||||
|
session = packageInstaller.openSession(sessionId)
|
||||||
|
|
||||||
|
inputStream = apkFile.inputStream()
|
||||||
|
val dataLength = apkFile.length()
|
||||||
|
|
||||||
|
session.openWrite("package", 0, dataLength).use { sessionStream ->
|
||||||
|
inputStream.copyToOutputStream(dataLength, sessionStream) { _ -> }
|
||||||
|
session.fsync(sessionStream)
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(context, InstallReceiver::class.java).apply {
|
||||||
|
putExtra(UpdateNotificationManager.EXTRA_VERSION, version)
|
||||||
|
putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkFile.absolutePath)
|
||||||
|
}
|
||||||
|
val pendingIntent = getBroadcast(context, 0, intent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
|
||||||
|
val statusReceiver = pendingIntent.intentSender
|
||||||
|
|
||||||
|
InstallReceiver.onReceiveResult.subscribe(this) { message ->
|
||||||
|
InstallReceiver.onReceiveResult.clear();
|
||||||
|
onReceiveResult(context, version, apkFile, message);
|
||||||
|
};
|
||||||
|
Logger.i(TAG, "Committing install session for ${apkFile.absolutePath}")
|
||||||
|
session.commit(statusReceiver)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.w(TAG, "Exception while installing update", e)
|
||||||
|
session?.abandon()
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
UIDialogs.toast(context, "Failed to install update: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, e.message)
|
||||||
|
} finally {
|
||||||
|
session?.close()
|
||||||
|
inputStream?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onReceiveResult(context: Context, version: Int, apkFile: File, result: String?) {
|
||||||
|
try {
|
||||||
|
InstallReceiver.onReceiveResult.remove(this)
|
||||||
|
|
||||||
|
if (result.isNullOrEmpty()) {
|
||||||
|
Logger.i(TAG, "Update install finished successfully")
|
||||||
|
UpdateNotificationManager.showInstallSucceededNotification(context, version)
|
||||||
|
} else {
|
||||||
|
Logger.w(TAG, "Update install failed: $result")
|
||||||
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, result)
|
||||||
|
UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n$result")
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to handle install result", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.PendingIntent.FLAG_MUTABLE
|
||||||
|
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
import android.app.PendingIntent.getBroadcast
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.futo.platformplayer.activities.InstallUpdateActivity
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
object UpdateNotificationManager {
|
||||||
|
private const val CHANNEL_ID = "app_updates"
|
||||||
|
private const val CHANNEL_NAME = "App updates"
|
||||||
|
private const val CHANNEL_DESCRIPTION = "Notifications about new app versions"
|
||||||
|
|
||||||
|
const val ACTION_UPDATE_YES = "com.futo.platformplayer.UPDATE_YES"
|
||||||
|
const val ACTION_UPDATE_NO = "com.futo.platformplayer.UPDATE_NO"
|
||||||
|
const val ACTION_UPDATE_NEVER = "com.futo.platformplayer.UPDATE_NEVER"
|
||||||
|
const val ACTION_DOWNLOAD_CANCEL = "com.futo.platformplayer.UPDATE_CANCEL"
|
||||||
|
const val ACTION_INSTALL_NOW = "com.futo.platformplayer.UPDATE_INSTALL"
|
||||||
|
private const val REQUEST_CODE_INSTALL = 1001
|
||||||
|
|
||||||
|
const val EXTRA_VERSION = "version"
|
||||||
|
const val EXTRA_APK_PATH = "apk_path"
|
||||||
|
|
||||||
|
const val NOTIF_ID_AVAILABLE = 2001
|
||||||
|
const val NOTIF_ID_DOWNLOADING = 2002
|
||||||
|
const val NOTIF_ID_READY = 2003
|
||||||
|
const val NOTIF_ID_INSTALL_FAILED = 2004
|
||||||
|
const val NOTIF_ID_INSTALL_SUCCEEDED = 2005
|
||||||
|
|
||||||
|
fun ensureChannel(context: Context) {
|
||||||
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
|
||||||
|
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||||
|
description = CHANNEL_DESCRIPTION
|
||||||
|
enableVibration(false)
|
||||||
|
enableLights(false)
|
||||||
|
setSound(null, null)
|
||||||
|
}
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showInstallSucceededNotification(context: Context, version: Int) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val launchIntent = context.packageManager
|
||||||
|
.getLaunchIntentForPackage(context.packageName)
|
||||||
|
?.apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
|
||||||
|
}
|
||||||
|
|
||||||
|
val launchPendingIntent = launchIntent?.let {
|
||||||
|
PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Update installed")
|
||||||
|
.setContentText("Version $version installed. Tap to open.")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setSilent(true)
|
||||||
|
|
||||||
|
if (launchPendingIntent != null) {
|
||||||
|
builder.setContentIntent(launchPendingIntent)
|
||||||
|
builder.addAction(0, "Open app", launchPendingIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_SUCCEEDED, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showUpdateAvailableNotification(context: Context, version: Int) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val yesIntent = Intent(context, UpdateActionReceiver::class.java).apply {
|
||||||
|
action = ACTION_UPDATE_YES
|
||||||
|
putExtra(EXTRA_VERSION, version)
|
||||||
|
}
|
||||||
|
val yesPendingIntent = getBroadcast(context, 0, yesIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
|
||||||
|
val noIntent = Intent(context, UpdateActionReceiver::class.java).apply {
|
||||||
|
action = ACTION_UPDATE_NO
|
||||||
|
putExtra(EXTRA_VERSION, version)
|
||||||
|
}
|
||||||
|
val noPendingIntent = getBroadcast(context, 1, noIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
|
||||||
|
val neverIntent = Intent(context, UpdateActionReceiver::class.java).apply {
|
||||||
|
action = ACTION_UPDATE_NEVER
|
||||||
|
putExtra(EXTRA_VERSION, version)
|
||||||
|
}
|
||||||
|
val neverPendingIntent = getBroadcast(context, 2, neverIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Update available")
|
||||||
|
.setContentText("A new version ($version) is available.")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(yesPendingIntent)
|
||||||
|
.setSilent(true)
|
||||||
|
.addAction(0, "Never", neverPendingIntent)
|
||||||
|
.addAction(0, "Not now", noPendingIntent)
|
||||||
|
.addAction(0, "Download", yesPendingIntent)
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_AVAILABLE, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildDownloadProgressNotification(context: Context, version: Int, progress: Int, indeterminate: Boolean): Notification {
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val cancelIntent = Intent(context, UpdateActionReceiver::class.java).apply {
|
||||||
|
action = ACTION_DOWNLOAD_CANCEL
|
||||||
|
putExtra(EXTRA_VERSION, version)
|
||||||
|
}
|
||||||
|
val cancelPendingIntent = getBroadcast(
|
||||||
|
context,
|
||||||
|
3,
|
||||||
|
cancelIntent,
|
||||||
|
FLAG_MUTABLE or FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Downloading update")
|
||||||
|
.setContentText("Downloading version $version")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.addAction(0, "Cancel", cancelPendingIntent)
|
||||||
|
|
||||||
|
if (indeterminate) {
|
||||||
|
builder.setProgress(0, 0, true)
|
||||||
|
} else {
|
||||||
|
builder.setProgress(100, progress, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateDownloadProgress(context: Context, version: Int, progress: Int, indeterminate: Boolean) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val notification = buildDownloadProgressNotification(context, version, progress, indeterminate)
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_DOWNLOADING, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun showDownloadCompleteNotification(context: Context, version: Int, apkFile: File) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath)
|
||||||
|
val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Update downloaded")
|
||||||
|
.setContentText("Tap to install version $version.")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setContentIntent(installPendingIntent)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.addAction(0, "Install", installPendingIntent)
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun showDownloadFailedNotification(context: Context, version: Int, error: Throwable?) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Failed to download update")
|
||||||
|
.setContentText(error?.message ?: "Unknown error")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setSilent(true)
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showInstallFailedNotification(context: Context, version: Int, apkFile: File, error: String?) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED)
|
||||||
|
return
|
||||||
|
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath)
|
||||||
|
val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Failed to install update")
|
||||||
|
.setContentText(if (error != null && error.isNotBlank()) "$error Tap to try again." else "Tap to try again.")
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.setContentIntent(installPendingIntent)
|
||||||
|
.addAction(0, "Install again", installPendingIntent)
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_FAILED, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelAll(context: Context) {
|
||||||
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_AVAILABLE)
|
||||||
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING)
|
||||||
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY)
|
||||||
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_FAILED)
|
||||||
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_SUCCEEDED)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,6 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.icu.util.Output
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.OperationCanceledException
|
import android.os.OperationCanceledException
|
||||||
@@ -44,6 +42,9 @@ import java.util.*
|
|||||||
import java.util.concurrent.ThreadLocalRandom
|
import java.util.concurrent.ThreadLocalRandom
|
||||||
import java.util.zip.GZIPInputStream
|
import java.util.zip.GZIPInputStream
|
||||||
import java.util.zip.GZIPOutputStream
|
import java.util.zip.GZIPOutputStream
|
||||||
|
import androidx.core.graphics.scale
|
||||||
|
import com.bumptech.glide.RequestBuilder
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
|
|
||||||
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
|
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
|
||||||
fun getRandomString(sizeOfRandomString: Int): String {
|
fun getRandomString(sizeOfRandomString: Int): String {
|
||||||
@@ -114,23 +115,6 @@ fun DocumentFile.writeBytes(context: Context, byteArray: ByteArray) = context.co
|
|||||||
it.flush();
|
it.flush();
|
||||||
};
|
};
|
||||||
|
|
||||||
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) {
|
fun TextView.setPlatformPlayerLinkMovementMethod(context: Context) {
|
||||||
this.movementMethod = PlatformLinkMovementMethod(context);
|
this.movementMethod = PlatformLinkMovementMethod(context);
|
||||||
}
|
}
|
||||||
@@ -458,4 +442,11 @@ fun addressScore(addr: InetAddress): Int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)
|
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)
|
||||||
|
|
||||||
|
fun <T> RequestBuilder<T>.withMaxSizePx(maxSizePx: Int = 1920): RequestBuilder<T> {
|
||||||
|
return this;
|
||||||
|
//.downsample(DownsampleStrategy.AT_MOST)
|
||||||
|
//.override(maxSizePx, maxSizePx)
|
||||||
|
//.centerInside()
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.futo.platformplayer.activities
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
import com.futo.platformplayer.UpdateInstaller
|
||||||
|
import com.futo.platformplayer.UpdateNotificationManager
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class InstallUpdateActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
UpdateNotificationManager.cancelAll(this)
|
||||||
|
|
||||||
|
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
||||||
|
val apkPath = intent.getStringExtra(UpdateNotificationManager.EXTRA_APK_PATH)
|
||||||
|
|
||||||
|
if (version == 0 || apkPath.isNullOrEmpty()) {
|
||||||
|
Logger.w("InstallUpdateActivity", "Missing version or apkPath")
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val apkFile = File(apkPath)
|
||||||
|
if (!apkFile.exists()) {
|
||||||
|
Logger.w("InstallUpdateActivity", "APK file does not exist: $apkPath")
|
||||||
|
UIDialogs.Companion.toast(this, "Update file missing")
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateInstaller.startInstall(this, version, apkFile)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun createIntent(context: Context, version: Int, apkPath: String): Intent =
|
||||||
|
Intent(context, InstallUpdateActivity::class.java).apply {
|
||||||
|
putExtra(UpdateNotificationManager.EXTRA_VERSION, version)
|
||||||
|
putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkPath)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import android.content.Intent
|
|||||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.Color
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -109,6 +110,7 @@ import com.futo.platformplayer.stores.StringStorage
|
|||||||
import com.futo.platformplayer.stores.SubscriptionStorage
|
import com.futo.platformplayer.stores.SubscriptionStorage
|
||||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||||
import com.futo.platformplayer.views.ToastView
|
import com.futo.platformplayer.views.ToastView
|
||||||
|
import com.futo.platformplayer.views.notification.NotificationOverlayView
|
||||||
import com.futo.polycentric.core.ApiMethods
|
import com.futo.polycentric.core.ApiMethods
|
||||||
import com.google.gson.JsonParser
|
import com.google.gson.JsonParser
|
||||||
import com.google.zxing.integration.android.IntentIntegrator
|
import com.google.zxing.integration.android.IntentIntegrator
|
||||||
@@ -200,6 +202,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
lateinit var _fragLibraryVideos: LibraryVideosFragment;
|
lateinit var _fragLibraryVideos: LibraryVideosFragment;
|
||||||
lateinit var _fragLibrarySearch: LibrarySearchFragment;
|
lateinit var _fragLibrarySearch: LibrarySearchFragment;
|
||||||
lateinit var _fragLibraryFiles: LibraryFilesFragment;
|
lateinit var _fragLibraryFiles: LibraryFilesFragment;
|
||||||
|
lateinit var _fragNotifications: NotificationOverlayView.Frag;
|
||||||
lateinit var _fragSettings: SettingsFragment;
|
lateinit var _fragSettings: SettingsFragment;
|
||||||
lateinit var _fragDeveloper: DeveloperFragment;
|
lateinit var _fragDeveloper: DeveloperFragment;
|
||||||
lateinit var _fragLogin: LoginFragment;
|
lateinit var _fragLogin: LoginFragment;
|
||||||
@@ -211,7 +214,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
|
|
||||||
//State
|
//State
|
||||||
private val _queue: LinkedList<Pair<MainFragment, Any?>> = LinkedList();
|
private val _queue: LinkedList<Pair<MainFragment, Any?>> = LinkedList();
|
||||||
lateinit var fragCurrent: MainFragment private set;
|
var fragCurrent: MainFragment? = null; private set;
|
||||||
private var _parameterCurrent: Any? = null;
|
private var _parameterCurrent: Any? = null;
|
||||||
|
|
||||||
var fragBeforeOverlay: MainFragment? = null; private set;
|
var fragBeforeOverlay: MainFragment? = null; private set;
|
||||||
@@ -244,19 +247,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private val _notifPermission = "android.permission.POST_NOTIFICATIONS";
|
|
||||||
private val _notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
|
|
||||||
if (isGranted)
|
|
||||||
UIDialogs.toast(this, "Notification permission granted");
|
|
||||||
else
|
|
||||||
UIDialogs.toast(this, "Notification permission denied");
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fun requestNotificationPermissions() {
|
|
||||||
_notificationPermissionLauncher?.launch(_notifPermission);
|
|
||||||
}
|
|
||||||
|
|
||||||
val mainId = UUID.randomUUID().toString().substring(0, 5)
|
val mainId = UUID.randomUUID().toString().substring(0, 5)
|
||||||
|
|
||||||
@@ -332,6 +322,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
window.isNavigationBarContrastEnforced = false
|
||||||
|
}
|
||||||
|
|
||||||
//Preload common files to memory
|
//Preload common files to memory
|
||||||
FragmentedStorage.get<SubscriptionStorage>();
|
FragmentedStorage.get<SubscriptionStorage>();
|
||||||
FragmentedStorage.get<Settings>();
|
FragmentedStorage.get<Settings>();
|
||||||
@@ -397,6 +391,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
_fragLibraryVideos = LibraryVideosFragment.newInstance();
|
_fragLibraryVideos = LibraryVideosFragment.newInstance();
|
||||||
_fragLibraryFiles = LibraryFilesFragment.newInstance();
|
_fragLibraryFiles = LibraryFilesFragment.newInstance();
|
||||||
_fragLibrarySearch = LibrarySearchFragment.newInstance();
|
_fragLibrarySearch = LibrarySearchFragment.newInstance();
|
||||||
|
_fragNotifications = NotificationOverlayView.Frag();
|
||||||
_fragSettings = SettingsFragment.newInstance();
|
_fragSettings = SettingsFragment.newInstance();
|
||||||
_fragDeveloper = DeveloperFragment.newInstance();
|
_fragDeveloper = DeveloperFragment.newInstance();
|
||||||
_fragLogin = LoginFragment.newInstance();
|
_fragLogin = LoginFragment.newInstance();
|
||||||
@@ -418,12 +413,17 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
updateSegmentPaddings();
|
updateSegmentPaddings();
|
||||||
};
|
};
|
||||||
_fragVideoDetail.onTransitioning.subscribe {
|
_fragVideoDetail.onTransitioning.subscribe {
|
||||||
if (it || _fragVideoDetail.state != VideoDetailFragment.State.MINIMIZED)
|
if (it || _fragVideoDetail.state != VideoDetailFragment.State.MINIMIZED) {
|
||||||
|
Logger.i(TAG, "onTransition Setting elevation higher");
|
||||||
_fragContainerOverlay.elevation =
|
_fragContainerOverlay.elevation =
|
||||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15f, resources.displayMetrics);
|
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15f, resources.displayMetrics);
|
||||||
else
|
}
|
||||||
|
else {
|
||||||
|
Logger.i(TAG, "onTransition Setting elevation lower");
|
||||||
_fragContainerOverlay.elevation =
|
_fragContainerOverlay.elevation =
|
||||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics);
|
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_fragVideoDetail.onCloseEvent.subscribe {
|
_fragVideoDetail.onCloseEvent.subscribe {
|
||||||
@@ -541,6 +541,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
_fragLibrarySearch.topBar = _fragTopBarSearch;
|
_fragLibrarySearch.topBar = _fragTopBarSearch;
|
||||||
_fragSettings.topBar = _fragTopBarNavigation;
|
_fragSettings.topBar = _fragTopBarNavigation;
|
||||||
_fragDeveloper.topBar = _fragTopBarNavigation;
|
_fragDeveloper.topBar = _fragTopBarNavigation;
|
||||||
|
_fragNotifications.topBar = _fragTopBarGeneral;
|
||||||
|
|
||||||
_fragBrowser.topBar = _fragTopBarNavigation;
|
_fragBrowser.topBar = _fragTopBarNavigation;
|
||||||
|
|
||||||
@@ -566,7 +567,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
defaultTab.action(_fragBotBarMenu);
|
defaultTab.action(_fragBotBarMenu);
|
||||||
StateSubscriptions.instance;
|
StateSubscriptions.instance;
|
||||||
|
|
||||||
fragCurrent.onShown(null, false);
|
fragCurrent?.onShown(null, false);
|
||||||
|
|
||||||
//Other stuff
|
//Other stuff
|
||||||
rootView.progress = 0f;
|
rootView.progress = 0f;
|
||||||
@@ -621,6 +622,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
|
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Settings.instance.autoUpdate.isAutoUpdateEnabled() && Settings.instance.autoUpdate.shouldBackgroundDownload) {
|
||||||
|
requestNotificationPermissions("You have enabled background updating.\n\nGrayjay uses notifications to inform you when a new app update is available.");
|
||||||
|
}
|
||||||
|
|
||||||
val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus")
|
val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus")
|
||||||
|
|
||||||
val numSubscriptions = StateSubscriptions.instance.getSubscriptionCount()
|
val numSubscriptions = StateSubscriptions.instance.getSubscriptionCount()
|
||||||
@@ -1153,7 +1158,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
if (_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.onBackPressed())
|
if (_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.onBackPressed())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (!fragCurrent.onBackPressed())
|
if (!(fragCurrent?.onBackPressed() ?: true))
|
||||||
closeSegment();
|
closeSegment();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1231,27 +1236,27 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fragCurrent.onHide();
|
fragCurrent?.onHide();
|
||||||
|
|
||||||
if (segment.isMainView) {
|
if (segment.isMainView) {
|
||||||
var transaction = supportFragmentManager.beginTransaction();
|
var transaction = supportFragmentManager.beginTransaction();
|
||||||
if (segment.topBar != null) {
|
if (segment.topBar != null) {
|
||||||
if (segment.topBar != fragCurrent.topBar) {
|
if (segment.topBar != fragCurrent?.topBar) {
|
||||||
transaction = transaction
|
transaction = transaction
|
||||||
.show(segment.topBar as Fragment)
|
.show(segment.topBar as Fragment)
|
||||||
.replace(R.id.fragment_top_bar, segment.topBar as Fragment);
|
.replace(R.id.fragment_top_bar, segment.topBar as Fragment);
|
||||||
fragCurrent.topBar?.onHide();
|
fragCurrent?.topBar?.onHide();
|
||||||
}
|
}
|
||||||
} else if (fragCurrent.topBar != null)
|
} else if (fragCurrent?.topBar != null)
|
||||||
transaction.hide(fragCurrent.topBar as Fragment);
|
transaction.hide(fragCurrent?.topBar as Fragment);
|
||||||
|
|
||||||
transaction = transaction.replace(R.id.fragment_main, segment);
|
transaction = transaction.replace(R.id.fragment_main, segment);
|
||||||
|
|
||||||
if (segment.hasBottomBar) {
|
if (segment.hasBottomBar) {
|
||||||
if (!fragCurrent.hasBottomBar)
|
if (!(fragCurrent?.hasBottomBar ?: false))
|
||||||
transaction = transaction.show(_fragBotBarMenu);
|
transaction = transaction.show(_fragBotBarMenu);
|
||||||
} else {
|
} else {
|
||||||
if (fragCurrent.hasBottomBar)
|
if (fragCurrent?.hasBottomBar ?: false)
|
||||||
transaction = transaction.hide(_fragBotBarMenu);
|
transaction = transaction.hide(_fragBotBarMenu);
|
||||||
}
|
}
|
||||||
transaction.commitNow();
|
transaction.commitNow();
|
||||||
@@ -1264,10 +1269,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fragCurrent.isHistory && withHistory && _queue.lastOrNull() != fragCurrent)
|
if (fragCurrent?.isHistory ?: false && withHistory && _queue.lastOrNull() != fragCurrent)
|
||||||
_queue.add(Pair(fragCurrent, _parameterCurrent));
|
_queue.add(Pair(fragCurrent!!, _parameterCurrent));
|
||||||
|
|
||||||
if (segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory)
|
if (segment.isOverlay && !(fragCurrent?.isOverlay ?: false) && withHistory)// && fragCurrent.isHistory)
|
||||||
fragBeforeOverlay = fragCurrent;
|
fragBeforeOverlay = fragCurrent;
|
||||||
|
|
||||||
fragCurrent = segment;
|
fragCurrent = segment;
|
||||||
@@ -1298,11 +1303,24 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
navigate(last.first, last.second, false, true);
|
navigate(last.first, last.second, false, true);
|
||||||
} else {
|
} else {
|
||||||
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
|
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
|
||||||
|
Logger.i(TAG, "Closing activity because _fragVideoDetail.state == closed");
|
||||||
finish();
|
finish();
|
||||||
} else {
|
} else {
|
||||||
|
//UIDialogs.toast("Grayjay continues in background because of an open video.")
|
||||||
|
if(Settings.instance.playback.isBackgroundPictureInPicture()) {
|
||||||
|
try {
|
||||||
|
_fragVideoDetail._viewDetail?.startPictureInPicture();
|
||||||
|
_fragVideoDetail?.forcePictureInPicture();
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
} //Fail silently
|
||||||
|
}
|
||||||
|
else
|
||||||
|
moveTaskToBack(false);
|
||||||
|
/*
|
||||||
UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", {
|
UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", {
|
||||||
finish();
|
finish();
|
||||||
})
|
})
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1354,6 +1372,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
LibraryVideosFragment::class -> _fragLibraryVideos as T;
|
LibraryVideosFragment::class -> _fragLibraryVideos as T;
|
||||||
LibraryFilesFragment::class -> _fragLibraryFiles as T;
|
LibraryFilesFragment::class -> _fragLibraryFiles as T;
|
||||||
LibrarySearchFragment::class -> _fragLibrarySearch as T;
|
LibrarySearchFragment::class -> _fragLibrarySearch as T;
|
||||||
|
NotificationOverlayView.Frag::class -> _fragNotifications as T;
|
||||||
SettingsFragment:: class -> _fragSettings as T;
|
SettingsFragment:: class -> _fragSettings as T;
|
||||||
DeveloperFragment::class -> _fragDeveloper as T;
|
DeveloperFragment::class -> _fragDeveloper as T;
|
||||||
LoginFragment::class -> _fragLogin as T;
|
LoginFragment::class -> _fragLogin as T;
|
||||||
@@ -1364,7 +1383,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
|
|
||||||
private fun updateSegmentPaddings() {
|
private fun updateSegmentPaddings() {
|
||||||
var paddingBottom = 0f;
|
var paddingBottom = 0f;
|
||||||
if (fragCurrent.hasBottomBar)
|
if (fragCurrent?.hasBottomBar ?: false)
|
||||||
paddingBottom += HEIGHT_MENU_DP;
|
paddingBottom += HEIGHT_MENU_DP;
|
||||||
|
|
||||||
_fragContainerOverlay.setPadding(
|
_fragContainerOverlay.setPadding(
|
||||||
|
|||||||
+318
@@ -0,0 +1,318 @@
|
|||||||
|
package com.futo.platformplayer.api.http.server.handlers
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import com.futo.platformplayer.api.http.server.HttpContext
|
||||||
|
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class HttpContentUriHandler(
|
||||||
|
method: String,
|
||||||
|
path: String,
|
||||||
|
private val contentResolver: ContentResolver,
|
||||||
|
private val uri: Uri,
|
||||||
|
private val explicitContentType: String? = null
|
||||||
|
) : HttpHandler(method, path) {
|
||||||
|
|
||||||
|
override fun handle(httpContext: HttpContext) {
|
||||||
|
val resolver = contentResolver
|
||||||
|
val requestHeaders = httpContext.headers
|
||||||
|
val responseHeaders = this.headers.clone()
|
||||||
|
|
||||||
|
val meta = try {
|
||||||
|
queryMetadata(resolver, uri)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to query metadata for $uri", e)
|
||||||
|
httpContext.respondCode(404, responseHeaders)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val contentType = explicitContentType
|
||||||
|
?: resolver.getType(uri)
|
||||||
|
?: "application/octet-stream"
|
||||||
|
responseHeaders["Content-Type"] = contentType
|
||||||
|
|
||||||
|
meta.lastModifiedMillis?.let { lastModified ->
|
||||||
|
responseHeaders["Last-Modified"] = httpDateFormat.format(Date(lastModified))
|
||||||
|
|
||||||
|
val ifModifiedSinceHeader = requestHeaders["If-Modified-Since"]
|
||||||
|
if (ifModifiedSinceHeader != null) {
|
||||||
|
val ifModifiedSince = try {
|
||||||
|
httpDateFormat.parse(ifModifiedSinceHeader)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ifModifiedSince != null && lastModified <= ifModifiedSince.time) {
|
||||||
|
httpContext.respondCode(304, responseHeaders)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val safeName = (meta.displayName ?: "content.bin").replace("\"", "\\\"")
|
||||||
|
responseHeaders["Content-Disposition"] = "attachment; filename=\"$safeName\""
|
||||||
|
|
||||||
|
val length = meta.size
|
||||||
|
if (length == null) {
|
||||||
|
Logger.i(TAG, "Streaming $uri with unknown length; Range not supported")
|
||||||
|
responseHeaders.remove("Content-Length")
|
||||||
|
responseHeaders.remove("Content-Range")
|
||||||
|
responseHeaders.remove("Accept-Ranges")
|
||||||
|
|
||||||
|
stream(
|
||||||
|
httpContext = httpContext,
|
||||||
|
resolver = resolver,
|
||||||
|
uri = uri,
|
||||||
|
statusCode = 200,
|
||||||
|
headers = responseHeaders,
|
||||||
|
start = null,
|
||||||
|
length = null
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
responseHeaders["Accept-Ranges"] = "bytes"
|
||||||
|
|
||||||
|
val rangeHeader = requestHeaders["Range"]
|
||||||
|
if (rangeHeader.isNullOrBlank()) {
|
||||||
|
responseHeaders["Content-Length"] = length.toString()
|
||||||
|
Logger.i(TAG, "Sending full content for $uri, length=$length")
|
||||||
|
|
||||||
|
stream(
|
||||||
|
httpContext = httpContext,
|
||||||
|
resolver = resolver,
|
||||||
|
uri = uri,
|
||||||
|
statusCode = 200,
|
||||||
|
headers = responseHeaders,
|
||||||
|
start = 0L,
|
||||||
|
length = length
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val range = parseRange(rangeHeader, length)
|
||||||
|
if (range == null) {
|
||||||
|
Logger.w(TAG, "Invalid Range '$rangeHeader' for $uri (length=$length)")
|
||||||
|
responseHeaders["Content-Range"] = "bytes */$length"
|
||||||
|
httpContext.respondCode(416, responseHeaders)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val start = range.first
|
||||||
|
val endInclusive = range.last
|
||||||
|
val bytesToSend = endInclusive - start + 1
|
||||||
|
|
||||||
|
responseHeaders["Content-Range"] = "bytes $start-$endInclusive/$length"
|
||||||
|
responseHeaders["Content-Length"] = bytesToSend.toString()
|
||||||
|
Logger.i(TAG, "Sending range $start-$endInclusive (length=$bytesToSend) of $length for $uri")
|
||||||
|
|
||||||
|
stream(
|
||||||
|
httpContext = httpContext,
|
||||||
|
resolver = resolver,
|
||||||
|
uri = uri,
|
||||||
|
statusCode = 206,
|
||||||
|
headers = responseHeaders,
|
||||||
|
start = start,
|
||||||
|
length = bytesToSend
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ContentMeta(
|
||||||
|
val displayName: String?,
|
||||||
|
val size: Long?,
|
||||||
|
val lastModifiedMillis: Long?
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun queryMetadata(resolver: ContentResolver, uri: Uri): ContentMeta {
|
||||||
|
var displayName: String? = null
|
||||||
|
var size: Long? = null
|
||||||
|
var lastModifiedMillis: Long? = null
|
||||||
|
|
||||||
|
resolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||||
|
if (nameIndex != -1 && !cursor.isNull(nameIndex)) {
|
||||||
|
displayName = cursor.getString(nameIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||||
|
if (sizeIndex != -1 && !cursor.isNull(sizeIndex)) {
|
||||||
|
val s = cursor.getLong(sizeIndex)
|
||||||
|
if (s >= 0) size = s // -1 means unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
val dateModifiedIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED)
|
||||||
|
if (dateModifiedIndex != -1 && !cursor.isNull(dateModifiedIndex)) {
|
||||||
|
val seconds = cursor.getLong(dateModifiedIndex)
|
||||||
|
if (seconds > 0) {
|
||||||
|
lastModifiedMillis = seconds * 1000L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastModifiedMillis == null) {
|
||||||
|
val dateAddedIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED)
|
||||||
|
if (dateAddedIndex != -1 && !cursor.isNull(dateAddedIndex)) {
|
||||||
|
val seconds = cursor.getLong(dateAddedIndex)
|
||||||
|
if (seconds > 0) {
|
||||||
|
lastModifiedMillis = seconds * 1000L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayName == null) {
|
||||||
|
displayName = uri.lastPathSegment
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size == null) {
|
||||||
|
try {
|
||||||
|
resolver.openAssetFileDescriptor(uri, "r")?.use { afd ->
|
||||||
|
val assetLen = afd.length
|
||||||
|
if (assetLen >= 0) {
|
||||||
|
size = assetLen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return ContentMeta(
|
||||||
|
displayName = displayName,
|
||||||
|
size = size,
|
||||||
|
lastModifiedMillis = lastModifiedMillis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseRange(header: String, totalLength: Long): LongRange? {
|
||||||
|
if (totalLength <= 0L) return null
|
||||||
|
|
||||||
|
val prefix = "bytes="
|
||||||
|
if (!header.startsWith(prefix, ignoreCase = true)) return null
|
||||||
|
|
||||||
|
val spec = header.substring(prefix.length).trim()
|
||||||
|
if (spec.isEmpty()) return null
|
||||||
|
|
||||||
|
if (spec.contains(",")) return null
|
||||||
|
|
||||||
|
val dashIndex = spec.indexOf('-')
|
||||||
|
if (dashIndex < 0) return null
|
||||||
|
|
||||||
|
val startPart = spec.substring(0, dashIndex).trim()
|
||||||
|
val endPart = spec.substring(dashIndex + 1).trim()
|
||||||
|
|
||||||
|
return when {
|
||||||
|
startPart.isNotEmpty() -> {
|
||||||
|
val start = startPart.toLongOrNull() ?: return null
|
||||||
|
if (start < 0 || start >= totalLength) return null
|
||||||
|
|
||||||
|
val end = if (endPart.isNotEmpty()) {
|
||||||
|
val rawEnd = endPart.toLongOrNull() ?: return null
|
||||||
|
if (rawEnd < start) return null
|
||||||
|
rawEnd.coerceAtMost(totalLength - 1)
|
||||||
|
} else {
|
||||||
|
totalLength - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
start..end
|
||||||
|
}
|
||||||
|
|
||||||
|
endPart.isNotEmpty() -> {
|
||||||
|
val suffixLen = endPart.toLongOrNull() ?: return null
|
||||||
|
if (suffixLen <= 0L) return null
|
||||||
|
|
||||||
|
if (suffixLen >= totalLength) {
|
||||||
|
0L..(totalLength - 1)
|
||||||
|
} else {
|
||||||
|
val start = totalLength - suffixLen
|
||||||
|
val end = totalLength - 1
|
||||||
|
start..end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stream(httpContext: HttpContext, resolver: ContentResolver, uri: Uri, statusCode: Int, headers: HttpHeaders, start: Long?, length: Long?) {
|
||||||
|
try {
|
||||||
|
val input = resolver.openInputStream(uri)
|
||||||
|
if (input == null) {
|
||||||
|
Logger.w(TAG, "Content not found: $uri")
|
||||||
|
httpContext.respondCode(404, headers)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
input.use { inputStream ->
|
||||||
|
httpContext.respond(statusCode, headers) { outputStream ->
|
||||||
|
try {
|
||||||
|
val offset = start ?: 0L
|
||||||
|
if (offset > 0L) {
|
||||||
|
skipFully(inputStream, offset)
|
||||||
|
}
|
||||||
|
copyStream(inputStream, outputStream, length)
|
||||||
|
outputStream.flush()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Error while streaming $uri (start=$start, length=$length)", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: FileNotFoundException) {
|
||||||
|
Logger.w(TAG, "Content not found: $uri", e)
|
||||||
|
httpContext.respondCode(404, headers)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to open stream for $uri", e)
|
||||||
|
httpContext.respondCode(500, headers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyStream(input: InputStream, output: OutputStream, limit: Long?) {
|
||||||
|
val buffer = ByteArray(8192)
|
||||||
|
if (limit == null) {
|
||||||
|
while (true) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read < 0) break
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var remaining = limit
|
||||||
|
while (remaining > 0L) {
|
||||||
|
val toRead = remaining.coerceAtMost(buffer.size.toLong()).toInt()
|
||||||
|
val read = input.read(buffer, 0, toRead)
|
||||||
|
if (read < 0) break
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
remaining -= read.toLong()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun skipFully(input: InputStream, bytesToSkip: Long) {
|
||||||
|
var remaining = bytesToSkip
|
||||||
|
while (remaining > 0L) {
|
||||||
|
val skipped = input.skip(remaining)
|
||||||
|
if (skipped <= 0L) {
|
||||||
|
val b = input.read()
|
||||||
|
if (b == -1) break
|
||||||
|
remaining -= 1L
|
||||||
|
} else {
|
||||||
|
remaining -= skipped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "HttpContentUriHandler"
|
||||||
|
|
||||||
|
private val httpDateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US).apply {
|
||||||
|
timeZone = TimeZone.getTimeZone("GMT")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -12,6 +12,9 @@ class DashManifestSource : IVideoSource, IDashManifestSource {
|
|||||||
|
|
||||||
override var priority: Boolean = false;
|
override var priority: Boolean = false;
|
||||||
|
|
||||||
|
override val language: String? = null;
|
||||||
|
override val original: Boolean? = false;
|
||||||
|
|
||||||
constructor(url : String) {
|
constructor(url : String) {
|
||||||
this.url = url;
|
this.url = url;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -12,6 +12,9 @@ class HLSManifestSource : IVideoSource, IHLSManifestSource {
|
|||||||
|
|
||||||
override var priority: Boolean = false;
|
override var priority: Boolean = false;
|
||||||
|
|
||||||
|
override val language: String? = null;
|
||||||
|
override val original: Boolean? = false;
|
||||||
|
|
||||||
constructor(url : String) {
|
constructor(url : String) {
|
||||||
this.url = url;
|
this.url = url;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -14,6 +14,9 @@ class HLSVariantVideoUrlSource(
|
|||||||
override val priority: Boolean,
|
override val priority: Boolean,
|
||||||
val url: String
|
val url: String
|
||||||
) : IVideoUrlSource {
|
) : IVideoUrlSource {
|
||||||
|
override val language: String? = null;
|
||||||
|
override val original: Boolean? = false;
|
||||||
|
|
||||||
override fun getVideoUrl(): String {
|
override fun getVideoUrl(): String {
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -9,4 +9,6 @@ interface IVideoSource {
|
|||||||
val bitrate : Int?;
|
val bitrate : Int?;
|
||||||
val duration: Long;
|
val duration: Long;
|
||||||
val priority: Boolean;
|
val priority: Boolean;
|
||||||
|
val language: String?;
|
||||||
|
val original: Boolean?;
|
||||||
}
|
}
|
||||||
+4
@@ -16,6 +16,10 @@ class LocalVideoSource : IVideoSource, IStreamMetaDataSource {
|
|||||||
|
|
||||||
override var priority: Boolean = false;
|
override var priority: Boolean = false;
|
||||||
|
|
||||||
|
override val language: String? = null;
|
||||||
|
override val original: Boolean? = false;
|
||||||
|
|
||||||
|
|
||||||
val filePath : String;
|
val filePath : String;
|
||||||
val fileSize : Long;
|
val fileSize : Long;
|
||||||
|
|
||||||
|
|||||||
+3
@@ -19,6 +19,9 @@ open class VideoUrlSource(
|
|||||||
) : IVideoUrlSource, IStreamMetaDataSource {
|
) : IVideoUrlSource, IStreamMetaDataSource {
|
||||||
override var streamMetaData: StreamMetaData? = null;
|
override var streamMetaData: StreamMetaData? = null;
|
||||||
|
|
||||||
|
override val language: String? = null;
|
||||||
|
override val original: Boolean? = false;
|
||||||
|
|
||||||
override fun getVideoUrl() : String {
|
override fun getVideoUrl() : String {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -73,10 +73,10 @@ open class LocalVideoDetails(
|
|||||||
override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false)
|
override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false)
|
||||||
(LocalVideoUnMuxedSourceDescriptor(
|
(LocalVideoUnMuxedSourceDescriptor(
|
||||||
arrayOf(),
|
arrayOf(),
|
||||||
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name))
|
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name, duration))
|
||||||
))
|
))
|
||||||
else (LocalVideoMuxedSourceDescriptor(
|
else (LocalVideoMuxedSourceDescriptor(
|
||||||
LocalVideoContentSource(url, mimeType ?: "", name)
|
LocalVideoContentSource(url, mimeType ?: "", name, duration)
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
override val preview: ISerializedVideoSourceDescriptor? = null;
|
override val preview: ISerializedVideoSourceDescriptor? = null;
|
||||||
|
|||||||
@@ -153,8 +153,8 @@ open class JSClient : IPlatformClient {
|
|||||||
_captcha = descriptor.getCaptchaData();
|
_captcha = descriptor.getCaptchaData();
|
||||||
flags = descriptor.flags.toTypedArray();
|
flags = descriptor.flags.toTypedArray();
|
||||||
|
|
||||||
_httpClient = JSHttpClient(this, null, _captcha);
|
_httpClient = JSHttpClient(this, null, _captcha, config);
|
||||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha);
|
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
|
||||||
_plugin = V8Plugin(context, descriptor.config, null, _httpClient, _httpClientAuth);
|
_plugin = V8Plugin(context, descriptor.config, null, _httpClient, _httpClientAuth);
|
||||||
_plugin.withDependency(context, "scripts/polyfil.js");
|
_plugin.withDependency(context, "scripts/polyfil.js");
|
||||||
_plugin.withDependency(context, "scripts/source.js");
|
_plugin.withDependency(context, "scripts/source.js");
|
||||||
@@ -186,8 +186,8 @@ open class JSClient : IPlatformClient {
|
|||||||
_captcha = descriptor.getCaptchaData();
|
_captcha = descriptor.getCaptchaData();
|
||||||
flags = descriptor.flags.toTypedArray();
|
flags = descriptor.flags.toTypedArray();
|
||||||
|
|
||||||
_httpClient = JSHttpClient(this, null, _captcha);
|
_httpClient = JSHttpClient(this, null, _captcha, config);
|
||||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha);
|
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
|
||||||
_plugin = V8Plugin(context, descriptor.config, script, _httpClient, _httpClientAuth);
|
_plugin = V8Plugin(context, descriptor.config, script, _httpClient, _httpClientAuth);
|
||||||
_plugin.withDependency(context, "scripts/polyfil.js");
|
_plugin.withDependency(context, "scripts/polyfil.js");
|
||||||
_plugin.withDependency(context, "scripts/source.js");
|
_plugin.withDependency(context, "scripts/source.js");
|
||||||
|
|||||||
+13
-1
@@ -61,6 +61,11 @@ class SourcePluginConfig(
|
|||||||
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
|
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
|
||||||
val absoluteScriptUrl: String get() = resolveAbsoluteUrl(scriptUrl, sourceUrl)!!;
|
val absoluteScriptUrl: String get() = resolveAbsoluteUrl(scriptUrl, sourceUrl)!!;
|
||||||
|
|
||||||
|
fun isOfficialAuthor(): Boolean {
|
||||||
|
return scriptSignature != null &&
|
||||||
|
scriptPublicKey == "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsoFJU4AReDyUnSQI9A99UjLCwkY8OH+1o8cdtf2EjSb+fO2qmP8MGMTAvfvgmq5d2QBJE2XHRkRO3JKcTlcc1j0WlOlU8P9W272DYCeX6oYaavpKNqGKoGEuodp9wtiyNwyH46++JfpU/uIUacZbZKkHv9gIGchmNvpKYZQjFd/8pUqXGpcXZP54tGSC9PLcY+5TozZThK7Oy1+3YEf1bZ44UinRYYATbLk/wNuAfsupvlt6nxZOcJhABhdo9V+gY0FE6Ayg5+1cd1noWhnRtLF+sPdEr3z8Nt15JEK5a/524t25FMhwz8yKxlGW5qW3QLJHSUgLQncL6a1zlZ1s8QIDAQAB"
|
||||||
|
}
|
||||||
|
|
||||||
private fun resolveAbsoluteUrl(url: String?, sourceUrl: String?): String? {
|
private fun resolveAbsoluteUrl(url: String?, sourceUrl: String?): String? {
|
||||||
if(url == null)
|
if(url == null)
|
||||||
return null;
|
return null;
|
||||||
@@ -165,6 +170,12 @@ class SourcePluginConfig(
|
|||||||
"Unrestricted Http Header access",
|
"Unrestricted Http Header access",
|
||||||
"Allows this plugin to access all headers (including cookies and authorization headers) for unauthenticated requests."
|
"Allows this plugin to access all headers (including cookies and authorization headers) for unauthenticated requests."
|
||||||
))
|
))
|
||||||
|
if(packagesOptional.contains("Browser") || packages.contains("Browser")) {
|
||||||
|
list.add(Pair(
|
||||||
|
"Browser Interop",
|
||||||
|
"This plugin requires webbrowser interop. May access urls outside of the restricted urls. This will only work for official plugins and during development builds."
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
@@ -224,7 +235,8 @@ class SourcePluginConfig(
|
|||||||
val variable: String? = null,
|
val variable: String? = null,
|
||||||
val dependency: String? = null,
|
val dependency: String? = null,
|
||||||
val warningDialog: String? = null,
|
val warningDialog: String? = null,
|
||||||
val options: List<String>? = null
|
val options: List<String>? = null,
|
||||||
|
val isAdvanced: Boolean? = null
|
||||||
) {
|
) {
|
||||||
val variableOrName: String get() = variable ?: name;
|
val variableOrName: String get() = variable ?: name;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -23,6 +23,7 @@ import java.util.UUID
|
|||||||
class JSHttpClient : ManagedHttpClient {
|
class JSHttpClient : ManagedHttpClient {
|
||||||
private val _jsClient: JSClient?;
|
private val _jsClient: JSClient?;
|
||||||
private val _jsConfig: SourcePluginConfig?;
|
private val _jsConfig: SourcePluginConfig?;
|
||||||
|
val config get() = _jsConfig
|
||||||
private val _auth: SourceAuth?;
|
private val _auth: SourceAuth?;
|
||||||
private val _captcha: SourceCaptchaData?;
|
private val _captcha: SourceCaptchaData?;
|
||||||
|
|
||||||
|
|||||||
+30
-4
@@ -17,11 +17,14 @@ import com.futo.platformplayer.getOrThrow
|
|||||||
import com.futo.platformplayer.invokeV8
|
import com.futo.platformplayer.invokeV8
|
||||||
import com.futo.platformplayer.invokeV8Void
|
import com.futo.platformplayer.invokeV8Void
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StateDeveloper
|
import com.futo.platformplayer.states.StateDeveloper
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
|
|
||||||
class JSRequestExecutor {
|
class JSRequestExecutor: AutoCloseable {
|
||||||
private val _plugin: JSClient;
|
private val _plugin: JSClient;
|
||||||
private val _config: IV8PluginConfig;
|
private val _config: IV8PluginConfig;
|
||||||
private var _executor: V8ValueObject;
|
private var _executor: V8ValueObject;
|
||||||
@@ -29,6 +32,9 @@ class JSRequestExecutor {
|
|||||||
|
|
||||||
private val hasCleanup: Boolean;
|
private val hasCleanup: Boolean;
|
||||||
|
|
||||||
|
private var _cleanLock = Any();
|
||||||
|
private var _cleaned: Boolean = false;
|
||||||
|
|
||||||
constructor(plugin: JSClient, executor: V8ValueObject) {
|
constructor(plugin: JSClient, executor: V8ValueObject) {
|
||||||
this._plugin = plugin;
|
this._plugin = plugin;
|
||||||
this._executor = executor;
|
this._executor = executor;
|
||||||
@@ -102,8 +108,12 @@ class JSRequestExecutor {
|
|||||||
|
|
||||||
|
|
||||||
open fun cleanup() {
|
open fun cleanup() {
|
||||||
if (!hasCleanup || _executor.isClosed)
|
synchronized(_cleanLock) {
|
||||||
return;
|
if (!hasCleanup || _executor.isClosed || _cleaned)
|
||||||
|
return;
|
||||||
|
_cleaned = true;
|
||||||
|
}
|
||||||
|
Logger.i("JSRequestExecutor", "JSRequestExecutor cleanup requested");
|
||||||
_plugin.busy {
|
_plugin.busy {
|
||||||
if(_plugin is DevJSClient)
|
if(_plugin is DevJSClient)
|
||||||
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
|
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
|
||||||
@@ -125,9 +135,25 @@ class JSRequestExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun finalize() {
|
override fun close() {
|
||||||
cleanup();
|
cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun closeAsync() {
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){
|
||||||
|
try {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e("JSRequestExecutor", "Cleanup failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
protected fun finalize() {
|
||||||
|
cleanup();
|
||||||
|
}*/
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO: are these available..?
|
//TODO: are these available..?
|
||||||
|
|||||||
+1
-1
@@ -54,7 +54,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
|
|||||||
duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
|
duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
|
||||||
priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
|
priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
|
||||||
language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN;
|
language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN;
|
||||||
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
|
original = obj.getOrNull(config, "original", contextName) ?: false;
|
||||||
hasGenerate = _obj.has("generate");
|
hasGenerate = _obj.has("generate");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
@@ -39,6 +39,10 @@ open class JSDashManifestRawSource(
|
|||||||
private val ctx = "DashRawSource"
|
private val ctx = "DashRawSource"
|
||||||
private val cfg = plugin.config
|
private val cfg = plugin.config
|
||||||
|
|
||||||
|
override val language: String? = _obj.getOrDefault(cfg, "language", ctx, null);
|
||||||
|
override val original: Boolean? = _obj.getOrDefault(cfg, "original", ctx, null);
|
||||||
|
|
||||||
|
|
||||||
override val container: String =
|
override val container: String =
|
||||||
_obj.getOrDefault<String>(cfg, "container", ctx, null) ?: "application/dash+xml"
|
_obj.getOrDefault<String>(cfg, "container", ctx, null) ?: "application/dash+xml"
|
||||||
|
|
||||||
@@ -185,6 +189,9 @@ class JSDashManifestMergingRawSource(
|
|||||||
override val priority: Boolean
|
override val priority: Boolean
|
||||||
get() = video.priority;
|
get() = video.priority;
|
||||||
|
|
||||||
|
override val language: String? get() = audio.language
|
||||||
|
override val original: Boolean? get() = audio.original;
|
||||||
|
|
||||||
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
||||||
val videoDashDef = video.generateAsync(scope);
|
val videoDashDef = video.generateAsync(scope);
|
||||||
val audioDashDef = audio.generateAsync(scope);
|
val audioDashDef = audio.generateAsync(scope);
|
||||||
|
|||||||
+6
@@ -21,6 +21,9 @@ class JSDashManifestSource : IVideoUrlSource, IDashManifestSource, JSSource {
|
|||||||
|
|
||||||
override var priority: Boolean = false;
|
override var priority: Boolean = false;
|
||||||
|
|
||||||
|
override val language: String?;
|
||||||
|
override val original: Boolean?;
|
||||||
|
|
||||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
|
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
|
||||||
val contextName = "DashSource";
|
val contextName = "DashSource";
|
||||||
val config = plugin.config;
|
val config = plugin.config;
|
||||||
@@ -29,6 +32,9 @@ class JSDashManifestSource : IVideoUrlSource, IDashManifestSource, JSSource {
|
|||||||
duration = _obj.getOrThrow(config, "duration", contextName);
|
duration = _obj.getOrThrow(config, "duration", contextName);
|
||||||
|
|
||||||
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
||||||
|
|
||||||
|
language = obj.getOrNull(config, "language", contextName);
|
||||||
|
original = obj.getOrNull(config, "original", contextName);
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getVideoUrl(): String {
|
override fun getVideoUrl(): String {
|
||||||
|
|||||||
+6
@@ -28,6 +28,9 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
|
|||||||
override val licenseUri: String
|
override val licenseUri: String
|
||||||
override val hasLicenseRequestExecutor: Boolean
|
override val hasLicenseRequestExecutor: Boolean
|
||||||
|
|
||||||
|
override val language: String?;
|
||||||
|
override val original: Boolean?;
|
||||||
|
|
||||||
@Suppress("ConvertSecondaryConstructorToPrimary")
|
@Suppress("ConvertSecondaryConstructorToPrimary")
|
||||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
|
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
|
||||||
val contextName = "DashWidevineSource"
|
val contextName = "DashWidevineSource"
|
||||||
@@ -40,6 +43,9 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
|
|||||||
|
|
||||||
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
|
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
|
||||||
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
|
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
|
||||||
|
|
||||||
|
language = _obj.getOrNull(config, "language", contextName);
|
||||||
|
original = _obj.getOrNull(config, "original", contextName);
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
|
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
|
|||||||
language = _obj.getOrThrow(config, "language", contextName);
|
language = _obj.getOrThrow(config, "language", contextName);
|
||||||
|
|
||||||
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
||||||
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
|
original = obj.getOrNull(config, "original", contextName) ?: false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+6
@@ -21,6 +21,9 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource {
|
|||||||
|
|
||||||
override var priority: Boolean = false;
|
override var priority: Boolean = false;
|
||||||
|
|
||||||
|
override val language: String?;
|
||||||
|
override val original: Boolean?;
|
||||||
|
|
||||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) {
|
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) {
|
||||||
val contextName = "HLSSource";
|
val contextName = "HLSSource";
|
||||||
val config = plugin.config;
|
val config = plugin.config;
|
||||||
@@ -30,5 +33,8 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource {
|
|||||||
duration = _obj.getOrThrow<Int>(config, "duration", contextName).toLong();
|
duration = _obj.getOrThrow<Int>(config, "duration", contextName).toLong();
|
||||||
|
|
||||||
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
||||||
|
|
||||||
|
language = _obj.getOrNull(config, "language", contextName);
|
||||||
|
original = _obj.getOrNull(config, "original", contextName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
@@ -44,6 +44,9 @@ open class JSVideoUrlSource(
|
|||||||
override var priority: Boolean =
|
override var priority: Boolean =
|
||||||
_obj.getOrDefault<Boolean>(cfg, "priority", ctx, false) ?: false
|
_obj.getOrDefault<Boolean>(cfg, "priority", ctx, false) ?: false
|
||||||
|
|
||||||
|
override val language: String? = _obj.getOrDefault(cfg, "language", ctx, null);
|
||||||
|
override val original: Boolean? = _obj.getOrDefault(cfg, "original", ctx, null);
|
||||||
|
|
||||||
override fun getVideoUrl(): String = url
|
override fun getVideoUrl(): String = url
|
||||||
|
|
||||||
override fun toString(): String =
|
override fun toString(): String =
|
||||||
|
|||||||
+2
-2
@@ -23,10 +23,10 @@ class LocalAudioContentSource : IAudioSource {
|
|||||||
|
|
||||||
var contentUrl: String;
|
var contentUrl: String;
|
||||||
|
|
||||||
constructor(contentUrl: String, mime: String, name: String? = null) {
|
constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) {
|
||||||
this.name = name ?: "File";
|
this.name = name ?: "File";
|
||||||
container = mime;
|
container = mime;
|
||||||
duration = 0;
|
this.duration = duration;
|
||||||
|
|
||||||
this.contentUrl = contentUrl;
|
this.contentUrl = contentUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -20,14 +20,17 @@ class LocalVideoContentSource: IVideoSource {
|
|||||||
override val duration: Long;
|
override val duration: Long;
|
||||||
override val priority: Boolean = false;
|
override val priority: Boolean = false;
|
||||||
|
|
||||||
|
override val language: String? = null;
|
||||||
|
override val original: Boolean? = false;
|
||||||
|
|
||||||
var contentUrl: String;
|
var contentUrl: String;
|
||||||
|
|
||||||
constructor(contentUrl: String, mime: String, name: String? = null) {
|
constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) {
|
||||||
this.name = name ?: "File";
|
this.name = name ?: "File";
|
||||||
width = 0;
|
width = 0;
|
||||||
height = 0;
|
height = 0;
|
||||||
container = mime;
|
container = mime;
|
||||||
duration = 0;
|
this.duration = duration;
|
||||||
this.contentUrl = contentUrl;
|
this.contentUrl = contentUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
@@ -20,6 +20,9 @@ class LocalVideoFileSource: IVideoSource {
|
|||||||
override val duration: Long;
|
override val duration: Long;
|
||||||
override val priority: Boolean = false;
|
override val priority: Boolean = false;
|
||||||
|
|
||||||
|
override val language: String? = null;
|
||||||
|
override val original: Boolean? = null;
|
||||||
|
|
||||||
var file: File;
|
var file: File;
|
||||||
|
|
||||||
constructor(file: File) {
|
constructor(file: File) {
|
||||||
|
|||||||
@@ -1,330 +0,0 @@
|
|||||||
package com.futo.platformplayer.casting
|
|
||||||
|
|
||||||
import android.os.Looper
|
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
|
||||||
import com.futo.platformplayer.getConnectedSocket
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
|
||||||
import com.futo.platformplayer.toInetAddress
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.net.InetAddress
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
class AirPlayCastingDevice : CastingDeviceLegacy {
|
|
||||||
//See for more info: https://nto.github.io/AirPlay
|
|
||||||
|
|
||||||
override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY;
|
|
||||||
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
|
|
||||||
override var usedRemoteAddress: InetAddress? = null;
|
|
||||||
override var localAddress: InetAddress? = null;
|
|
||||||
override val canSetVolume: Boolean get() = false;
|
|
||||||
override val canSetSpeed: Boolean get() = true;
|
|
||||||
|
|
||||||
var addresses: Array<InetAddress>? = null;
|
|
||||||
var port: Int = 0;
|
|
||||||
|
|
||||||
private var _scopeIO: CoroutineScope? = null;
|
|
||||||
private var _started: Boolean = false;
|
|
||||||
private var _sessionId: String? = null;
|
|
||||||
private val _client = ManagedHttpClient();
|
|
||||||
|
|
||||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
|
||||||
this.name = name;
|
|
||||||
this.addresses = addresses;
|
|
||||||
this.port = port;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(deviceInfo: CastingDeviceInfo) : super() {
|
|
||||||
this.name = deviceInfo.name;
|
|
||||||
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
|
|
||||||
this.port = deviceInfo.port;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAddresses(): List<InetAddress> {
|
|
||||||
return addresses?.toList() ?: listOf();
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
|
|
||||||
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(FCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
|
||||||
|
|
||||||
setTime(resumePosition);
|
|
||||||
setDuration(duration);
|
|
||||||
if (resumePosition > 0.0) {
|
|
||||||
val pos = resumePosition / duration;
|
|
||||||
Logger.i(TAG, "resumePosition: $resumePosition, duration: ${duration}, pos: $pos")
|
|
||||||
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: $pos");
|
|
||||||
} else {
|
|
||||||
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (speed != null) {
|
|
||||||
changeSpeed(speed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
|
|
||||||
throw NotImplementedError();
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun seekVideo(timeSeconds: Double) {
|
|
||||||
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
post("scrub?position=${timeSeconds}");
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun resumeVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::resumeVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isPlaying = true;
|
|
||||||
post("rate?value=1.000000");
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun pauseVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::pauseVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isPlaying = false;
|
|
||||||
post("rate?value=0.000000");
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stopVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::stopVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
post("stop");
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stopCasting() {
|
|
||||||
if (invokeInIOScopeIfRequired(::stopCasting)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
post("stop");
|
|
||||||
stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun start() {
|
|
||||||
val adrs = addresses ?: return;
|
|
||||||
if (_started) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_started = true;
|
|
||||||
_scopeIO?.cancel();
|
|
||||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
|
||||||
|
|
||||||
Logger.i(TAG, "Starting...");
|
|
||||||
|
|
||||||
_scopeIO?.launch {
|
|
||||||
try {
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
try {
|
|
||||||
val connectedSocket = getConnectedSocket(adrs.toList(), port);
|
|
||||||
if (connectedSocket == null) {
|
|
||||||
delay(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
usedRemoteAddress = connectedSocket.inetAddress;
|
|
||||||
localAddress = connectedSocket.localAddress;
|
|
||||||
connectedSocket.close();
|
|
||||||
_sessionId = UUID.randomUUID().toString();
|
|
||||||
break;
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e)
|
|
||||||
delay(1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
try {
|
|
||||||
val progressInfo = getProgress();
|
|
||||||
if (progressInfo == null) {
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
Logger.i(TAG, "Failed to retrieve progress from AirPlay device.");
|
|
||||||
delay(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectionState = CastConnectionState.CONNECTED;
|
|
||||||
|
|
||||||
val progressIndex = progressInfo.lowercase().indexOf("position: ");
|
|
||||||
if (progressIndex == -1) {
|
|
||||||
delay(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
val progress = progressInfo.substring(progressIndex + "position: ".length).toDoubleOrNull() ?: continue;
|
|
||||||
setTime(progress);
|
|
||||||
|
|
||||||
val durationIndex = progressInfo.lowercase().indexOf("duration: ");
|
|
||||||
if (durationIndex == -1) {
|
|
||||||
delay(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
val duration = progressInfo.substring(durationIndex + "duration: ".length).toDoubleOrNull() ?: continue;
|
|
||||||
setDuration(duration);
|
|
||||||
delay(1000);
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to get server info from AirPlay device.", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to setup AirPlay device connection.", e)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Logger.i(TAG, "Started.");
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stop() {
|
|
||||||
Logger.i(TAG, "Stopping...");
|
|
||||||
connectionState = CastConnectionState.DISCONNECTED;
|
|
||||||
|
|
||||||
usedRemoteAddress = null;
|
|
||||||
localAddress = null;
|
|
||||||
_started = false;
|
|
||||||
_scopeIO?.cancel();
|
|
||||||
_scopeIO = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun changeSpeed(speed: Double) {
|
|
||||||
setSpeed(speed)
|
|
||||||
post("rate?value=$speed")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
|
||||||
return CastingDeviceInfo(name!!, CastProtocolType.AIRPLAY, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getProgress(): String? {
|
|
||||||
val info = get("scrub");
|
|
||||||
Logger.i(TAG, "Progress: ${info ?: "null"}");
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getPlaybackInfo(): String? {
|
|
||||||
val playbackInfo = get("playback-info");
|
|
||||||
Logger.i(TAG, "Playback info: ${playbackInfo ?: "null"}");
|
|
||||||
return playbackInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getServerInfo(): String? {
|
|
||||||
val serverInfo = get("server-info");
|
|
||||||
Logger.i(TAG, "Server info: ${serverInfo ?: "null"}");
|
|
||||||
return serverInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun post(path: String): Boolean {
|
|
||||||
try {
|
|
||||||
val sessionId = _sessionId ?: return false;
|
|
||||||
|
|
||||||
val headers = hashMapOf(
|
|
||||||
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
|
|
||||||
"User-Agent" to "MediaControl/1.0",
|
|
||||||
"Content-Length" to "0",
|
|
||||||
"X-Apple-Session-ID" to sessionId
|
|
||||||
);
|
|
||||||
|
|
||||||
val url = "http://${usedRemoteAddress}:${port}/${path}";
|
|
||||||
|
|
||||||
Logger.i(TAG, "POST $url");
|
|
||||||
val response = _client.post(url, headers);
|
|
||||||
if (!response.isOk) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to POST $path");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun post(path: String, contentType: String, body: String): Boolean {
|
|
||||||
try {
|
|
||||||
val sessionId = _sessionId ?: return false;
|
|
||||||
|
|
||||||
val headers = hashMapOf(
|
|
||||||
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
|
|
||||||
"User-Agent" to "MediaControl/1.0",
|
|
||||||
"X-Apple-Session-ID" to sessionId,
|
|
||||||
"Content-Type" to contentType
|
|
||||||
);
|
|
||||||
|
|
||||||
val url = "http://${usedRemoteAddress}:${port}/${path}";
|
|
||||||
|
|
||||||
Logger.i(TAG, "POST $url:\n$body");
|
|
||||||
val response = _client.post(url, body, headers);
|
|
||||||
if (!response.isOk) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to POST $path $body");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun get(path: String): String? {
|
|
||||||
val sessionId = _sessionId ?: return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
val headers = hashMapOf(
|
|
||||||
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
|
|
||||||
"Content-Length" to "0",
|
|
||||||
"User-Agent" to "MediaControl/1.0",
|
|
||||||
"X-Apple-Session-ID" to sessionId
|
|
||||||
);
|
|
||||||
|
|
||||||
val url = "http://${usedRemoteAddress}:${port}/${path}";
|
|
||||||
|
|
||||||
Logger.i(TAG, "GET $url");
|
|
||||||
val response = _client.get(url, headers);
|
|
||||||
if (!response.isOk) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.body == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.body.string();
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to GET $path");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
|
|
||||||
if(Looper.getMainLooper().thread == Thread.currentThread()) {
|
|
||||||
_scopeIO?.launch { action(); }
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val TAG = "AirPlayCastingDevice";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +1,289 @@
|
|||||||
package com.futo.platformplayer.casting
|
package com.futo.platformplayer.casting
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import com.futo.platformplayer.BuildConfig
|
||||||
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||||
import org.fcast.sender_sdk.Metadata
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
import org.fcast.sender_sdk.ApplicationInfo
|
||||||
|
import org.fcast.sender_sdk.CastingDevice as RsCastingDevice
|
||||||
|
import org.fcast.sender_sdk.KeyEvent
|
||||||
|
import org.fcast.sender_sdk.MediaEvent
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
|
import org.fcast.sender_sdk.PlaybackState
|
||||||
|
import org.fcast.sender_sdk.Source
|
||||||
|
import org.fcast.sender_sdk.urlFormatIpAddr
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.Inet6Address
|
||||||
|
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
|
||||||
|
import org.fcast.sender_sdk.DeviceConnectionState
|
||||||
|
import org.fcast.sender_sdk.DeviceFeature
|
||||||
|
import org.fcast.sender_sdk.EventSubscription
|
||||||
|
import org.fcast.sender_sdk.IpAddr
|
||||||
|
import org.fcast.sender_sdk.LoadRequest
|
||||||
|
import org.fcast.sender_sdk.MediaItemEventType
|
||||||
|
import org.fcast.sender_sdk.Metadata
|
||||||
|
import org.fcast.sender_sdk.ProtocolType
|
||||||
|
|
||||||
abstract class CastingDevice {
|
enum class CastConnectionState {
|
||||||
abstract val isReady: Boolean
|
DISCONNECTED,
|
||||||
abstract val usedRemoteAddress: InetAddress?
|
CONNECTING,
|
||||||
abstract val localAddress: InetAddress?
|
CONNECTED
|
||||||
abstract val name: String?
|
}
|
||||||
abstract val onConnectionStateChanged: Event1<CastConnectionState>
|
|
||||||
abstract val onPlayChanged: Event1<Boolean>
|
|
||||||
abstract val onTimeChanged: Event1<Double>
|
|
||||||
abstract val onDurationChanged: Event1<Double>
|
|
||||||
abstract val onVolumeChanged: Event1<Double>
|
|
||||||
abstract val onSpeedChanged: Event1<Double>
|
|
||||||
abstract var connectionState: CastConnectionState
|
|
||||||
abstract val protocolType: CastProtocolType
|
|
||||||
abstract var isPlaying: Boolean
|
|
||||||
abstract val expectedCurrentTime: Double
|
|
||||||
abstract var speed: Double
|
|
||||||
abstract var time: Double
|
|
||||||
abstract var duration: Double
|
|
||||||
abstract var volume: Double
|
|
||||||
abstract fun canSetVolume(): Boolean
|
|
||||||
abstract fun canSetSpeed(): Boolean
|
|
||||||
|
|
||||||
@Throws
|
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
|
||||||
abstract fun resumePlayback()
|
enum class CastProtocolType {
|
||||||
|
CHROMECAST,
|
||||||
|
AIRPLAY,
|
||||||
|
FCAST;
|
||||||
|
|
||||||
@Throws
|
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
|
||||||
abstract fun pausePlayback()
|
override val descriptor: SerialDescriptor =
|
||||||
|
PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
|
||||||
|
|
||||||
@Throws
|
override fun serialize(encoder: Encoder, value: CastProtocolType) {
|
||||||
abstract fun stopPlayback()
|
encoder.encodeString(value.name)
|
||||||
|
}
|
||||||
|
|
||||||
@Throws
|
override fun deserialize(decoder: Decoder): CastProtocolType {
|
||||||
abstract fun seekTo(timeSeconds: Double)
|
val name = decoder.decodeString()
|
||||||
|
return when (name) {
|
||||||
|
"FASTCAST" -> FCAST // Handle the renamed case
|
||||||
|
else -> CastProtocolType.valueOf(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Throws
|
private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) {
|
||||||
abstract fun changeVolume(timeSeconds: Double)
|
is IpAddr.V4 -> Inet4Address.getByAddress(
|
||||||
|
byteArrayOf(
|
||||||
|
addr.o1.toByte(),
|
||||||
|
addr.o2.toByte(),
|
||||||
|
addr.o3.toByte(),
|
||||||
|
addr.o4.toByte()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@Throws
|
is IpAddr.V6 -> Inet6Address.getByAddress(
|
||||||
abstract fun changeSpeed(speed: Double)
|
byteArrayOf(
|
||||||
|
addr.o1.toByte(),
|
||||||
|
addr.o2.toByte(),
|
||||||
|
addr.o3.toByte(),
|
||||||
|
addr.o4.toByte(),
|
||||||
|
addr.o5.toByte(),
|
||||||
|
addr.o6.toByte(),
|
||||||
|
addr.o7.toByte(),
|
||||||
|
addr.o8.toByte(),
|
||||||
|
addr.o9.toByte(),
|
||||||
|
addr.o10.toByte(),
|
||||||
|
addr.o11.toByte(),
|
||||||
|
addr.o12.toByte(),
|
||||||
|
addr.o13.toByte(),
|
||||||
|
addr.o14.toByte(),
|
||||||
|
addr.o15.toByte(),
|
||||||
|
addr.o16.toByte()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Throws
|
// abstract class CastingDevice {
|
||||||
abstract fun connect()
|
class CastingDevice(val device: RsCastingDevice) {
|
||||||
|
// abstract val isReady: Boolean
|
||||||
|
// abstract val usedRemoteAddress: InetAddress?
|
||||||
|
// abstract val localAddress: InetAddress?
|
||||||
|
// abstract val name: String?
|
||||||
|
// abstract val onConnectionStateChanged: Event1<CastConnectionState>
|
||||||
|
// abstract val onPlayChanged: Event1<Boolean>
|
||||||
|
// abstract val onTimeChanged: Event1<Double>
|
||||||
|
// abstract val onDurationChanged: Event1<Double>
|
||||||
|
// abstract val onVolumeChanged: Event1<Double>
|
||||||
|
// abstract val onSpeedChanged: Event1<Double>
|
||||||
|
// abstract val onMediaItemEnd: Event0
|
||||||
|
// abstract var connectionState: CastConnectionState
|
||||||
|
// abstract val protocolType: CastProtocolType
|
||||||
|
// abstract var isPlaying: Boolean
|
||||||
|
// abstract val expectedCurrentTime: Double
|
||||||
|
// abstract var speed: Double
|
||||||
|
// abstract var time: Double
|
||||||
|
// abstract var duration: Double
|
||||||
|
// abstract var volume: Double
|
||||||
|
// abstract fun canSetVolume(): Boolean
|
||||||
|
// abstract fun canSetSpeed(): Boolean
|
||||||
|
|
||||||
@Throws
|
// @Throws
|
||||||
abstract fun disconnect()
|
// abstract fun resumePlayback()
|
||||||
abstract fun getDeviceInfo(): CastingDeviceInfo
|
|
||||||
abstract fun getAddresses(): List<InetAddress>
|
|
||||||
|
|
||||||
@Throws
|
// @Throws
|
||||||
abstract fun loadVideo(
|
// abstract fun pausePlayback()
|
||||||
|
|
||||||
|
// @Throws
|
||||||
|
// abstract fun stopPlayback()
|
||||||
|
|
||||||
|
// @Throws
|
||||||
|
// abstract fun seekTo(timeSeconds: Double)
|
||||||
|
|
||||||
|
// @Throws
|
||||||
|
// abstract fun changeVolume(timeSeconds: Double)
|
||||||
|
|
||||||
|
// @Throws
|
||||||
|
// abstract fun changeSpeed(speed: Double)
|
||||||
|
|
||||||
|
// @Throws
|
||||||
|
// abstract fun connect()
|
||||||
|
|
||||||
|
// @Throws
|
||||||
|
// abstract fun disconnect()
|
||||||
|
// abstract fun getDeviceInfo(): CastingDeviceInfo
|
||||||
|
// abstract fun getAddresses(): List<InetAddress>
|
||||||
|
|
||||||
|
// @Throws
|
||||||
|
// abstract fun loadVideo(
|
||||||
|
// streamType: String,
|
||||||
|
// contentType: String,
|
||||||
|
// contentId: String,
|
||||||
|
// resumePosition: Double,
|
||||||
|
// duration: Double,
|
||||||
|
// speed: Double?,
|
||||||
|
// metadata: Metadata?
|
||||||
|
// )
|
||||||
|
|
||||||
|
// @Throws
|
||||||
|
// fun loadContent(
|
||||||
|
// contentType: String,
|
||||||
|
// content: String,
|
||||||
|
// resumePosition: Double,
|
||||||
|
// duration: Double,
|
||||||
|
// speed: Double?,
|
||||||
|
// metadata: Metadata?
|
||||||
|
// )
|
||||||
|
|
||||||
|
// fun ensureThreadStarted()
|
||||||
|
|
||||||
|
class EventHandler : RsDeviceEventHandler {
|
||||||
|
var onConnectionStateChanged = Event1<DeviceConnectionState>();
|
||||||
|
var onPlayChanged = Event1<Boolean>()
|
||||||
|
var onTimeChanged = Event1<Double>()
|
||||||
|
var onDurationChanged = Event1<Double>()
|
||||||
|
var onVolumeChanged = Event1<Double>()
|
||||||
|
var onSpeedChanged = Event1<Double>()
|
||||||
|
var onMediaItemEnd = Event0()
|
||||||
|
|
||||||
|
override fun connectionStateChanged(state: DeviceConnectionState) {
|
||||||
|
onConnectionStateChanged.emit(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun volumeChanged(volume: Double) {
|
||||||
|
onVolumeChanged.emit(volume)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun timeChanged(time: Double) {
|
||||||
|
onTimeChanged.emit(time)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun playbackStateChanged(state: PlaybackState) {
|
||||||
|
onPlayChanged.emit(state == PlaybackState.PLAYING)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun durationChanged(duration: Double) {
|
||||||
|
onDurationChanged.emit(duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun speedChanged(speed: Double) {
|
||||||
|
onSpeedChanged.emit(speed)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun sourceChanged(source: Source) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun keyEvent(event: KeyEvent) {
|
||||||
|
// Unreachable
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun mediaEvent(event: MediaEvent) {
|
||||||
|
if (event.type == MediaItemEventType.END) {
|
||||||
|
onMediaItemEnd.emit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun playbackError(message: String) {
|
||||||
|
Logger.e(TAG, "Playback error: $message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val eventHandler = EventHandler()
|
||||||
|
val isReady: Boolean
|
||||||
|
get() = device.isReady()
|
||||||
|
val name: String
|
||||||
|
get() = device.name()
|
||||||
|
var usedRemoteAddress: InetAddress? = null
|
||||||
|
var localAddress: InetAddress? = null
|
||||||
|
fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME)
|
||||||
|
fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED)
|
||||||
|
|
||||||
|
val onConnectionStateChanged =
|
||||||
|
Event1<CastConnectionState>()
|
||||||
|
val onPlayChanged: Event1<Boolean>
|
||||||
|
get() = eventHandler.onPlayChanged
|
||||||
|
val onTimeChanged: Event1<Double>
|
||||||
|
get() = eventHandler.onTimeChanged
|
||||||
|
val onDurationChanged: Event1<Double>
|
||||||
|
get() = eventHandler.onDurationChanged
|
||||||
|
val onVolumeChanged: Event1<Double>
|
||||||
|
get() = eventHandler.onVolumeChanged
|
||||||
|
val onSpeedChanged: Event1<Double>
|
||||||
|
get() = eventHandler.onSpeedChanged
|
||||||
|
val onMediaItemEnd: Event0
|
||||||
|
get() = eventHandler.onMediaItemEnd
|
||||||
|
|
||||||
|
fun resumePlayback() = device.resumePlayback()
|
||||||
|
fun pausePlayback() = device.pausePlayback()
|
||||||
|
fun stopPlayback() = device.stopPlayback()
|
||||||
|
fun seekTo(timeSeconds: Double) = device.seek(timeSeconds)
|
||||||
|
fun changeVolume(newVolume: Double) {
|
||||||
|
device.changeVolume(newVolume)
|
||||||
|
volume = newVolume
|
||||||
|
}
|
||||||
|
fun changeSpeed(speed: Double) = device.changeSpeed(speed)
|
||||||
|
fun connect() = device.connect(
|
||||||
|
ApplicationInfo(
|
||||||
|
"Grayjay Android",
|
||||||
|
"${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}",
|
||||||
|
"${Build.MANUFACTURER} ${Build.MODEL}"
|
||||||
|
),
|
||||||
|
eventHandler,
|
||||||
|
1000.toULong()
|
||||||
|
)
|
||||||
|
|
||||||
|
fun disconnect() = device.disconnect()
|
||||||
|
|
||||||
|
fun getDeviceInfo(): CastingDeviceInfo {
|
||||||
|
val info = device.getDeviceInfo()
|
||||||
|
return CastingDeviceInfo(
|
||||||
|
info.name,
|
||||||
|
when (info.protocol) {
|
||||||
|
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
|
||||||
|
ProtocolType.F_CAST -> CastProtocolType.FCAST
|
||||||
|
},
|
||||||
|
addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(),
|
||||||
|
port = info.port.toInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAddresses(): List<InetAddress> = device.getAddresses().map {
|
||||||
|
ipAddrToInetAddress(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadVideo(
|
||||||
streamType: String,
|
streamType: String,
|
||||||
contentType: String,
|
contentType: String,
|
||||||
contentId: String,
|
contentId: String,
|
||||||
@@ -62,18 +291,107 @@ abstract class CastingDevice {
|
|||||||
duration: Double,
|
duration: Double,
|
||||||
speed: Double?,
|
speed: Double?,
|
||||||
metadata: Metadata?
|
metadata: Metadata?
|
||||||
|
) = device.load(
|
||||||
|
LoadRequest.Video(
|
||||||
|
contentType = contentType,
|
||||||
|
url = contentId,
|
||||||
|
resumePosition = resumePosition,
|
||||||
|
speed = speed,
|
||||||
|
volume = volume,
|
||||||
|
metadata = metadata,
|
||||||
|
requestHeaders = null,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@Throws
|
fun loadContent(
|
||||||
abstract fun loadContent(
|
|
||||||
contentType: String,
|
contentType: String,
|
||||||
content: String,
|
content: String,
|
||||||
resumePosition: Double,
|
resumePosition: Double,
|
||||||
duration: Double,
|
duration: Double,
|
||||||
speed: Double?,
|
speed: Double?,
|
||||||
metadata: Metadata?
|
metadata: Metadata?
|
||||||
|
) = device.load(
|
||||||
|
LoadRequest.Content(
|
||||||
|
contentType = contentType,
|
||||||
|
content = content,
|
||||||
|
resumePosition = resumePosition,
|
||||||
|
speed = speed,
|
||||||
|
volume = volume,
|
||||||
|
metadata = metadata,
|
||||||
|
requestHeaders = null,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
abstract fun ensureThreadStarted()
|
var connectionState = CastConnectionState.DISCONNECTED
|
||||||
}
|
val protocolType: CastProtocolType
|
||||||
|
get() = when (device.castingProtocol()) {
|
||||||
|
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
|
||||||
|
ProtocolType.F_CAST -> CastProtocolType.FCAST
|
||||||
|
}
|
||||||
|
var volume: Double = 1.0
|
||||||
|
var duration: Double = 0.0
|
||||||
|
private var lastTimeChangeTime_ms: Long = 0
|
||||||
|
var time: Double = 0.0
|
||||||
|
var speed: Double = 0.0
|
||||||
|
var isPlaying: Boolean = false
|
||||||
|
|
||||||
|
val expectedCurrentTime: Double
|
||||||
|
get() {
|
||||||
|
val diff =
|
||||||
|
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
|
||||||
|
return time + diff
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
eventHandler.onConnectionStateChanged.subscribe { newState ->
|
||||||
|
when (newState) {
|
||||||
|
is DeviceConnectionState.Connected -> {
|
||||||
|
if (device.supportsFeature(DeviceFeature.MEDIA_EVENT_SUBSCRIPTION)) {
|
||||||
|
try {
|
||||||
|
device.subscribeEvent(EventSubscription.MediaItemEnd)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to subscribe to MediaItemEnd events: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
|
||||||
|
localAddress = ipAddrToInetAddress(newState.localAddr)
|
||||||
|
connectionState = CastConnectionState.CONNECTED
|
||||||
|
onConnectionStateChanged.emit(CastConnectionState.CONNECTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> {
|
||||||
|
connectionState = CastConnectionState.CONNECTING
|
||||||
|
onConnectionStateChanged.emit(CastConnectionState.CONNECTING)
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceConnectionState.Disconnected -> {
|
||||||
|
connectionState = CastConnectionState.DISCONNECTED
|
||||||
|
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newState == DeviceConnectionState.Disconnected) {
|
||||||
|
try {
|
||||||
|
Logger.i(TAG, "Stopping device")
|
||||||
|
device.disconnect()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to stop device: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eventHandler.onPlayChanged.subscribe { isPlaying = it }
|
||||||
|
eventHandler.onTimeChanged.subscribe {
|
||||||
|
lastTimeChangeTime_ms = System.currentTimeMillis()
|
||||||
|
time = it
|
||||||
|
}
|
||||||
|
eventHandler.onDurationChanged.subscribe { duration = it }
|
||||||
|
eventHandler.onVolumeChanged.subscribe { volume = it }
|
||||||
|
eventHandler.onSpeedChanged.subscribe { speed = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ensureThreadStarted() {}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = "CastingDeviceExp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,271 +0,0 @@
|
|||||||
package com.futo.platformplayer.casting
|
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import com.futo.platformplayer.BuildConfig
|
|
||||||
import com.futo.platformplayer.constructs.Event1
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
|
||||||
import org.fcast.sender_sdk.ApplicationInfo
|
|
||||||
import org.fcast.sender_sdk.GenericKeyEvent
|
|
||||||
import org.fcast.sender_sdk.GenericMediaEvent
|
|
||||||
import org.fcast.sender_sdk.PlaybackState
|
|
||||||
import org.fcast.sender_sdk.Source
|
|
||||||
import java.net.InetAddress
|
|
||||||
import org.fcast.sender_sdk.CastingDevice as RsCastingDevice;
|
|
||||||
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
|
|
||||||
import org.fcast.sender_sdk.DeviceConnectionState
|
|
||||||
import org.fcast.sender_sdk.DeviceFeature
|
|
||||||
import org.fcast.sender_sdk.IpAddr
|
|
||||||
import org.fcast.sender_sdk.LoadRequest
|
|
||||||
import org.fcast.sender_sdk.Metadata
|
|
||||||
import org.fcast.sender_sdk.ProtocolType
|
|
||||||
import org.fcast.sender_sdk.urlFormatIpAddr
|
|
||||||
import java.net.Inet4Address
|
|
||||||
import java.net.Inet6Address
|
|
||||||
|
|
||||||
private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) {
|
|
||||||
is IpAddr.V4 -> Inet4Address.getByAddress(
|
|
||||||
byteArrayOf(
|
|
||||||
addr.o1.toByte(),
|
|
||||||
addr.o2.toByte(),
|
|
||||||
addr.o3.toByte(),
|
|
||||||
addr.o4.toByte()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
is IpAddr.V6 -> Inet6Address.getByAddress(
|
|
||||||
byteArrayOf(
|
|
||||||
addr.o1.toByte(),
|
|
||||||
addr.o2.toByte(),
|
|
||||||
addr.o3.toByte(),
|
|
||||||
addr.o4.toByte(),
|
|
||||||
addr.o5.toByte(),
|
|
||||||
addr.o6.toByte(),
|
|
||||||
addr.o7.toByte(),
|
|
||||||
addr.o8.toByte(),
|
|
||||||
addr.o9.toByte(),
|
|
||||||
addr.o10.toByte(),
|
|
||||||
addr.o11.toByte(),
|
|
||||||
addr.o12.toByte(),
|
|
||||||
addr.o13.toByte(),
|
|
||||||
addr.o14.toByte(),
|
|
||||||
addr.o15.toByte(),
|
|
||||||
addr.o16.toByte()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
|
||||||
class EventHandler : RsDeviceEventHandler {
|
|
||||||
var onConnectionStateChanged = Event1<DeviceConnectionState>();
|
|
||||||
var onPlayChanged = Event1<Boolean>()
|
|
||||||
var onTimeChanged = Event1<Double>()
|
|
||||||
var onDurationChanged = Event1<Double>()
|
|
||||||
var onVolumeChanged = Event1<Double>()
|
|
||||||
var onSpeedChanged = Event1<Double>()
|
|
||||||
|
|
||||||
override fun connectionStateChanged(state: DeviceConnectionState) {
|
|
||||||
onConnectionStateChanged.emit(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun volumeChanged(volume: Double) {
|
|
||||||
onVolumeChanged.emit(volume)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun timeChanged(time: Double) {
|
|
||||||
onTimeChanged.emit(time)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun playbackStateChanged(state: PlaybackState) {
|
|
||||||
onPlayChanged.emit(state == PlaybackState.PLAYING)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun durationChanged(duration: Double) {
|
|
||||||
onDurationChanged.emit(duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun speedChanged(speed: Double) {
|
|
||||||
onSpeedChanged.emit(speed)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun sourceChanged(source: Source) {
|
|
||||||
// TODO
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun keyEvent(event: GenericKeyEvent) {
|
|
||||||
// Unreachable
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun mediaEvent(event: GenericMediaEvent) {
|
|
||||||
// Unreachable
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun playbackError(message: String) {
|
|
||||||
Logger.e(TAG, "Playback error: $message")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val eventHandler = EventHandler()
|
|
||||||
override val isReady: Boolean
|
|
||||||
get() = device.isReady()
|
|
||||||
override val name: String
|
|
||||||
get() = device.name()
|
|
||||||
override var usedRemoteAddress: InetAddress? = null
|
|
||||||
override var localAddress: InetAddress? = null
|
|
||||||
override fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME)
|
|
||||||
override fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED)
|
|
||||||
|
|
||||||
override val onConnectionStateChanged =
|
|
||||||
Event1<CastConnectionState>()
|
|
||||||
override val onPlayChanged: Event1<Boolean>
|
|
||||||
get() = eventHandler.onPlayChanged
|
|
||||||
override val onTimeChanged: Event1<Double>
|
|
||||||
get() = eventHandler.onTimeChanged
|
|
||||||
override val onDurationChanged: Event1<Double>
|
|
||||||
get() = eventHandler.onDurationChanged
|
|
||||||
override val onVolumeChanged: Event1<Double>
|
|
||||||
get() = eventHandler.onVolumeChanged
|
|
||||||
override val onSpeedChanged: Event1<Double>
|
|
||||||
get() = eventHandler.onSpeedChanged
|
|
||||||
|
|
||||||
override fun resumePlayback() = device.resumePlayback()
|
|
||||||
override fun pausePlayback() = device.pausePlayback()
|
|
||||||
override fun stopPlayback() = device.stopPlayback()
|
|
||||||
override fun seekTo(timeSeconds: Double) = device.seek(timeSeconds)
|
|
||||||
override fun changeVolume(newVolume: Double) {
|
|
||||||
device.changeVolume(newVolume)
|
|
||||||
volume = newVolume
|
|
||||||
}
|
|
||||||
override fun changeSpeed(speed: Double) = device.changeSpeed(speed)
|
|
||||||
override fun connect() = device.connect(
|
|
||||||
ApplicationInfo(
|
|
||||||
"Grayjay Android",
|
|
||||||
"${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}",
|
|
||||||
"${Build.MANUFACTURER} ${Build.MODEL}"
|
|
||||||
),
|
|
||||||
eventHandler,
|
|
||||||
1000.toULong()
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun disconnect() = device.disconnect()
|
|
||||||
|
|
||||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
|
||||||
val info = device.getDeviceInfo()
|
|
||||||
return CastingDeviceInfo(
|
|
||||||
info.name,
|
|
||||||
when (info.protocol) {
|
|
||||||
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
|
|
||||||
ProtocolType.F_CAST -> CastProtocolType.FCAST
|
|
||||||
},
|
|
||||||
addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(),
|
|
||||||
port = info.port.toInt(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAddresses(): List<InetAddress> = device.getAddresses().map {
|
|
||||||
ipAddrToInetAddress(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadVideo(
|
|
||||||
streamType: String,
|
|
||||||
contentType: String,
|
|
||||||
contentId: String,
|
|
||||||
resumePosition: Double,
|
|
||||||
duration: Double,
|
|
||||||
speed: Double?,
|
|
||||||
metadata: Metadata?
|
|
||||||
) = device.load(
|
|
||||||
LoadRequest.Video(
|
|
||||||
contentType = contentType,
|
|
||||||
url = contentId,
|
|
||||||
resumePosition = resumePosition,
|
|
||||||
speed = speed,
|
|
||||||
volume = volume,
|
|
||||||
metadata = metadata
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun loadContent(
|
|
||||||
contentType: String,
|
|
||||||
content: String,
|
|
||||||
resumePosition: Double,
|
|
||||||
duration: Double,
|
|
||||||
speed: Double?,
|
|
||||||
metadata: Metadata?
|
|
||||||
) = device.load(
|
|
||||||
LoadRequest.Content(
|
|
||||||
contentType = contentType,
|
|
||||||
content = content,
|
|
||||||
resumePosition = resumePosition,
|
|
||||||
speed = speed,
|
|
||||||
volume = volume,
|
|
||||||
metadata = metadata,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
override var connectionState = CastConnectionState.DISCONNECTED
|
|
||||||
override val protocolType: CastProtocolType
|
|
||||||
get() = when (device.castingProtocol()) {
|
|
||||||
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
|
|
||||||
ProtocolType.F_CAST -> CastProtocolType.FCAST
|
|
||||||
}
|
|
||||||
override var volume: Double = 1.0
|
|
||||||
override var duration: Double = 0.0
|
|
||||||
private var lastTimeChangeTime_ms: Long = 0
|
|
||||||
override var time: Double = 0.0
|
|
||||||
override var speed: Double = 0.0
|
|
||||||
override var isPlaying: Boolean = false
|
|
||||||
|
|
||||||
override val expectedCurrentTime: Double
|
|
||||||
get() {
|
|
||||||
val diff =
|
|
||||||
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
|
|
||||||
return time + diff
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
eventHandler.onConnectionStateChanged.subscribe { newState ->
|
|
||||||
when (newState) {
|
|
||||||
is DeviceConnectionState.Connected -> {
|
|
||||||
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
|
|
||||||
localAddress = ipAddrToInetAddress(newState.localAddr)
|
|
||||||
connectionState = CastConnectionState.CONNECTED
|
|
||||||
onConnectionStateChanged.emit(CastConnectionState.CONNECTED)
|
|
||||||
}
|
|
||||||
|
|
||||||
DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> {
|
|
||||||
connectionState = CastConnectionState.CONNECTING
|
|
||||||
onConnectionStateChanged.emit(CastConnectionState.CONNECTING)
|
|
||||||
}
|
|
||||||
|
|
||||||
DeviceConnectionState.Disconnected -> {
|
|
||||||
connectionState = CastConnectionState.CONNECTING
|
|
||||||
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newState == DeviceConnectionState.Disconnected) {
|
|
||||||
try {
|
|
||||||
Logger.i(TAG, "Stopping device")
|
|
||||||
device.disconnect()
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to stop device: $e")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
eventHandler.onPlayChanged.subscribe { isPlaying = it }
|
|
||||||
eventHandler.onTimeChanged.subscribe {
|
|
||||||
lastTimeChangeTime_ms = System.currentTimeMillis()
|
|
||||||
time = it
|
|
||||||
}
|
|
||||||
eventHandler.onDurationChanged.subscribe { duration = it }
|
|
||||||
eventHandler.onVolumeChanged.subscribe { volume = it }
|
|
||||||
eventHandler.onSpeedChanged.subscribe { speed = it }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun ensureThreadStarted() {}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = "CastingDeviceExp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
package com.futo.platformplayer.casting
|
|
||||||
|
|
||||||
import com.futo.platformplayer.constructs.Event1
|
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
|
||||||
import kotlinx.serialization.KSerializer
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
|
||||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
|
||||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
|
||||||
import kotlinx.serialization.encoding.Decoder
|
|
||||||
import kotlinx.serialization.encoding.Encoder
|
|
||||||
import org.fcast.sender_sdk.Metadata
|
|
||||||
import java.net.InetAddress
|
|
||||||
|
|
||||||
enum class CastConnectionState {
|
|
||||||
DISCONNECTED,
|
|
||||||
CONNECTING,
|
|
||||||
CONNECTED
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
|
|
||||||
enum class CastProtocolType {
|
|
||||||
CHROMECAST,
|
|
||||||
AIRPLAY,
|
|
||||||
FCAST;
|
|
||||||
|
|
||||||
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
|
|
||||||
override val descriptor: SerialDescriptor =
|
|
||||||
PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
|
|
||||||
|
|
||||||
override fun serialize(encoder: Encoder, value: CastProtocolType) {
|
|
||||||
encoder.encodeString(value.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun deserialize(decoder: Decoder): CastProtocolType {
|
|
||||||
val name = decoder.decodeString()
|
|
||||||
return when (name) {
|
|
||||||
"FASTCAST" -> FCAST // Handle the renamed case
|
|
||||||
else -> CastProtocolType.valueOf(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract class CastingDeviceLegacy {
|
|
||||||
abstract val protocol: CastProtocolType;
|
|
||||||
abstract val isReady: Boolean;
|
|
||||||
abstract var usedRemoteAddress: InetAddress?;
|
|
||||||
abstract var localAddress: InetAddress?;
|
|
||||||
abstract val canSetVolume: Boolean;
|
|
||||||
abstract val canSetSpeed: Boolean;
|
|
||||||
|
|
||||||
var name: String? = null;
|
|
||||||
var isPlaying: Boolean = false
|
|
||||||
set(value) {
|
|
||||||
val changed = value != field;
|
|
||||||
field = value;
|
|
||||||
if (changed) {
|
|
||||||
onPlayChanged.emit(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private var lastTimeChangeTime_ms: Long = 0
|
|
||||||
var time: Double = 0.0
|
|
||||||
private set
|
|
||||||
|
|
||||||
protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
|
||||||
if (changeTime_ms > lastTimeChangeTime_ms && value != time) {
|
|
||||||
time = value
|
|
||||||
lastTimeChangeTime_ms = changeTime_ms
|
|
||||||
onTimeChanged.emit(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var lastDurationChangeTime_ms: Long = 0
|
|
||||||
var duration: Double = 0.0
|
|
||||||
private set
|
|
||||||
|
|
||||||
protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
|
||||||
if (changeTime_ms > lastDurationChangeTime_ms && value != duration) {
|
|
||||||
duration = value
|
|
||||||
lastDurationChangeTime_ms = changeTime_ms
|
|
||||||
onDurationChanged.emit(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var lastVolumeChangeTime_ms: Long = 0
|
|
||||||
var volume: Double = 1.0
|
|
||||||
private set
|
|
||||||
|
|
||||||
protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
|
||||||
if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) {
|
|
||||||
volume = value
|
|
||||||
lastVolumeChangeTime_ms = changeTime_ms
|
|
||||||
onVolumeChanged.emit(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var lastSpeedChangeTime_ms: Long = 0
|
|
||||||
var speed: Double = 1.0
|
|
||||||
private set
|
|
||||||
|
|
||||||
protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
|
||||||
if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) {
|
|
||||||
speed = value
|
|
||||||
lastSpeedChangeTime_ms = changeTime_ms
|
|
||||||
onSpeedChanged.emit(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val expectedCurrentTime: Double
|
|
||||||
get() {
|
|
||||||
val diff =
|
|
||||||
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
|
|
||||||
return time + diff;
|
|
||||||
};
|
|
||||||
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
|
|
||||||
set(value) {
|
|
||||||
val changed = value != field;
|
|
||||||
field = value;
|
|
||||||
|
|
||||||
if (changed) {
|
|
||||||
onConnectionStateChanged.emit(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var onConnectionStateChanged = Event1<CastConnectionState>();
|
|
||||||
var onPlayChanged = Event1<Boolean>();
|
|
||||||
var onTimeChanged = Event1<Double>();
|
|
||||||
var onDurationChanged = Event1<Double>();
|
|
||||||
var onVolumeChanged = Event1<Double>();
|
|
||||||
var onSpeedChanged = Event1<Double>();
|
|
||||||
|
|
||||||
abstract fun stopCasting();
|
|
||||||
|
|
||||||
abstract fun seekVideo(timeSeconds: Double);
|
|
||||||
abstract fun stopVideo();
|
|
||||||
abstract fun pauseVideo();
|
|
||||||
abstract fun resumeVideo();
|
|
||||||
abstract fun loadVideo(
|
|
||||||
streamType: String,
|
|
||||||
contentType: String,
|
|
||||||
contentId: String,
|
|
||||||
resumePosition: Double,
|
|
||||||
duration: Double,
|
|
||||||
speed: Double?
|
|
||||||
);
|
|
||||||
|
|
||||||
abstract fun loadContent(
|
|
||||||
contentType: String,
|
|
||||||
content: String,
|
|
||||||
resumePosition: Double,
|
|
||||||
duration: Double,
|
|
||||||
speed: Double?
|
|
||||||
);
|
|
||||||
|
|
||||||
open fun changeVolume(volume: Double) {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
open fun changeSpeed(speed: Double) {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract fun start();
|
|
||||||
abstract fun stop();
|
|
||||||
|
|
||||||
abstract fun getDeviceInfo(): CastingDeviceInfo;
|
|
||||||
|
|
||||||
abstract fun getAddresses(): List<InetAddress>;
|
|
||||||
}
|
|
||||||
|
|
||||||
class CastingDeviceLegacyWrapper(val inner: CastingDeviceLegacy) : CastingDevice() {
|
|
||||||
override val isReady: Boolean get() = inner.isReady
|
|
||||||
override val usedRemoteAddress: InetAddress? get() = inner.usedRemoteAddress
|
|
||||||
override val localAddress: InetAddress? get() = inner.localAddress
|
|
||||||
override val name: String? get() = inner.name
|
|
||||||
override val onConnectionStateChanged: Event1<CastConnectionState> get() = inner.onConnectionStateChanged
|
|
||||||
override val onPlayChanged: Event1<Boolean> get() = inner.onPlayChanged
|
|
||||||
override val onTimeChanged: Event1<Double> get() = inner.onTimeChanged
|
|
||||||
override val onDurationChanged: Event1<Double> get() = inner.onDurationChanged
|
|
||||||
override val onVolumeChanged: Event1<Double> get() = inner.onVolumeChanged
|
|
||||||
override val onSpeedChanged: Event1<Double> get() = inner.onSpeedChanged
|
|
||||||
override var connectionState: CastConnectionState
|
|
||||||
get() = inner.connectionState
|
|
||||||
set(_) = Unit
|
|
||||||
override val protocolType: CastProtocolType get() = inner.protocol
|
|
||||||
override var isPlaying: Boolean
|
|
||||||
get() = inner.isPlaying
|
|
||||||
set(_) = Unit
|
|
||||||
override val expectedCurrentTime: Double
|
|
||||||
get() = inner.expectedCurrentTime
|
|
||||||
override var speed: Double
|
|
||||||
get() = inner.speed
|
|
||||||
set(_) = Unit
|
|
||||||
override var time: Double
|
|
||||||
get() = inner.time
|
|
||||||
set(_) = Unit
|
|
||||||
override var duration: Double
|
|
||||||
get() = inner.duration
|
|
||||||
set(_) = Unit
|
|
||||||
override var volume: Double
|
|
||||||
get() = inner.volume
|
|
||||||
set(_) = Unit
|
|
||||||
|
|
||||||
override fun canSetVolume(): Boolean = inner.canSetVolume
|
|
||||||
override fun canSetSpeed(): Boolean = inner.canSetSpeed
|
|
||||||
override fun resumePlayback() = inner.resumeVideo()
|
|
||||||
override fun pausePlayback() = inner.pauseVideo()
|
|
||||||
override fun stopPlayback() = inner.stopVideo()
|
|
||||||
override fun seekTo(timeSeconds: Double) = inner.seekVideo(timeSeconds)
|
|
||||||
override fun changeVolume(timeSeconds: Double) = inner.changeVolume(timeSeconds)
|
|
||||||
override fun changeSpeed(speed: Double) = inner.changeSpeed(speed)
|
|
||||||
override fun connect() = inner.start()
|
|
||||||
override fun disconnect() = inner.stop()
|
|
||||||
override fun getDeviceInfo(): CastingDeviceInfo = inner.getDeviceInfo()
|
|
||||||
override fun getAddresses(): List<InetAddress> = inner.getAddresses()
|
|
||||||
override fun loadVideo(
|
|
||||||
streamType: String,
|
|
||||||
contentType: String,
|
|
||||||
contentId: String,
|
|
||||||
resumePosition: Double,
|
|
||||||
duration: Double,
|
|
||||||
speed: Double?,
|
|
||||||
metadata: Metadata?
|
|
||||||
) = inner.loadVideo(streamType, contentType, contentId, resumePosition, duration, speed)
|
|
||||||
|
|
||||||
override fun loadContent(
|
|
||||||
contentType: String,
|
|
||||||
content: String,
|
|
||||||
resumePosition: Double,
|
|
||||||
duration: Double,
|
|
||||||
speed: Double?,
|
|
||||||
metadata: Metadata?
|
|
||||||
) = inner.loadContent(contentType, content, resumePosition, duration, speed)
|
|
||||||
|
|
||||||
override fun ensureThreadStarted() = when (inner) {
|
|
||||||
is FCastCastingDevice -> inner.ensureThreadStarted()
|
|
||||||
is ChromecastCastingDevice -> inner.ensureThreadsStarted()
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,736 +0,0 @@
|
|||||||
package com.futo.platformplayer.casting
|
|
||||||
|
|
||||||
import android.os.Looper
|
|
||||||
import android.util.Log
|
|
||||||
import com.futo.platformplayer.getConnectedSocket
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
|
||||||
import com.futo.platformplayer.protos.ChromeCast
|
|
||||||
import com.futo.platformplayer.toHexString
|
|
||||||
import com.futo.platformplayer.toInetAddress
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.io.DataInputStream
|
|
||||||
import java.io.DataOutputStream
|
|
||||||
import java.net.InetAddress
|
|
||||||
import java.net.InetSocketAddress
|
|
||||||
import java.net.Socket
|
|
||||||
import java.security.cert.X509Certificate
|
|
||||||
import javax.net.ssl.SSLContext
|
|
||||||
import javax.net.ssl.SSLSocket
|
|
||||||
import javax.net.ssl.TrustManager
|
|
||||||
import javax.net.ssl.X509TrustManager
|
|
||||||
|
|
||||||
class ChromecastCastingDevice : CastingDeviceLegacy {
|
|
||||||
//See for more info: https://developers.google.com/cast/docs/media/messages
|
|
||||||
|
|
||||||
override val protocol: CastProtocolType get() = CastProtocolType.CHROMECAST;
|
|
||||||
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
|
|
||||||
override var usedRemoteAddress: InetAddress? = null;
|
|
||||||
override var localAddress: InetAddress? = null;
|
|
||||||
override val canSetVolume: Boolean get() = true;
|
|
||||||
override val canSetSpeed: Boolean get() = true;
|
|
||||||
|
|
||||||
var addresses: Array<InetAddress>? = null;
|
|
||||||
var port: Int = 0;
|
|
||||||
|
|
||||||
private var _streamType: String? = null;
|
|
||||||
private var _contentType: String? = null;
|
|
||||||
private var _contentId: String? = null;
|
|
||||||
|
|
||||||
private var _socket: SSLSocket? = null;
|
|
||||||
private var _outputStream: DataOutputStream? = null;
|
|
||||||
private var _outputStreamLock = Object();
|
|
||||||
private var _inputStream: DataInputStream? = null;
|
|
||||||
private var _inputStreamLock = Object();
|
|
||||||
private var _scopeIO: CoroutineScope? = null;
|
|
||||||
private var _requestId = 1;
|
|
||||||
private var _started: Boolean = false;
|
|
||||||
private var _sessionId: String? = null;
|
|
||||||
private var _transportId: String? = null;
|
|
||||||
private var _launching = false;
|
|
||||||
private var _mediaSessionId: Int? = null;
|
|
||||||
private var _thread: Thread? = null;
|
|
||||||
private var _pingThread: Thread? = null;
|
|
||||||
private var _launchRetries = 0
|
|
||||||
private val MAX_LAUNCH_RETRIES = 3
|
|
||||||
private var _lastLaunchTime_ms = 0L
|
|
||||||
private var _retryJob: Job? = null
|
|
||||||
private var _autoLaunchEnabled = true
|
|
||||||
|
|
||||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
|
||||||
this.name = name;
|
|
||||||
this.addresses = addresses;
|
|
||||||
this.port = port;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(deviceInfo: CastingDeviceInfo) : super() {
|
|
||||||
this.name = deviceInfo.name;
|
|
||||||
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
|
|
||||||
this.port = deviceInfo.port;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAddresses(): List<InetAddress> {
|
|
||||||
return addresses?.toList() ?: listOf();
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
|
|
||||||
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
|
||||||
|
|
||||||
setTime(resumePosition);
|
|
||||||
setDuration(duration);
|
|
||||||
_streamType = streamType;
|
|
||||||
_contentType = contentType;
|
|
||||||
_contentId = contentId;
|
|
||||||
|
|
||||||
playVideo();
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
|
|
||||||
//TODO: Can maybe be implemented by sending data:contentType,base64...
|
|
||||||
throw NotImplementedError();
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun connectMediaChannel(transportId: String) {
|
|
||||||
val connectObject = JSONObject();
|
|
||||||
connectObject.put("type", "CONNECT");
|
|
||||||
connectObject.put("connType", 0);
|
|
||||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.tp.connection", connectObject.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestMediaStatus() {
|
|
||||||
val transportId = _transportId ?: return;
|
|
||||||
|
|
||||||
val loadObject = JSONObject();
|
|
||||||
loadObject.put("type", "GET_STATUS");
|
|
||||||
loadObject.put("requestId", _requestId++);
|
|
||||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun playVideo() {
|
|
||||||
val transportId = _transportId ?: return;
|
|
||||||
val contentId = _contentId ?: return;
|
|
||||||
val streamType = _streamType ?: return;
|
|
||||||
val contentType = _contentType ?: return;
|
|
||||||
|
|
||||||
val loadObject = JSONObject();
|
|
||||||
loadObject.put("type", "LOAD");
|
|
||||||
|
|
||||||
val mediaObject = JSONObject();
|
|
||||||
mediaObject.put("contentId", contentId);
|
|
||||||
mediaObject.put("streamType", streamType);
|
|
||||||
mediaObject.put("contentType", contentType);
|
|
||||||
|
|
||||||
if (time > 0.0) {
|
|
||||||
val seekTime = time;
|
|
||||||
loadObject.put("currentTime", seekTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadObject.put("media", mediaObject);
|
|
||||||
loadObject.put("requestId", _requestId++);
|
|
||||||
|
|
||||||
|
|
||||||
//TODO: This replace is necessary to get rid of backward slashes added by the JSON Object serializer
|
|
||||||
val json = loadObject.toString().replace("\\/","/");
|
|
||||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", json);
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun changeSpeed(speed: Double) {
|
|
||||||
if (invokeInIOScopeIfRequired { changeSpeed(speed) }) return
|
|
||||||
|
|
||||||
val speedClamped = speed.coerceAtLeast(1.0).coerceAtLeast(1.0).coerceAtMost(2.0)
|
|
||||||
setSpeed(speedClamped)
|
|
||||||
val mediaSessionId = _mediaSessionId ?: return
|
|
||||||
val transportId = _transportId ?: return
|
|
||||||
val setSpeedObject = JSONObject().apply {
|
|
||||||
put("type", "SET_PLAYBACK_RATE")
|
|
||||||
put("mediaSessionId", mediaSessionId)
|
|
||||||
put("playbackRate", speedClamped)
|
|
||||||
put("requestId", _requestId++)
|
|
||||||
}
|
|
||||||
|
|
||||||
sendChannelMessage(sourceId = "sender-0", destinationId = transportId, namespace = "urn:x-cast:com.google.cast.media", json = setSpeedObject.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun changeVolume(volume: Double) {
|
|
||||||
if (invokeInIOScopeIfRequired({ changeVolume(volume) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setVolume(volume)
|
|
||||||
val setVolumeObject = JSONObject();
|
|
||||||
setVolumeObject.put("type", "SET_VOLUME");
|
|
||||||
|
|
||||||
val volumeObject = JSONObject();
|
|
||||||
volumeObject.put("level", volume)
|
|
||||||
setVolumeObject.put("volume", volumeObject);
|
|
||||||
|
|
||||||
setVolumeObject.put("requestId", _requestId++);
|
|
||||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", setVolumeObject.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun seekVideo(timeSeconds: Double) {
|
|
||||||
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val transportId = _transportId ?: return;
|
|
||||||
val mediaSessionId = _mediaSessionId ?: return;
|
|
||||||
|
|
||||||
val loadObject = JSONObject();
|
|
||||||
loadObject.put("type", "SEEK");
|
|
||||||
loadObject.put("mediaSessionId", mediaSessionId);
|
|
||||||
loadObject.put("requestId", _requestId++);
|
|
||||||
loadObject.put("currentTime", timeSeconds);
|
|
||||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun resumeVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::resumeVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val transportId = _transportId ?: return;
|
|
||||||
val mediaSessionId = _mediaSessionId ?: return;
|
|
||||||
|
|
||||||
val loadObject = JSONObject();
|
|
||||||
loadObject.put("type", "PLAY");
|
|
||||||
loadObject.put("mediaSessionId", mediaSessionId);
|
|
||||||
loadObject.put("requestId", _requestId++);
|
|
||||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun pauseVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::pauseVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val transportId = _transportId ?: return;
|
|
||||||
val mediaSessionId = _mediaSessionId ?: return;
|
|
||||||
|
|
||||||
val loadObject = JSONObject();
|
|
||||||
loadObject.put("type", "PAUSE");
|
|
||||||
loadObject.put("mediaSessionId", mediaSessionId);
|
|
||||||
loadObject.put("requestId", _requestId++);
|
|
||||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stopVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::stopVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val transportId = _transportId ?: return;
|
|
||||||
val mediaSessionId = _mediaSessionId ?: return;
|
|
||||||
_contentId = null;
|
|
||||||
_contentType = null;
|
|
||||||
_streamType = null;
|
|
||||||
|
|
||||||
val loadObject = JSONObject();
|
|
||||||
loadObject.put("type", "STOP");
|
|
||||||
loadObject.put("mediaSessionId", mediaSessionId);
|
|
||||||
loadObject.put("requestId", _requestId++);
|
|
||||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun launchPlayer() {
|
|
||||||
if (invokeInIOScopeIfRequired(::launchPlayer)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val launchObject = JSONObject();
|
|
||||||
launchObject.put("type", "LAUNCH");
|
|
||||||
launchObject.put("appId", "CC1AD845");
|
|
||||||
launchObject.put("requestId", _requestId++);
|
|
||||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
|
|
||||||
_lastLaunchTime_ms = System.currentTimeMillis()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getStatus() {
|
|
||||||
if (invokeInIOScopeIfRequired(::getStatus)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val launchObject = JSONObject();
|
|
||||||
launchObject.put("type", "GET_STATUS");
|
|
||||||
launchObject.put("requestId", _requestId++);
|
|
||||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
|
|
||||||
if(Looper.getMainLooper().thread == Thread.currentThread()) {
|
|
||||||
_scopeIO?.launch { action(); }
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stopCasting() {
|
|
||||||
if (invokeInIOScopeIfRequired(::stopCasting)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val sessionId = _sessionId;
|
|
||||||
if (sessionId != null) {
|
|
||||||
val launchObject = JSONObject();
|
|
||||||
launchObject.put("type", "STOP");
|
|
||||||
launchObject.put("sessionId", sessionId);
|
|
||||||
launchObject.put("requestId", _requestId++);
|
|
||||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
|
|
||||||
|
|
||||||
_contentId = null;
|
|
||||||
_contentType = null;
|
|
||||||
_streamType = null;
|
|
||||||
_sessionId = null;
|
|
||||||
_launchRetries = 0
|
|
||||||
_transportId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Stopping active device because stopCasting was called.")
|
|
||||||
stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun start() {
|
|
||||||
if (_started) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_autoLaunchEnabled = true
|
|
||||||
_started = true;
|
|
||||||
_sessionId = null;
|
|
||||||
_launchRetries = 0
|
|
||||||
_mediaSessionId = null;
|
|
||||||
|
|
||||||
Logger.i(TAG, "Starting...");
|
|
||||||
|
|
||||||
_launching = true;
|
|
||||||
|
|
||||||
ensureThreadsStarted();
|
|
||||||
Logger.i(TAG, "Started.");
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ensureThreadsStarted() {
|
|
||||||
val adrs = addresses ?: return;
|
|
||||||
|
|
||||||
val thread = _thread
|
|
||||||
val pingThread = _pingThread
|
|
||||||
if (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive) {
|
|
||||||
Log.i(TAG, "Restarting threads because one of the threads has died")
|
|
||||||
|
|
||||||
_scopeIO?.cancel();
|
|
||||||
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
|
|
||||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
|
||||||
|
|
||||||
_thread = Thread {
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
|
|
||||||
var connectedSocket: Socket? = null
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
try {
|
|
||||||
val resultSocket = getConnectedSocket(adrs.toList(), port);
|
|
||||||
if (resultSocket == null) {
|
|
||||||
Thread.sleep(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedSocket = resultSocket
|
|
||||||
usedRemoteAddress = connectedSocket.inetAddress;
|
|
||||||
localAddress = connectedSocket.localAddress;
|
|
||||||
break;
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
|
|
||||||
Thread.sleep(1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val sslContext = SSLContext.getInstance("TLS");
|
|
||||||
sslContext.init(null, trustAllCerts, null);
|
|
||||||
|
|
||||||
val factory = sslContext.socketFactory;
|
|
||||||
|
|
||||||
val address = InetSocketAddress(usedRemoteAddress, port)
|
|
||||||
|
|
||||||
//Connection loop
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
_sessionId = null;
|
|
||||||
_launchRetries = 0
|
|
||||||
_mediaSessionId = null;
|
|
||||||
|
|
||||||
Logger.i(TAG, "Connecting to Chromecast.");
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
|
|
||||||
try {
|
|
||||||
_socket?.close()
|
|
||||||
if (connectedSocket != null) {
|
|
||||||
Logger.i(TAG, "Using connected socket.")
|
|
||||||
_socket = factory.createSocket(connectedSocket, connectedSocket.inetAddress.hostAddress, connectedSocket.port, true) as SSLSocket
|
|
||||||
connectedSocket = null
|
|
||||||
} else {
|
|
||||||
Logger.i(TAG, "Using new socket.")
|
|
||||||
val s = Socket().apply { this.connect(address, 2000) }
|
|
||||||
_socket = factory.createSocket(s, s.inetAddress.hostAddress, s.port, true) as SSLSocket
|
|
||||||
}
|
|
||||||
|
|
||||||
_socket?.startHandshake();
|
|
||||||
Logger.i(TAG, "Successfully connected to Chromecast at $usedRemoteAddress:$port");
|
|
||||||
|
|
||||||
try {
|
|
||||||
_outputStream = DataOutputStream(_socket?.outputStream);
|
|
||||||
_inputStream = DataInputStream(_socket?.inputStream);
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.i(TAG, "Failed to authenticate to Chromecast.", e);
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
_socket?.close();
|
|
||||||
Logger.i(TAG, "Failed to connect to Chromecast.", e);
|
|
||||||
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
Thread.sleep(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
localAddress = _socket?.localAddress;
|
|
||||||
|
|
||||||
try {
|
|
||||||
val connectObject = JSONObject();
|
|
||||||
connectObject.put("type", "CONNECT");
|
|
||||||
connectObject.put("connType", 0);
|
|
||||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.connection", connectObject.toString());
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.i(TAG, "Failed to send connect message to Chromecast.", e);
|
|
||||||
_socket?.close();
|
|
||||||
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
Thread.sleep(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatus();
|
|
||||||
|
|
||||||
val buffer = ByteArray(409600);
|
|
||||||
|
|
||||||
Logger.i(TAG, "Started receiving.");
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
try {
|
|
||||||
val inputStream = _inputStream ?: break;
|
|
||||||
|
|
||||||
val message = synchronized(_inputStreamLock)
|
|
||||||
{
|
|
||||||
Log.d(TAG, "Receiving next packet...");
|
|
||||||
val b1 = inputStream.readUnsignedByte();
|
|
||||||
val b2 = inputStream.readUnsignedByte();
|
|
||||||
val b3 = inputStream.readUnsignedByte();
|
|
||||||
val b4 = inputStream.readUnsignedByte();
|
|
||||||
val size =
|
|
||||||
((b1.toLong() shl 24) or (b2.toLong() shl 16) or (b3.toLong() shl 8) or b4.toLong()).toInt();
|
|
||||||
if (size > buffer.size) {
|
|
||||||
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
|
|
||||||
inputStream.skip(size.toLong());
|
|
||||||
return@synchronized null
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
|
||||||
inputStream.read(buffer, 0, size);
|
|
||||||
|
|
||||||
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
|
|
||||||
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
|
|
||||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
|
||||||
val msg = ChromeCast.CastMessage.parseFrom(messageBytes);
|
|
||||||
if (msg.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
|
||||||
Logger.i(TAG, "Received message: $msg");
|
|
||||||
}
|
|
||||||
return@synchronized msg
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message != null) {
|
|
||||||
try {
|
|
||||||
handleMessage(message);
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to handle message.", e);
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: java.net.SocketException) {
|
|
||||||
Logger.e(TAG, "Socket exception while receiving.", e);
|
|
||||||
break;
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Exception while receiving.", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_socket?.close();
|
|
||||||
Logger.i(TAG, "Socket disconnected.");
|
|
||||||
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
Thread.sleep(1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Stopped connection loop.");
|
|
||||||
connectionState = CastConnectionState.DISCONNECTED;
|
|
||||||
}.apply { start() };
|
|
||||||
|
|
||||||
//Start ping loop
|
|
||||||
_pingThread = Thread {
|
|
||||||
Logger.i(TAG, "Started ping loop.")
|
|
||||||
|
|
||||||
val pingObject = JSONObject();
|
|
||||||
pingObject.put("type", "PING");
|
|
||||||
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
try {
|
|
||||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.heartbeat", pingObject.toString());
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.w(TAG, "Failed to send ping.");
|
|
||||||
}
|
|
||||||
|
|
||||||
Thread.sleep(5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Stopped ping loop.");
|
|
||||||
}.apply { start() };
|
|
||||||
} else {
|
|
||||||
Log.i(TAG, "Threads still alive, not restarted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendChannelMessage(sourceId: String, destinationId: String, namespace: String, json: String) {
|
|
||||||
try {
|
|
||||||
val castMessage = ChromeCast.CastMessage.newBuilder()
|
|
||||||
.setProtocolVersion(ChromeCast.CastMessage.ProtocolVersion.CASTV2_1_0)
|
|
||||||
.setSourceId(sourceId)
|
|
||||||
.setDestinationId(destinationId)
|
|
||||||
.setNamespace(namespace)
|
|
||||||
.setPayloadType(ChromeCast.CastMessage.PayloadType.STRING)
|
|
||||||
.setPayloadUtf8(json)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
sendMessage(castMessage.toByteArray());
|
|
||||||
|
|
||||||
if (namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
|
||||||
//Log.d(TAG, "Sent channel message: $castMessage");
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to send channel message (sourceId: $sourceId, destinationId: $destinationId, namespace: $namespace, json: $json)", e);
|
|
||||||
_socket?.close();
|
|
||||||
Logger.i(TAG, "Socket disconnected.");
|
|
||||||
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleMessage(message: ChromeCast.CastMessage) {
|
|
||||||
if (message.payloadType == ChromeCast.CastMessage.PayloadType.STRING) {
|
|
||||||
val jsonObject = JSONObject(message.payloadUtf8);
|
|
||||||
val type = jsonObject.getString("type");
|
|
||||||
if (type == "RECEIVER_STATUS") {
|
|
||||||
val status = jsonObject.getJSONObject("status");
|
|
||||||
|
|
||||||
var sessionIsRunning = false;
|
|
||||||
if (status.has("applications")) {
|
|
||||||
val applications = status.getJSONArray("applications");
|
|
||||||
|
|
||||||
for (i in 0 until applications.length()) {
|
|
||||||
val applicationUpdate = applications.getJSONObject(i);
|
|
||||||
|
|
||||||
val appId = applicationUpdate.getString("appId");
|
|
||||||
Logger.i(TAG, "Status update received appId (appId: $appId)");
|
|
||||||
|
|
||||||
if (appId == "CC1AD845") {
|
|
||||||
sessionIsRunning = true;
|
|
||||||
_autoLaunchEnabled = false
|
|
||||||
|
|
||||||
if (_sessionId == null) {
|
|
||||||
connectionState = CastConnectionState.CONNECTED;
|
|
||||||
_sessionId = applicationUpdate.getString("sessionId");
|
|
||||||
_launchRetries = 0
|
|
||||||
|
|
||||||
val transportId = applicationUpdate.getString("transportId");
|
|
||||||
connectMediaChannel(transportId);
|
|
||||||
Logger.i(TAG, "Connected to media channel $transportId");
|
|
||||||
_transportId = transportId;
|
|
||||||
|
|
||||||
requestMediaStatus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sessionIsRunning) {
|
|
||||||
if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
|
|
||||||
_sessionId = null
|
|
||||||
_mediaSessionId = null
|
|
||||||
_transportId = null
|
|
||||||
|
|
||||||
if (_autoLaunchEnabled) {
|
|
||||||
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
|
||||||
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
|
|
||||||
_launchRetries++
|
|
||||||
launchPlayer()
|
|
||||||
} else {
|
|
||||||
// Maybe the first GET_STATUS came back empty; still try launching
|
|
||||||
Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}")
|
|
||||||
_launching = true
|
|
||||||
_launchRetries++
|
|
||||||
launchPlayer()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Logger.e(TAG, "Player not found ($_launchRetries, _autoLaunchEnabled = $_autoLaunchEnabled); giving up.")
|
|
||||||
Logger.i(TAG, "Unable to start media receiver on device")
|
|
||||||
stop()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (_retryJob == null) {
|
|
||||||
Logger.i(TAG, "Scheduled retry job over 5 seconds")
|
|
||||||
_retryJob = _scopeIO?.launch(Dispatchers.IO) {
|
|
||||||
delay(5000)
|
|
||||||
getStatus()
|
|
||||||
_retryJob = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_launching = false
|
|
||||||
_launchRetries = 0
|
|
||||||
_autoLaunchEnabled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
val volume = status.getJSONObject("volume");
|
|
||||||
//val volumeControlType = volume.getString("controlType");
|
|
||||||
val volumeLevel = volume.getString("level").toDouble();
|
|
||||||
val volumeMuted = volume.getBoolean("muted");
|
|
||||||
//val volumeStepInterval = volume.getString("stepInterval").toFloat();
|
|
||||||
setVolume(if (volumeMuted) 0.0 else volumeLevel);
|
|
||||||
|
|
||||||
Logger.i(TAG, "Status update received volume (level: $volumeLevel, muted: $volumeMuted)");
|
|
||||||
} else if (type == "MEDIA_STATUS") {
|
|
||||||
val statuses = jsonObject.getJSONArray("status");
|
|
||||||
for (i in 0 until statuses.length()) {
|
|
||||||
val status = statuses.getJSONObject(i);
|
|
||||||
_mediaSessionId = status.getInt("mediaSessionId");
|
|
||||||
|
|
||||||
val playerState = status.getString("playerState");
|
|
||||||
val currentTime = status.getDouble("currentTime");
|
|
||||||
if (status.has("media")) {
|
|
||||||
val media = status.getJSONObject("media")
|
|
||||||
if (media.has("duration")) {
|
|
||||||
setDuration(media.getDouble("duration"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isPlaying = playerState == "PLAYING";
|
|
||||||
if (isPlaying || playerState == "PAUSED") {
|
|
||||||
setTime(currentTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
val playbackRate = status.getInt("playbackRate");
|
|
||||||
Logger.i(TAG, "Media update received (mediaSessionId: $_mediaSessionId, playedState: $playerState, currentTime: $currentTime, playbackRate: $playbackRate)");
|
|
||||||
|
|
||||||
if (_contentType == null) {
|
|
||||||
stopVideo();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val needsLoad = statuses.length() == 0 || (statuses.getJSONObject(0).getString("playerState") == "IDLE")
|
|
||||||
if (needsLoad && _contentId != null && _mediaSessionId == null) {
|
|
||||||
Logger.i(TAG, "Receiver idle, sending initial LOAD")
|
|
||||||
playVideo()
|
|
||||||
}
|
|
||||||
} else if (type == "CLOSE") {
|
|
||||||
if (message.sourceId == "receiver-0") {
|
|
||||||
Logger.i(TAG, "Close received.");
|
|
||||||
stopCasting();
|
|
||||||
} else if (_transportId == message.sourceId) {
|
|
||||||
throw Exception("Transport id closed.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw Exception("Payload type ${message.payloadType} is not implemented.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendMessage(data: ByteArray) {
|
|
||||||
val outputStream = _outputStream;
|
|
||||||
if (outputStream == null) {
|
|
||||||
Logger.w(TAG, "Failed to send ${data.size} bytes, output stream is null.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized(_outputStreamLock)
|
|
||||||
{
|
|
||||||
val serializedSizeBE = ByteArray(4);
|
|
||||||
serializedSizeBE[0] = (data.size shr 24 and 0xff).toByte();
|
|
||||||
serializedSizeBE[1] = (data.size shr 16 and 0xff).toByte();
|
|
||||||
serializedSizeBE[2] = (data.size shr 8 and 0xff).toByte();
|
|
||||||
serializedSizeBE[3] = (data.size and 0xff).toByte();
|
|
||||||
outputStream.write(serializedSizeBE);
|
|
||||||
outputStream.write(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
//Log.d(TAG, "Sent ${data.size} bytes.");
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stop() {
|
|
||||||
Logger.i(TAG, "Stopping...");
|
|
||||||
usedRemoteAddress = null;
|
|
||||||
localAddress = null;
|
|
||||||
_started = false;
|
|
||||||
|
|
||||||
_contentId = null
|
|
||||||
_contentType = null
|
|
||||||
_streamType = null
|
|
||||||
|
|
||||||
_retryJob?.cancel()
|
|
||||||
_retryJob = null
|
|
||||||
|
|
||||||
val socket = _socket;
|
|
||||||
val scopeIO = _scopeIO;
|
|
||||||
|
|
||||||
if (scopeIO != null && socket != null) {
|
|
||||||
Logger.i(TAG, "Cancelling scopeIO with open socket.")
|
|
||||||
|
|
||||||
scopeIO.launch {
|
|
||||||
socket.close();
|
|
||||||
connectionState = CastConnectionState.DISCONNECTED;
|
|
||||||
scopeIO.cancel();
|
|
||||||
Logger.i(TAG, "Cancelled scopeIO with open socket.")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
scopeIO?.cancel();
|
|
||||||
Logger.i(TAG, "Cancelled scopeIO without open socket.")
|
|
||||||
}
|
|
||||||
|
|
||||||
_pingThread = null;
|
|
||||||
_thread = null;
|
|
||||||
_scopeIO = null;
|
|
||||||
_socket = null;
|
|
||||||
_outputStream = null;
|
|
||||||
_inputStream = null;
|
|
||||||
_mediaSessionId = null;
|
|
||||||
connectionState = CastConnectionState.DISCONNECTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
|
||||||
return CastingDeviceInfo(name!!, CastProtocolType.CHROMECAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val TAG = "ChromecastCastingDevice";
|
|
||||||
|
|
||||||
val trustAllCerts: Array<TrustManager> = arrayOf<TrustManager>(object : X509TrustManager {
|
|
||||||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
|
|
||||||
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
|
|
||||||
override fun getAcceptedIssuers(): Array<X509Certificate> { return emptyArray(); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,636 +0,0 @@
|
|||||||
package com.futo.platformplayer.casting
|
|
||||||
|
|
||||||
import android.os.Looper
|
|
||||||
import android.util.Base64
|
|
||||||
import android.util.Log
|
|
||||||
import com.futo.platformplayer.UIDialogs
|
|
||||||
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastKeyExchangeMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastPlayMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastPlaybackErrorMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastPlaybackUpdateMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastSeekMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastSetSpeedMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastSetVolumeMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastVersionMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastVolumeUpdateMessage
|
|
||||||
import com.futo.platformplayer.ensureNotMainThread
|
|
||||||
import com.futo.platformplayer.getConnectedSocket
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
|
||||||
import com.futo.platformplayer.toHexString
|
|
||||||
import com.futo.platformplayer.toInetAddress
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import java.io.IOException
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
import java.math.BigInteger
|
|
||||||
import java.net.InetAddress
|
|
||||||
import java.net.InetSocketAddress
|
|
||||||
import java.net.Socket
|
|
||||||
import java.security.KeyFactory
|
|
||||||
import java.security.KeyPair
|
|
||||||
import java.security.KeyPairGenerator
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.security.PrivateKey
|
|
||||||
import java.security.spec.X509EncodedKeySpec
|
|
||||||
import javax.crypto.Cipher
|
|
||||||
import javax.crypto.KeyAgreement
|
|
||||||
import javax.crypto.spec.DHParameterSpec
|
|
||||||
import javax.crypto.spec.IvParameterSpec
|
|
||||||
import javax.crypto.spec.SecretKeySpec
|
|
||||||
|
|
||||||
enum class Opcode(val value: Byte) {
|
|
||||||
None(0),
|
|
||||||
Play(1),
|
|
||||||
Pause(2),
|
|
||||||
Resume(3),
|
|
||||||
Stop(4),
|
|
||||||
Seek(5),
|
|
||||||
PlaybackUpdate(6),
|
|
||||||
VolumeUpdate(7),
|
|
||||||
SetVolume(8),
|
|
||||||
PlaybackError(9),
|
|
||||||
SetSpeed(10),
|
|
||||||
Version(11),
|
|
||||||
Ping(12),
|
|
||||||
Pong(13);
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val _map = entries.associateBy { it.value }
|
|
||||||
fun find(value: Byte): Opcode = _map[value] ?: Opcode.None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FCastCastingDevice : CastingDeviceLegacy {
|
|
||||||
//See for more info: TODO
|
|
||||||
|
|
||||||
override val protocol: CastProtocolType get() = CastProtocolType.FCAST;
|
|
||||||
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
|
|
||||||
override var usedRemoteAddress: InetAddress? = null;
|
|
||||||
override var localAddress: InetAddress? = null;
|
|
||||||
override val canSetVolume: Boolean get() = true;
|
|
||||||
override val canSetSpeed: Boolean get() = true;
|
|
||||||
|
|
||||||
var addresses: Array<InetAddress>? = null;
|
|
||||||
var port: Int = 0;
|
|
||||||
|
|
||||||
private var _socket: Socket? = null;
|
|
||||||
private var _outputStream: OutputStream? = null;
|
|
||||||
private var _inputStream: InputStream? = null;
|
|
||||||
private var _scopeIO: CoroutineScope? = null;
|
|
||||||
private var _started: Boolean = false;
|
|
||||||
private var _version: Long = 1;
|
|
||||||
private var _thread: Thread? = null
|
|
||||||
private var _pingThread: Thread? = null
|
|
||||||
@Volatile private var _lastPongTime = System.currentTimeMillis()
|
|
||||||
private var _outputStreamLock = Object()
|
|
||||||
|
|
||||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
|
||||||
this.name = name;
|
|
||||||
this.addresses = addresses;
|
|
||||||
this.port = port;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(deviceInfo: CastingDeviceInfo) : super() {
|
|
||||||
this.name = deviceInfo.name;
|
|
||||||
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
|
|
||||||
this.port = deviceInfo.port;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAddresses(): List<InetAddress> {
|
|
||||||
return addresses?.toList() ?: listOf();
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
|
|
||||||
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
//TODO: Remove this later, temporary for the transition
|
|
||||||
if (_version <= 1L) {
|
|
||||||
UIDialogs.toast("Version not received, if you are experiencing issues, try updating FCast")
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
|
||||||
|
|
||||||
setTime(resumePosition);
|
|
||||||
setDuration(duration);
|
|
||||||
send(Opcode.Play, FCastPlayMessage(
|
|
||||||
container = contentType,
|
|
||||||
url = contentId,
|
|
||||||
time = resumePosition,
|
|
||||||
speed = speed
|
|
||||||
));
|
|
||||||
|
|
||||||
setSpeed(speed ?: 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
|
|
||||||
if (invokeInIOScopeIfRequired({ loadContent(contentType, content, resumePosition, duration, speed) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
//TODO: Remove this later, temporary for the transition
|
|
||||||
if (_version <= 1L) {
|
|
||||||
UIDialogs.toast("Version not received, if you are experiencing issues, try updating FCast")
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Start streaming content (contentType: $contentType, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
|
||||||
|
|
||||||
setTime(resumePosition);
|
|
||||||
setDuration(duration);
|
|
||||||
send(Opcode.Play, FCastPlayMessage(
|
|
||||||
container = contentType,
|
|
||||||
content = content,
|
|
||||||
time = resumePosition,
|
|
||||||
speed = speed
|
|
||||||
));
|
|
||||||
|
|
||||||
setSpeed(speed ?: 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun changeVolume(volume: Double) {
|
|
||||||
if (invokeInIOScopeIfRequired({ changeVolume(volume) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setVolume(volume);
|
|
||||||
send(Opcode.SetVolume, FCastSetVolumeMessage(volume))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun changeSpeed(speed: Double) {
|
|
||||||
if (invokeInIOScopeIfRequired({ changeSpeed(speed) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSpeed(speed);
|
|
||||||
send(Opcode.SetSpeed, FCastSetSpeedMessage(speed))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun seekVideo(timeSeconds: Double) {
|
|
||||||
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
send(Opcode.Seek, FCastSeekMessage(
|
|
||||||
time = timeSeconds
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun resumeVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::resumeVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
send(Opcode.Resume);
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun pauseVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::pauseVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
send(Opcode.Pause);
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stopVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::stopVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
send(Opcode.Stop);
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
|
|
||||||
if(Looper.getMainLooper().thread == Thread.currentThread()) {
|
|
||||||
_scopeIO?.launch {
|
|
||||||
try {
|
|
||||||
action();
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to invoke in IO scope.", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stopCasting() {
|
|
||||||
if (invokeInIOScopeIfRequired(::stopCasting)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
stopVideo();
|
|
||||||
|
|
||||||
Logger.i(TAG, "Stopping active device because stopCasting was called.")
|
|
||||||
stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun start() {
|
|
||||||
if (_started) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_started = true;
|
|
||||||
Logger.i(TAG, "Starting...");
|
|
||||||
|
|
||||||
ensureThreadStarted();
|
|
||||||
Logger.i(TAG, "Started.");
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ensureThreadStarted() {
|
|
||||||
val adrs = addresses ?: return;
|
|
||||||
|
|
||||||
val thread = _thread
|
|
||||||
val pingThread = _pingThread
|
|
||||||
if (_started && (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive)) {
|
|
||||||
Log.i(TAG, "(Re)starting thread because the thread has died")
|
|
||||||
|
|
||||||
_scopeIO?.let {
|
|
||||||
it.cancel()
|
|
||||||
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
|
|
||||||
}
|
|
||||||
|
|
||||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
|
||||||
|
|
||||||
_thread = Thread {
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
Log.i(TAG, "Connection thread started.")
|
|
||||||
|
|
||||||
var connectedSocket: Socket? = null
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
try {
|
|
||||||
Log.i(TAG, "getConnectedSocket (adrs = [ ${adrs.joinToString(", ")} ], port = ${port}).")
|
|
||||||
|
|
||||||
val resultSocket = getConnectedSocket(adrs.toList(), port);
|
|
||||||
|
|
||||||
if (resultSocket == null) {
|
|
||||||
Log.i(TAG, "Connection failed, waiting 1 seconds.")
|
|
||||||
Thread.sleep(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.i(TAG, "Connection succeeded.")
|
|
||||||
|
|
||||||
connectedSocket = resultSocket
|
|
||||||
usedRemoteAddress = connectedSocket.inetAddress
|
|
||||||
localAddress = connectedSocket.localAddress
|
|
||||||
break;
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e)
|
|
||||||
Thread.sleep(1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val address = InetSocketAddress(usedRemoteAddress, port)
|
|
||||||
|
|
||||||
//Connection loop
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
Logger.i(TAG, "Connecting to FastCast.");
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
|
|
||||||
try {
|
|
||||||
_socket?.close()
|
|
||||||
_inputStream?.close()
|
|
||||||
_outputStream?.close()
|
|
||||||
if (connectedSocket != null) {
|
|
||||||
Logger.i(TAG, "Using connected socket.");
|
|
||||||
_socket = connectedSocket
|
|
||||||
connectedSocket = null
|
|
||||||
} else {
|
|
||||||
Logger.i(TAG, "Using new socket.");
|
|
||||||
_socket = Socket().apply { this.connect(address, 2000) };
|
|
||||||
}
|
|
||||||
Logger.i(TAG, "Successfully connected to FastCast at $usedRemoteAddress:$port");
|
|
||||||
|
|
||||||
_outputStream = _socket?.outputStream;
|
|
||||||
_inputStream = _socket?.inputStream;
|
|
||||||
} catch (e: IOException) {
|
|
||||||
_socket?.close()
|
|
||||||
_inputStream?.close()
|
|
||||||
_outputStream?.close()
|
|
||||||
Logger.i(TAG, "Failed to connect to FastCast.", e);
|
|
||||||
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
Thread.sleep(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
localAddress = _socket?.localAddress
|
|
||||||
_lastPongTime = System.currentTimeMillis()
|
|
||||||
connectionState = CastConnectionState.CONNECTED
|
|
||||||
|
|
||||||
val buffer = ByteArray(4096);
|
|
||||||
|
|
||||||
Logger.i(TAG, "Started receiving.");
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
try {
|
|
||||||
val inputStream = _inputStream ?: break;
|
|
||||||
Log.d(TAG, "Receiving next packet...");
|
|
||||||
|
|
||||||
var headerBytesRead = 0
|
|
||||||
while (headerBytesRead < 4) {
|
|
||||||
val read = inputStream.read(buffer, headerBytesRead, 4 - headerBytesRead)
|
|
||||||
if (read == -1)
|
|
||||||
throw Exception("Stream closed")
|
|
||||||
headerBytesRead += read
|
|
||||||
}
|
|
||||||
|
|
||||||
val size = ((buffer[3].toUByte().toLong() shl 24) or (buffer[2].toUByte().toLong() shl 16) or (buffer[1].toUByte().toLong() shl 8) or buffer[0].toUByte().toLong()).toInt();
|
|
||||||
if (size > buffer.size) {
|
|
||||||
Logger.w(TAG, "Packets larger than $size bytes are not supported.")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
|
||||||
var bytesRead = 0
|
|
||||||
while (bytesRead < size) {
|
|
||||||
val read = inputStream.read(buffer, bytesRead, size - bytesRead)
|
|
||||||
if (read == -1)
|
|
||||||
throw Exception("Stream closed")
|
|
||||||
bytesRead += read
|
|
||||||
}
|
|
||||||
|
|
||||||
val messageBytes = buffer.sliceArray(IntRange(0, size));
|
|
||||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
|
||||||
|
|
||||||
val opcode = messageBytes[0];
|
|
||||||
var json: String? = null;
|
|
||||||
if (size > 1) {
|
|
||||||
json = messageBytes.sliceArray(IntRange(1, size - 1)).decodeToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
handleMessage(Opcode.find(opcode), json);
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to handle message.", e)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} catch (e: java.net.SocketException) {
|
|
||||||
Logger.e(TAG, "Socket exception while receiving.", e);
|
|
||||||
break
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Exception while receiving.", e);
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
_socket?.close()
|
|
||||||
_inputStream?.close()
|
|
||||||
_outputStream?.close()
|
|
||||||
Logger.i(TAG, "Socket disconnected.");
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to close socket.", e)
|
|
||||||
}
|
|
||||||
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
Thread.sleep(1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Stopped connection loop.");
|
|
||||||
connectionState = CastConnectionState.DISCONNECTED;
|
|
||||||
}.apply { start() }
|
|
||||||
|
|
||||||
_pingThread = Thread {
|
|
||||||
Logger.i(TAG, "Started ping loop.")
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
if (connectionState == CastConnectionState.CONNECTED) {
|
|
||||||
try {
|
|
||||||
send(Opcode.Ping)
|
|
||||||
if (System.currentTimeMillis() - _lastPongTime > 15000) {
|
|
||||||
Logger.w(TAG, "Closing socket due to last pong time being larger than 15 seconds.")
|
|
||||||
try {
|
|
||||||
_socket?.close()
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.w(TAG, "Failed to close socket.", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.w(TAG, "Failed to send ping.")
|
|
||||||
try {
|
|
||||||
_socket?.close()
|
|
||||||
_inputStream?.close()
|
|
||||||
_outputStream?.close()
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.w(TAG, "Failed to close socket.", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Thread.sleep(5000)
|
|
||||||
}
|
|
||||||
Logger.i(TAG, "Stopped ping loop.")
|
|
||||||
}.apply { start() }
|
|
||||||
} else {
|
|
||||||
Log.i(TAG, "Thread was still alive, not restarted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleMessage(opcode: Opcode, json: String? = null) {
|
|
||||||
Log.i(TAG, "Processing packet (opcode: $opcode, size: ${json?.length ?: 0})")
|
|
||||||
|
|
||||||
when (opcode) {
|
|
||||||
Opcode.PlaybackUpdate -> {
|
|
||||||
if (json == null) {
|
|
||||||
Logger.w(TAG, "Got playback update without JSON, ignoring.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val playbackUpdate = FCastCastingDevice.json.decodeFromString<FCastPlaybackUpdateMessage>(json);
|
|
||||||
setTime(playbackUpdate.time, playbackUpdate.generationTime);
|
|
||||||
setDuration(playbackUpdate.duration, playbackUpdate.generationTime);
|
|
||||||
isPlaying = when (playbackUpdate.state) {
|
|
||||||
1 -> true
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Opcode.VolumeUpdate -> {
|
|
||||||
if (json == null) {
|
|
||||||
Logger.w(TAG, "Got volume update without JSON, ignoring.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val volumeUpdate = FCastCastingDevice.json.decodeFromString<FCastVolumeUpdateMessage>(json);
|
|
||||||
setVolume(volumeUpdate.volume, volumeUpdate.generationTime);
|
|
||||||
}
|
|
||||||
Opcode.PlaybackError -> {
|
|
||||||
if (json == null) {
|
|
||||||
Logger.w(TAG, "Got playback error without JSON, ignoring.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val playbackError = FCastCastingDevice.json.decodeFromString<FCastPlaybackErrorMessage>(json);
|
|
||||||
Logger.e(TAG, "Remote casting playback error received: $playbackError")
|
|
||||||
}
|
|
||||||
Opcode.Version -> {
|
|
||||||
if (json == null) {
|
|
||||||
Logger.w(TAG, "Got version without JSON, ignoring.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val version = FCastCastingDevice.json.decodeFromString<FCastVersionMessage>(json);
|
|
||||||
_version = version.version;
|
|
||||||
Logger.i(TAG, "Remote version received: $version")
|
|
||||||
}
|
|
||||||
Opcode.Ping -> send(Opcode.Pong)
|
|
||||||
Opcode.Pong -> _lastPongTime = System.currentTimeMillis()
|
|
||||||
else -> { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun send(opcode: Opcode, message: String? = null) {
|
|
||||||
ensureNotMainThread()
|
|
||||||
|
|
||||||
synchronized (_outputStreamLock) {
|
|
||||||
try {
|
|
||||||
val data: ByteArray = message?.encodeToByteArray() ?: ByteArray(0)
|
|
||||||
val size = 1 + data.size
|
|
||||||
val outputStream = _outputStream
|
|
||||||
if (outputStream == null) {
|
|
||||||
Log.w(TAG, "Failed to send $size bytes, output stream is null.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val serializedSizeLE = ByteArray(4)
|
|
||||||
serializedSizeLE[0] = (size and 0xff).toByte()
|
|
||||||
serializedSizeLE[1] = (size shr 8 and 0xff).toByte()
|
|
||||||
serializedSizeLE[2] = (size shr 16 and 0xff).toByte()
|
|
||||||
serializedSizeLE[3] = (size shr 24 and 0xff).toByte()
|
|
||||||
outputStream.write(serializedSizeLE)
|
|
||||||
|
|
||||||
val opcodeBytes = ByteArray(1)
|
|
||||||
opcodeBytes[0] = opcode.value
|
|
||||||
outputStream.write(opcodeBytes)
|
|
||||||
|
|
||||||
if (data.isNotEmpty()) {
|
|
||||||
outputStream.write(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(TAG, "Sent $size bytes: (opcode: $opcode, body: $message).")
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.i(TAG, "Failed to send message.", e)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun <reified T> send(opcode: Opcode, message: T) {
|
|
||||||
try {
|
|
||||||
send(opcode, message?.let { Json.encodeToString(it) })
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.i(TAG, "Failed to encode message to string.", e)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stop() {
|
|
||||||
Logger.i(TAG, "Stopping...");
|
|
||||||
usedRemoteAddress = null;
|
|
||||||
localAddress = null;
|
|
||||||
_started = false;
|
|
||||||
//TODO: Kill and/or join thread?
|
|
||||||
_thread = null;
|
|
||||||
_pingThread = null;
|
|
||||||
|
|
||||||
val socket = _socket;
|
|
||||||
val scopeIO = _scopeIO;
|
|
||||||
|
|
||||||
if (scopeIO != null && socket != null) {
|
|
||||||
Logger.i(TAG, "Cancelling scopeIO with open socket.")
|
|
||||||
|
|
||||||
scopeIO.launch {
|
|
||||||
socket.close();
|
|
||||||
_inputStream?.close()
|
|
||||||
_outputStream?.close()
|
|
||||||
connectionState = CastConnectionState.DISCONNECTED;
|
|
||||||
scopeIO.cancel();
|
|
||||||
Logger.i(TAG, "Cancelled scopeIO with open socket.")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
scopeIO?.cancel();
|
|
||||||
Logger.i(TAG, "Cancelled scopeIO without open socket.")
|
|
||||||
}
|
|
||||||
|
|
||||||
_scopeIO = null;
|
|
||||||
_socket = null;
|
|
||||||
_outputStream = null;
|
|
||||||
_inputStream = null;
|
|
||||||
connectionState = CastConnectionState.DISCONNECTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
|
||||||
return CastingDeviceInfo(name!!, CastProtocolType.FCAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val TAG = "FCastCastingDevice";
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
|
||||||
|
|
||||||
fun getKeyExchangeMessage(keyPair: KeyPair): FCastKeyExchangeMessage {
|
|
||||||
return FCastKeyExchangeMessage(1, Base64.encodeToString(keyPair.public.encoded, Base64.NO_WRAP))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun generateKeyPair(): KeyPair {
|
|
||||||
//modp14
|
|
||||||
val p = BigInteger("ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa051015728e5a8aacaa68ffffffffffffffff", 16)
|
|
||||||
val g = BigInteger("2", 16)
|
|
||||||
val dhSpec = DHParameterSpec(p, g)
|
|
||||||
|
|
||||||
val keyGen = KeyPairGenerator.getInstance("DH")
|
|
||||||
keyGen.initialize(dhSpec)
|
|
||||||
|
|
||||||
return keyGen.generateKeyPair()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun computeSharedSecret(privateKey: PrivateKey, keyExchangeMessage: FCastKeyExchangeMessage): SecretKeySpec {
|
|
||||||
val keyFactory = KeyFactory.getInstance("DH")
|
|
||||||
val receivedPublicKeyBytes = Base64.decode(keyExchangeMessage.publicKey, Base64.NO_WRAP)
|
|
||||||
val receivedPublicKeySpec = X509EncodedKeySpec(receivedPublicKeyBytes)
|
|
||||||
val receivedPublicKey = keyFactory.generatePublic(receivedPublicKeySpec)
|
|
||||||
|
|
||||||
val keyAgreement = KeyAgreement.getInstance("DH")
|
|
||||||
keyAgreement.init(privateKey)
|
|
||||||
keyAgreement.doPhase(receivedPublicKey, true)
|
|
||||||
|
|
||||||
val sharedSecret = keyAgreement.generateSecret()
|
|
||||||
Log.i(TAG, "sharedSecret ${Base64.encodeToString(sharedSecret, Base64.NO_WRAP)}")
|
|
||||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
|
||||||
val hashedSecret = sha256.digest(sharedSecret)
|
|
||||||
Log.i(TAG, "hashedSecret ${Base64.encodeToString(hashedSecret, Base64.NO_WRAP)}")
|
|
||||||
|
|
||||||
return SecretKeySpec(hashedSecret, "AES")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun encryptMessage(aesKey: SecretKeySpec, decryptedMessage: FCastDecryptedMessage): FCastEncryptedMessage {
|
|
||||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, aesKey)
|
|
||||||
val iv = cipher.iv
|
|
||||||
val json = Json.encodeToString(decryptedMessage)
|
|
||||||
val encrypted = cipher.doFinal(json.toByteArray(Charsets.UTF_8))
|
|
||||||
return FCastEncryptedMessage(
|
|
||||||
version = 1,
|
|
||||||
iv = Base64.encodeToString(iv, Base64.NO_WRAP),
|
|
||||||
blob = Base64.encodeToString(encrypted, Base64.NO_WRAP)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun decryptMessage(aesKey: SecretKeySpec, encryptedMessage: FCastEncryptedMessage): FCastDecryptedMessage {
|
|
||||||
val iv = Base64.decode(encryptedMessage.iv, Base64.NO_WRAP)
|
|
||||||
val encrypted = Base64.decode(encryptedMessage.blob, Base64.NO_WRAP)
|
|
||||||
|
|
||||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
|
||||||
cipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv))
|
|
||||||
val decryptedJson = cipher.doFinal(encrypted)
|
|
||||||
return Json.decodeFromString(String(decryptedJson, Charsets.UTF_8))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,9 @@ import android.content.Context
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import com.futo.platformplayer.BuildConfig
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
@@ -14,6 +16,7 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
|
|||||||
import com.futo.platformplayer.api.http.server.HttpHeaders
|
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||||
import com.futo.platformplayer.api.http.server.ManagedHttpServer
|
import com.futo.platformplayer.api.http.server.ManagedHttpServer
|
||||||
import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler
|
import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler
|
||||||
|
import com.futo.platformplayer.api.http.server.handlers.HttpContentUriHandler
|
||||||
import com.futo.platformplayer.api.http.server.handlers.HttpFileHandler
|
import com.futo.platformplayer.api.http.server.handlers.HttpFileHandler
|
||||||
import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler
|
import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler
|
||||||
import com.futo.platformplayer.api.http.server.handlers.HttpProxyHandler
|
import com.futo.platformplayer.api.http.server.handlers.HttpProxyHandler
|
||||||
@@ -34,8 +37,11 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif
|
|||||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
|
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
|
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||||
|
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
|
||||||
|
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
|
||||||
import com.futo.platformplayer.awaitCancelConverted
|
import com.futo.platformplayer.awaitCancelConverted
|
||||||
import com.futo.platformplayer.builders.DashBuilder
|
import com.futo.platformplayer.builders.DashBuilder
|
||||||
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.constructs.Event2
|
import com.futo.platformplayer.constructs.Event2
|
||||||
@@ -52,16 +58,22 @@ import com.futo.platformplayer.views.casting.CastView.Companion
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.fcast.sender_sdk.CastContext
|
||||||
|
import org.fcast.sender_sdk.DeviceInfo
|
||||||
import org.fcast.sender_sdk.Metadata
|
import org.fcast.sender_sdk.Metadata
|
||||||
|
import org.fcast.sender_sdk.NsdDeviceDiscoverer
|
||||||
|
import org.fcast.sender_sdk.ProtocolType
|
||||||
import java.net.Inet6Address
|
import java.net.Inet6Address
|
||||||
import java.net.URLDecoder
|
import java.net.URLDecoder
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo
|
||||||
|
|
||||||
abstract class StateCasting {
|
class StateCasting {
|
||||||
val _scopeIO = CoroutineScope(Dispatchers.IO);
|
val _scopeIO = CoroutineScope(Dispatchers.IO);
|
||||||
val _scopeMain = CoroutineScope(Dispatchers.Main);
|
val _scopeMain = CoroutineScope(Dispatchers.Main);
|
||||||
private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get();
|
private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get();
|
||||||
@@ -78,6 +90,7 @@ abstract class StateCasting {
|
|||||||
val onActiveDeviceTimeChanged = Event1<Double>();
|
val onActiveDeviceTimeChanged = Event1<Double>();
|
||||||
val onActiveDeviceDurationChanged = Event1<Double>();
|
val onActiveDeviceDurationChanged = Event1<Double>();
|
||||||
val onActiveDeviceVolumeChanged = Event1<Double>();
|
val onActiveDeviceVolumeChanged = Event1<Double>();
|
||||||
|
val onActiveDeviceMediaItemEnd = Event0()
|
||||||
var activeDevice: CastingDevice? = null;
|
var activeDevice: CastingDevice? = null;
|
||||||
private var _videoExecutor: JSRequestExecutor? = null
|
private var _videoExecutor: JSRequestExecutor? = null
|
||||||
private var _audioExecutor: JSRequestExecutor? = null
|
private var _audioExecutor: JSRequestExecutor? = null
|
||||||
@@ -86,15 +99,163 @@ abstract class StateCasting {
|
|||||||
val isCasting: Boolean get() = activeDevice != null;
|
val isCasting: Boolean get() = activeDevice != null;
|
||||||
private val _castId = AtomicInteger(0)
|
private val _castId = AtomicInteger(0)
|
||||||
|
|
||||||
abstract fun handleUrl(url: String)
|
private val _context = CastContext()
|
||||||
abstract fun onStop()
|
var _deviceDiscoverer: NsdDeviceDiscoverer? = null
|
||||||
abstract fun start(context: Context)
|
|
||||||
abstract fun stop()
|
|
||||||
|
|
||||||
abstract fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice?
|
class DiscoveryEventHandler(
|
||||||
abstract fun startUpdateTimeJob(
|
private val onDeviceAdded: (RsDeviceInfo) -> Unit,
|
||||||
onTimeJobTimeChanged_s: Event1<Long>, setTime: (Long) -> Unit
|
private val onDeviceRemoved: (String) -> Unit,
|
||||||
): Job?
|
private val onDeviceUpdated: (RsDeviceInfo) -> Unit,
|
||||||
|
) : org.fcast.sender_sdk.DeviceDiscovererEventHandler {
|
||||||
|
override fun deviceAvailable(deviceInfo: RsDeviceInfo) {
|
||||||
|
onDeviceAdded(deviceInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deviceChanged(deviceInfo: RsDeviceInfo) {
|
||||||
|
onDeviceUpdated(deviceInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deviceRemoved(deviceName: String) {
|
||||||
|
onDeviceRemoved(deviceName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleUrl(url: String) {
|
||||||
|
try {
|
||||||
|
val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!!
|
||||||
|
val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo)
|
||||||
|
connectDevice(CastingDevice(foundDevice))
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to handle URL: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onStop() {
|
||||||
|
val ad = activeDevice ?: return
|
||||||
|
_resumeCastingDevice = ad.getDeviceInfo()
|
||||||
|
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
|
||||||
|
Logger.i(TAG, "Stopping active device because of onStop.")
|
||||||
|
try {
|
||||||
|
ad.disconnect()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to disconnect from device: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun start(context: Context) {
|
||||||
|
if (_started)
|
||||||
|
return
|
||||||
|
_started = true
|
||||||
|
|
||||||
|
Log.i(TAG, "_resumeCastingDevice set null start")
|
||||||
|
_resumeCastingDevice = null
|
||||||
|
|
||||||
|
Logger.i(TAG, "CastingService starting...")
|
||||||
|
|
||||||
|
_castServer.start()
|
||||||
|
enableDeveloper(true)
|
||||||
|
|
||||||
|
Logger.i(TAG, "CastingService started.")
|
||||||
|
|
||||||
|
_deviceDiscoverer = NsdDeviceDiscoverer(
|
||||||
|
context,
|
||||||
|
DiscoveryEventHandler(
|
||||||
|
{ deviceInfo -> // Added
|
||||||
|
Logger.i(TAG, "Device added: ${deviceInfo.name}")
|
||||||
|
val device = _context.createDeviceFromInfo(deviceInfo)
|
||||||
|
val deviceHandle = CastingDevice(device)
|
||||||
|
devices[deviceHandle.device.name()] = deviceHandle
|
||||||
|
invokeInMainScopeIfRequired {
|
||||||
|
onDeviceAdded.emit(deviceHandle)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deviceName -> // Removed
|
||||||
|
invokeInMainScopeIfRequired {
|
||||||
|
if (devices.containsKey(deviceName)) {
|
||||||
|
val device = devices.remove(deviceName)
|
||||||
|
if (device != null) {
|
||||||
|
onDeviceRemoved.emit(device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deviceInfo -> // Updated
|
||||||
|
Logger.i(TAG, "Device updated: $deviceInfo")
|
||||||
|
val handle = devices[deviceInfo.name]
|
||||||
|
if (handle != null && handle is CastingDevice) {
|
||||||
|
handle.device.setPort(deviceInfo.port)
|
||||||
|
handle.device.setAddresses(deviceInfo.addresses)
|
||||||
|
invokeInMainScopeIfRequired {
|
||||||
|
onDeviceChanged.emit(handle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun stop() {
|
||||||
|
if (!_started) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_started = false
|
||||||
|
|
||||||
|
Logger.i(TAG, "CastingService stopping.")
|
||||||
|
|
||||||
|
_scopeIO.cancel()
|
||||||
|
_scopeMain.cancel()
|
||||||
|
|
||||||
|
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
|
||||||
|
val d = activeDevice
|
||||||
|
activeDevice = null
|
||||||
|
try {
|
||||||
|
d?.disconnect()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to disconnect device: $e")
|
||||||
|
}
|
||||||
|
|
||||||
|
_castServer.stop()
|
||||||
|
_castServer.removeAllHandlers()
|
||||||
|
|
||||||
|
Logger.i(TAG, "CastingService stopped.")
|
||||||
|
|
||||||
|
_deviceDiscoverer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startUpdateTimeJob(
|
||||||
|
onTimeJobTimeChanged_s: Event1<Long>,
|
||||||
|
setTime: (Long) -> Unit
|
||||||
|
): Job? = null
|
||||||
|
|
||||||
|
fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice? {
|
||||||
|
try {
|
||||||
|
val rsAddrs =
|
||||||
|
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) }
|
||||||
|
val rsDeviceInfo = RsDeviceInfo(
|
||||||
|
name = deviceInfo.name,
|
||||||
|
protocol = when (deviceInfo.type) {
|
||||||
|
com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST
|
||||||
|
com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST
|
||||||
|
else -> throw IllegalArgumentException()
|
||||||
|
},
|
||||||
|
addresses = rsAddrs,
|
||||||
|
port = deviceInfo.port.toUShort(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return CastingDevice(_context.createDeviceFromInfo(rsDeviceInfo))
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun onResume() {
|
fun onResume() {
|
||||||
val ad = activeDevice
|
val ad = activeDevice
|
||||||
@@ -141,6 +302,7 @@ abstract class StateCasting {
|
|||||||
device.onTimeChanged.clear();
|
device.onTimeChanged.clear();
|
||||||
device.onVolumeChanged.clear();
|
device.onVolumeChanged.clear();
|
||||||
device.onDurationChanged.clear();
|
device.onDurationChanged.clear();
|
||||||
|
device.onMediaItemEnd.clear();
|
||||||
ad.disconnect()
|
ad.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +317,7 @@ abstract class StateCasting {
|
|||||||
device.onTimeChanged.clear();
|
device.onTimeChanged.clear();
|
||||||
device.onVolumeChanged.clear();
|
device.onVolumeChanged.clear();
|
||||||
device.onDurationChanged.clear();
|
device.onDurationChanged.clear();
|
||||||
|
device.onMediaItemEnd.clear();
|
||||||
activeDevice = null;
|
activeDevice = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +381,9 @@ abstract class StateCasting {
|
|||||||
device.onTimeChanged.subscribe {
|
device.onTimeChanged.subscribe {
|
||||||
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
|
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
|
||||||
};
|
};
|
||||||
|
device.onMediaItemEnd.subscribe {
|
||||||
|
invokeInMainScopeIfRequired { onActiveDeviceMediaItemEnd.emit() }
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
device.connect();
|
device.connect();
|
||||||
@@ -228,6 +394,7 @@ abstract class StateCasting {
|
|||||||
device.onTimeChanged.clear();
|
device.onTimeChanged.clear();
|
||||||
device.onVolumeChanged.clear();
|
device.onVolumeChanged.clear();
|
||||||
device.onDurationChanged.clear();
|
device.onDurationChanged.clear();
|
||||||
|
device.onMediaItemEnd.clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,9 +402,9 @@ abstract class StateCasting {
|
|||||||
Logger.i(TAG, "Connect to device ${device.name}")
|
Logger.i(TAG, "Connect to device ${device.name}")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun metadataFromVideo(video: IPlatformVideoDetails): Metadata {
|
fun metadataFromVideo(video: IPlatformVideoDetails, videoThumbnailOverrideUrl: String? = null): Metadata {
|
||||||
return Metadata(
|
return Metadata(
|
||||||
title = video.name, thumbnailUrl = video.thumbnails.getHQThumbnail()
|
title = video.name, thumbnailUrl = videoThumbnailOverrideUrl ?: video.thumbnails.getHQThumbnail()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,6 +538,12 @@ abstract class StateCasting {
|
|||||||
} else if (audioSource is LocalAudioSource) {
|
} else if (audioSource is LocalAudioSource) {
|
||||||
Logger.i(TAG, "Casting as local audio");
|
Logger.i(TAG, "Casting as local audio");
|
||||||
castLocalAudio(video, audioSource, resumePosition, speed);
|
castLocalAudio(video, audioSource, resumePosition, speed);
|
||||||
|
} else if (videoSource is LocalVideoContentSource) {
|
||||||
|
Logger.i(TAG, "Casting as local video");
|
||||||
|
castLocalVideo(contentResolver, video, videoSource, resumePosition, speed);
|
||||||
|
} else if (audioSource is LocalAudioContentSource) {
|
||||||
|
Logger.i(TAG, "Casting as local audio");
|
||||||
|
castLocalAudio(contentResolver, video, audioSource, resumePosition, speed);
|
||||||
} else if (videoSource is JSDashManifestRawSource) {
|
} else if (videoSource is JSDashManifestRawSource) {
|
||||||
Logger.i(TAG, "Casting as JSDashManifestRawSource video");
|
Logger.i(TAG, "Casting as JSDashManifestRawSource video");
|
||||||
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading);
|
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading);
|
||||||
@@ -461,6 +634,65 @@ abstract class StateCasting {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun castLocalVideo(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: LocalVideoContentSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||||
|
val ad = activeDevice ?: return listOf();
|
||||||
|
|
||||||
|
val url = getLocalUrl(ad);
|
||||||
|
val id = UUID.randomUUID();
|
||||||
|
val videoPath = "/video-${id}"
|
||||||
|
val videoUrl = url + videoPath;
|
||||||
|
val thumbnailPath = "/thumbnail-${id}"
|
||||||
|
val thumbnailUrl = url + thumbnailPath;
|
||||||
|
val thumbnailContentUrl = video.thumbnails.getHQThumbnail()
|
||||||
|
|
||||||
|
if (thumbnailContentUrl != null) {
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpContentUriHandler("GET", thumbnailPath, contentResolver, thumbnailContentUrl.toUri())
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("cast");
|
||||||
|
}
|
||||||
|
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpContentUriHandler("GET", videoPath, contentResolver, videoSource.contentUrl.toUri())
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("cast");
|
||||||
|
|
||||||
|
Logger.i(TAG, "Casting local video (videoUrl: $videoUrl).");
|
||||||
|
ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video, if (thumbnailContentUrl != null) thumbnailUrl else null));
|
||||||
|
|
||||||
|
return listOf(videoUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun castLocalAudio(contentResolver: ContentResolver, video: IPlatformVideoDetails, audioSource: LocalAudioContentSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||||
|
val ad = activeDevice ?: return listOf();
|
||||||
|
|
||||||
|
val url = getLocalUrl(ad);
|
||||||
|
val id = UUID.randomUUID();
|
||||||
|
val audioPath = "/audio-${id}"
|
||||||
|
val audioUrl = url + audioPath;
|
||||||
|
val thumbnailPath = "/thumbnail-${id}"
|
||||||
|
val thumbnailUrl = url + thumbnailPath;
|
||||||
|
val thumbnailContentUrl = video.thumbnails.getHQThumbnail()
|
||||||
|
|
||||||
|
if (thumbnailContentUrl != null) {
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpContentUriHandler("GET", thumbnailPath, contentResolver, thumbnailContentUrl.toUri())
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("cast");
|
||||||
|
}
|
||||||
|
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpContentUriHandler("GET", audioPath, contentResolver, audioSource.contentUrl.toUri())
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("cast");
|
||||||
|
|
||||||
|
Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl).");
|
||||||
|
ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video, if (thumbnailContentUrl != null) thumbnailUrl else null));
|
||||||
|
|
||||||
|
return listOf(audioUrl);
|
||||||
|
}
|
||||||
|
|
||||||
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
|
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
|
|
||||||
@@ -1164,6 +1396,47 @@ abstract class StateCasting {
|
|||||||
return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}";
|
return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun escapeXml(s: String): String =
|
||||||
|
s.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("\"", """)
|
||||||
|
.replace("'", "'")
|
||||||
|
|
||||||
|
private fun injectSubtitleAdaptationSet(
|
||||||
|
mpd: String,
|
||||||
|
subtitleUrl: String,
|
||||||
|
mimeType: String,
|
||||||
|
lang: String = "und",
|
||||||
|
label: String = "Subtitles"
|
||||||
|
): String {
|
||||||
|
val mt = mimeType.lowercase()
|
||||||
|
val codecs = when (mt) {
|
||||||
|
"text/vtt", "text/webvtt" -> "wvtt"
|
||||||
|
"application/ttml+xml", "application/ttml" -> "stpp"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val codecsAttr = codecs?.let { " codecs=\"${escapeXml(it)}\"" } ?: ""
|
||||||
|
|
||||||
|
val adaptation = """
|
||||||
|
<AdaptationSet id="123456" contentType="text" mimeType="${escapeXml(mimeType)}" lang="${escapeXml(lang)}">
|
||||||
|
<Role schemeIdUri="urn:mpeg:dash:role:2011" value="subtitle"/>
|
||||||
|
<Label>${escapeXml(label)}</Label>
|
||||||
|
<Representation id="123457"$codecsAttr bandwidth="256" mimeType="${escapeXml(mimeType)}">
|
||||||
|
<BaseURL>${escapeXml(subtitleUrl)}</BaseURL>
|
||||||
|
</Representation>
|
||||||
|
</AdaptationSet>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val periodClose = Regex("</Period\\s*>", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
return if (periodClose.containsMatchIn(mpd)) {
|
||||||
|
mpd.replaceFirst(periodClose, adaptation + "\n</Period>")
|
||||||
|
} else {
|
||||||
|
mpd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?, castId: Int, onLoadingEstimate: ((Int) -> Unit)? = null, onLoading: ((Boolean) -> Unit)? = null) : List<String> {
|
private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?, castId: Int, onLoadingEstimate: ((Int) -> Unit)? = null, onLoading: ((Boolean) -> Unit)? = null) : List<String> {
|
||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
@@ -1185,30 +1458,42 @@ abstract class StateCasting {
|
|||||||
val videoUrl = url + videoPath
|
val videoUrl = url + videoPath
|
||||||
val audioUrl = url + audioPath
|
val audioUrl = url + audioPath
|
||||||
|
|
||||||
|
val subtitleMimeTypeFull = subtitleSource?.format ?: "text/vtt"
|
||||||
|
val subtitleMimeTypeForMpd = subtitleMimeTypeFull.substringBefore(';').trim()
|
||||||
|
|
||||||
val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) {
|
val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) {
|
||||||
return@withContext subtitleSource.getSubtitlesURI();
|
subtitleSource.getSubtitlesURI()
|
||||||
} else null;
|
} else null
|
||||||
|
|
||||||
var subtitlesUrl: String? = null;
|
var subtitlesUrl: String? = null
|
||||||
if (subtitlesUri != null) {
|
if (subtitlesUri != null) {
|
||||||
if(subtitlesUri.scheme == "file") {
|
when (subtitlesUri.scheme) {
|
||||||
var content: String? = null;
|
"file", "content" -> {
|
||||||
val inputStream = contentResolver.openInputStream(subtitlesUri);
|
val content = withContext(Dispatchers.IO) {
|
||||||
inputStream?.use { stream ->
|
contentResolver.openInputStream(subtitlesUri)?.use { stream ->
|
||||||
val reader = stream.bufferedReader();
|
stream.bufferedReader().use { it.readText() }
|
||||||
content = reader.use { it.readText() };
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content.isNullOrEmpty()) {
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpConstantHandler("GET", subtitlePath, content, subtitleMimeTypeFull)
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"),
|
||||||
|
true
|
||||||
|
).withTag("castDashRaw")
|
||||||
|
|
||||||
|
subtitlesUrl = url + subtitlePath
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (content != null) {
|
"http", "https" -> {
|
||||||
_castServer.addHandlerWithAllowAllOptions(
|
// Receiver will fetch directly (works only if it doesn’t need auth/headers)
|
||||||
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
|
subtitlesUrl = subtitlesUri.toString()
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
|
||||||
).withTag("cast");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
subtitlesUrl = url + subtitlePath;
|
else -> {
|
||||||
} else {
|
Logger.w(TAG, "Unsupported subtitlesUri scheme: ${subtitlesUri.scheme}")
|
||||||
subtitlesUrl = subtitlesUri.toString();
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1254,8 +1539,22 @@ abstract class StateCasting {
|
|||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (subtitlesUrl != null) {
|
||||||
|
dashContent = injectSubtitleAdaptationSet(
|
||||||
|
dashContent,
|
||||||
|
subtitlesUrl!!,
|
||||||
|
subtitleMimeTypeForMpd
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasAudioInDash = false
|
||||||
for (representation in representationRegex.findAll(dashContent)) {
|
for (representation in representationRegex.findAll(dashContent)) {
|
||||||
val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found")
|
val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found")
|
||||||
|
|
||||||
|
if (mediaType.startsWith("audio/")) {
|
||||||
|
hasAudioInDash = true
|
||||||
|
}
|
||||||
|
|
||||||
dashContent = mediaInitializationRegex.replace(dashContent) {
|
dashContent = mediaInitializationRegex.replace(dashContent) {
|
||||||
if (it.range.first < representation.range.first || it.range.last > representation.range.last) {
|
if (it.range.first < representation.range.first || it.range.last > representation.range.last) {
|
||||||
return@replace it.value
|
return@replace it.value
|
||||||
@@ -1279,12 +1578,20 @@ abstract class StateCasting {
|
|||||||
throw Exception("Audio source without request executor not supported")
|
throw Exception("Audio source without request executor not supported")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (audioSource != null && audioSource.hasRequestExecutor) {
|
if (videoSource != null && videoSource.hasRequestExecutor) {
|
||||||
_audioExecutor = audioSource.getRequestExecutor()
|
val oldVideoExecutor = _videoExecutor
|
||||||
|
oldVideoExecutor?.closeAsync()
|
||||||
|
_videoExecutor = videoSource.getRequestExecutor()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videoSource != null && videoSource.hasRequestExecutor) {
|
if (audioSource != null) {
|
||||||
_videoExecutor = videoSource.getRequestExecutor()
|
val oldExecutor = _audioExecutor
|
||||||
|
oldExecutor?.closeAsync()
|
||||||
|
_audioExecutor = audioSource.getRequestExecutor()
|
||||||
|
} else if (hasAudioInDash && videoSource != null) {
|
||||||
|
val oldExecutor = _audioExecutor
|
||||||
|
oldExecutor?.closeAsync()
|
||||||
|
_audioExecutor = _videoExecutor
|
||||||
}
|
}
|
||||||
|
|
||||||
//TOOD: Else also handle the non request executor case, perhaps add ?url=$originalUrl to the query parameters, ... propagate this for all other flows also
|
//TOOD: Else also handle the non request executor case, perhaps add ?url=$originalUrl to the query parameters, ... propagate this for all other flows also
|
||||||
@@ -1315,7 +1622,7 @@ abstract class StateCasting {
|
|||||||
}.withHeader("Access-Control-Allow-Origin", "*"), true
|
}.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("castDashRaw");
|
).withTag("castDashRaw");
|
||||||
}
|
}
|
||||||
if (audioSource != null) {
|
if (audioSource != null || (audioSource == null && hasAudioInDash)) {
|
||||||
_castServer.addHandlerWithAllowAllOptions(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpFunctionHandler("GET", audioPath) { httpContext ->
|
HttpFunctionHandler("GET", audioPath) { httpContext ->
|
||||||
val originalUrl = httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler
|
val originalUrl = httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler
|
||||||
@@ -1380,11 +1687,7 @@ abstract class StateCasting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
var instance: StateCasting = if (Settings.instance.casting.experimentalCasting) {
|
var instance = StateCasting()
|
||||||
StateCastingExp()
|
|
||||||
} else {
|
|
||||||
StateCastingLegacy()
|
|
||||||
}
|
|
||||||
private val representationRegex = Regex(
|
private val representationRegex = Regex(
|
||||||
"<Representation .*?mimeType=\"(.*?)\".*?>(.*?)<\\/Representation>",
|
"<Representation .*?mimeType=\"(.*?)\".*?>(.*?)<\\/Representation>",
|
||||||
RegexOption.DOT_MATCHES_ALL
|
RegexOption.DOT_MATCHES_ALL
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
package com.futo.platformplayer.casting
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Log
|
|
||||||
import com.futo.platformplayer.BuildConfig
|
|
||||||
import com.futo.platformplayer.constructs.Event1
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo
|
|
||||||
import org.fcast.sender_sdk.ProtocolType
|
|
||||||
import org.fcast.sender_sdk.CastContext
|
|
||||||
import org.fcast.sender_sdk.NsdDeviceDiscoverer
|
|
||||||
|
|
||||||
class StateCastingExp : StateCasting() {
|
|
||||||
private val _context = CastContext()
|
|
||||||
var _deviceDiscoverer: NsdDeviceDiscoverer? = null
|
|
||||||
|
|
||||||
class DiscoveryEventHandler(
|
|
||||||
private val onDeviceAdded: (RsDeviceInfo) -> Unit,
|
|
||||||
private val onDeviceRemoved: (String) -> Unit,
|
|
||||||
private val onDeviceUpdated: (RsDeviceInfo) -> Unit,
|
|
||||||
) : org.fcast.sender_sdk.DeviceDiscovererEventHandler {
|
|
||||||
override fun deviceAvailable(deviceInfo: RsDeviceInfo) {
|
|
||||||
onDeviceAdded(deviceInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun deviceChanged(deviceInfo: RsDeviceInfo) {
|
|
||||||
onDeviceUpdated(deviceInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun deviceRemoved(deviceName: String) {
|
|
||||||
onDeviceRemoved(deviceName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun handleUrl(url: String) {
|
|
||||||
try {
|
|
||||||
val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!!
|
|
||||||
val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo)
|
|
||||||
connectDevice(CastingDeviceExp(foundDevice))
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to handle URL: $e")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStop() {
|
|
||||||
val ad = activeDevice ?: return
|
|
||||||
_resumeCastingDevice = ad.getDeviceInfo()
|
|
||||||
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
|
|
||||||
Logger.i(TAG, "Stopping active device because of onStop.")
|
|
||||||
try {
|
|
||||||
ad.disconnect()
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to disconnect from device: $e")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun start(context: Context) {
|
|
||||||
if (_started)
|
|
||||||
return
|
|
||||||
_started = true
|
|
||||||
|
|
||||||
Log.i(TAG, "_resumeCastingDevice set null start")
|
|
||||||
_resumeCastingDevice = null
|
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService starting...")
|
|
||||||
|
|
||||||
_castServer.start()
|
|
||||||
enableDeveloper(true)
|
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService started.")
|
|
||||||
|
|
||||||
_deviceDiscoverer = NsdDeviceDiscoverer(
|
|
||||||
context,
|
|
||||||
DiscoveryEventHandler(
|
|
||||||
{ deviceInfo -> // Added
|
|
||||||
Logger.i(TAG, "Device added: ${deviceInfo.name}")
|
|
||||||
val device = _context.createDeviceFromInfo(deviceInfo)
|
|
||||||
val deviceHandle = CastingDeviceExp(device)
|
|
||||||
devices[deviceHandle.device.name()] = deviceHandle
|
|
||||||
invokeInMainScopeIfRequired {
|
|
||||||
onDeviceAdded.emit(deviceHandle)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ deviceName -> // Removed
|
|
||||||
invokeInMainScopeIfRequired {
|
|
||||||
if (devices.containsKey(deviceName)) {
|
|
||||||
val device = devices.remove(deviceName)
|
|
||||||
if (device != null) {
|
|
||||||
onDeviceRemoved.emit(device)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ deviceInfo -> // Updated
|
|
||||||
Logger.i(TAG, "Device updated: $deviceInfo")
|
|
||||||
val handle = devices[deviceInfo.name]
|
|
||||||
if (handle != null && handle is CastingDeviceExp) {
|
|
||||||
handle.device.setPort(deviceInfo.port)
|
|
||||||
handle.device.setAddresses(deviceInfo.addresses)
|
|
||||||
invokeInMainScopeIfRequired {
|
|
||||||
onDeviceChanged.emit(handle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun stop() {
|
|
||||||
if (!_started) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_started = false
|
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService stopping.")
|
|
||||||
|
|
||||||
_scopeIO.cancel()
|
|
||||||
_scopeMain.cancel()
|
|
||||||
|
|
||||||
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
|
|
||||||
val d = activeDevice
|
|
||||||
activeDevice = null
|
|
||||||
try {
|
|
||||||
d?.disconnect()
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to disconnect device: $e")
|
|
||||||
}
|
|
||||||
|
|
||||||
_castServer.stop()
|
|
||||||
_castServer.removeAllHandlers()
|
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService stopped.")
|
|
||||||
|
|
||||||
_deviceDiscoverer = null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun startUpdateTimeJob(
|
|
||||||
onTimeJobTimeChanged_s: Event1<Long>,
|
|
||||||
setTime: (Long) -> Unit
|
|
||||||
): Job? = null
|
|
||||||
|
|
||||||
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDeviceExp? {
|
|
||||||
try {
|
|
||||||
val rsAddrs =
|
|
||||||
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) }
|
|
||||||
val rsDeviceInfo = RsDeviceInfo(
|
|
||||||
name = deviceInfo.name,
|
|
||||||
protocol = when (deviceInfo.type) {
|
|
||||||
com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST
|
|
||||||
com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST
|
|
||||||
else -> throw IllegalArgumentException()
|
|
||||||
},
|
|
||||||
addresses = rsAddrs,
|
|
||||||
port = deviceInfo.port.toUShort(),
|
|
||||||
)
|
|
||||||
|
|
||||||
return CastingDeviceExp(_context.createDeviceFromInfo(rsDeviceInfo))
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = "StateCastingExp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
package com.futo.platformplayer.casting
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import android.net.nsd.NsdManager
|
|
||||||
import android.net.nsd.NsdServiceInfo
|
|
||||||
import android.os.Build
|
|
||||||
import android.util.Base64
|
|
||||||
import android.util.Log
|
|
||||||
import com.futo.platformplayer.constructs.Event1
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import java.net.InetAddress
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
|
|
||||||
class StateCastingLegacy : StateCasting() {
|
|
||||||
private var _nsdManager: NsdManager? = null
|
|
||||||
|
|
||||||
private val _discoveryListeners = mapOf(
|
|
||||||
"_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
|
|
||||||
"_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice),
|
|
||||||
"_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice),
|
|
||||||
"_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun handleUrl(url: String) {
|
|
||||||
val uri = Uri.parse(url)
|
|
||||||
if (uri.scheme != "fcast") {
|
|
||||||
throw Exception("Expected scheme to be FCast")
|
|
||||||
}
|
|
||||||
|
|
||||||
val type = uri.host
|
|
||||||
if (type != "r") {
|
|
||||||
throw Exception("Expected type r")
|
|
||||||
}
|
|
||||||
|
|
||||||
val connectionInfo = uri.pathSegments[0]
|
|
||||||
val json =
|
|
||||||
Base64.decode(connectionInfo, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
|
||||||
.toString(Charsets.UTF_8)
|
|
||||||
val networkConfig = Json.decodeFromString<FCastNetworkConfig>(json)
|
|
||||||
val tcpService = networkConfig.services.first { v -> v.type == 0 }
|
|
||||||
|
|
||||||
val foundInfo = addRememberedDevice(
|
|
||||||
CastingDeviceInfo(
|
|
||||||
name = networkConfig.name,
|
|
||||||
type = CastProtocolType.FCAST,
|
|
||||||
addresses = networkConfig.addresses.toTypedArray(),
|
|
||||||
port = tcpService.port
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (foundInfo != null) {
|
|
||||||
connectDevice(deviceFromInfo(foundInfo))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStop() {
|
|
||||||
val ad = activeDevice ?: return;
|
|
||||||
_resumeCastingDevice = ad.getDeviceInfo()
|
|
||||||
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
|
|
||||||
Logger.i(TAG, "Stopping active device because of onStop.");
|
|
||||||
ad.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun start(context: Context) {
|
|
||||||
if (_started)
|
|
||||||
return;
|
|
||||||
_started = true;
|
|
||||||
|
|
||||||
Log.i(TAG, "_resumeCastingDevice set null start")
|
|
||||||
_resumeCastingDevice = null;
|
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService starting...");
|
|
||||||
|
|
||||||
_castServer.start();
|
|
||||||
enableDeveloper(true);
|
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService started.");
|
|
||||||
|
|
||||||
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
|
|
||||||
startDiscovering()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun startDiscovering() {
|
|
||||||
_nsdManager?.apply {
|
|
||||||
_discoveryListeners.forEach {
|
|
||||||
discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun stopDiscovering() {
|
|
||||||
_nsdManager?.apply {
|
|
||||||
_discoveryListeners.forEach {
|
|
||||||
try {
|
|
||||||
stopServiceDiscovery(it.value)
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun stop() {
|
|
||||||
if (!_started)
|
|
||||||
return;
|
|
||||||
|
|
||||||
_started = false;
|
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService stopping.")
|
|
||||||
|
|
||||||
stopDiscovering()
|
|
||||||
_scopeIO.cancel();
|
|
||||||
_scopeMain.cancel();
|
|
||||||
|
|
||||||
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
|
|
||||||
val d = activeDevice;
|
|
||||||
activeDevice = null;
|
|
||||||
d?.disconnect();
|
|
||||||
|
|
||||||
_castServer.stop();
|
|
||||||
_castServer.removeAllHandlers();
|
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService stopped.")
|
|
||||||
|
|
||||||
_nsdManager = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createDiscoveryListener(addOrUpdate: (String, Array<InetAddress>, Int) -> Unit): NsdManager.DiscoveryListener {
|
|
||||||
return object : NsdManager.DiscoveryListener {
|
|
||||||
override fun onDiscoveryStarted(regType: String) {
|
|
||||||
Log.d(TAG, "Service discovery started for $regType")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDiscoveryStopped(serviceType: String) {
|
|
||||||
Log.i(TAG, "Discovery stopped: $serviceType")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceLost(service: NsdServiceInfo) {
|
|
||||||
Log.e(TAG, "service lost: $service")
|
|
||||||
// TODO: Handle service lost, e.g., remove device
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
|
||||||
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
|
|
||||||
try {
|
|
||||||
_nsdManager?.stopServiceDiscovery(this)
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
|
|
||||||
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
|
|
||||||
try {
|
|
||||||
_nsdManager?.stopServiceDiscovery(this)
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceFound(service: NsdServiceInfo) {
|
|
||||||
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
|
|
||||||
val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
||||||
service.hostAddresses.toTypedArray()
|
|
||||||
} else {
|
|
||||||
arrayOf(service.host)
|
|
||||||
}
|
|
||||||
addOrUpdate(service.serviceName, addresses, service.port)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
||||||
_nsdManager?.registerServiceInfoCallback(
|
|
||||||
service,
|
|
||||||
{ it.run() },
|
|
||||||
object : NsdManager.ServiceInfoCallback {
|
|
||||||
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
|
|
||||||
Log.v(TAG, "onServiceUpdated: $serviceInfo")
|
|
||||||
addOrUpdate(
|
|
||||||
serviceInfo.serviceName,
|
|
||||||
serviceInfo.hostAddresses.toTypedArray(),
|
|
||||||
serviceInfo.port
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceLost() {
|
|
||||||
Log.v(TAG, "onServiceLost: $service")
|
|
||||||
// TODO: Handle service lost
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
|
|
||||||
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceInfoCallbackUnregistered() {
|
|
||||||
Log.v(TAG, "onServiceInfoCallbackUnregistered")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
|
|
||||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
|
||||||
Log.v(TAG, "Resolve failed: $errorCode")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
|
|
||||||
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
|
|
||||||
addOrUpdate(
|
|
||||||
serviceInfo.serviceName,
|
|
||||||
arrayOf(serviceInfo.host),
|
|
||||||
serviceInfo.port
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun startUpdateTimeJob(
|
|
||||||
onTimeJobTimeChanged_s: Event1<Long>,
|
|
||||||
setTime: (Long) -> Unit
|
|
||||||
): Job? {
|
|
||||||
val d = activeDevice;
|
|
||||||
if (d is CastingDeviceLegacyWrapper && (d.inner is AirPlayCastingDevice || d.inner is ChromecastCastingDevice)) {
|
|
||||||
return _scopeMain.launch {
|
|
||||||
while (true) {
|
|
||||||
val device = instance.activeDevice
|
|
||||||
if (device == null || !device.isPlaying) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
delay(1000)
|
|
||||||
val time_ms = (device.expectedCurrentTime * 1000.0).toLong()
|
|
||||||
setTime(time_ms)
|
|
||||||
onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice {
|
|
||||||
return CastingDeviceLegacyWrapper(
|
|
||||||
when (deviceInfo.type) {
|
|
||||||
CastProtocolType.CHROMECAST -> {
|
|
||||||
ChromecastCastingDevice(deviceInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
CastProtocolType.AIRPLAY -> {
|
|
||||||
AirPlayCastingDevice(deviceInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
CastProtocolType.FCAST -> {
|
|
||||||
FCastCastingDevice(deviceInfo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addOrUpdateChromeCastDevice(
|
|
||||||
name: String,
|
|
||||||
addresses: Array<InetAddress>,
|
|
||||||
port: Int
|
|
||||||
) {
|
|
||||||
return addOrUpdateCastDevice(
|
|
||||||
name,
|
|
||||||
deviceFactory = {
|
|
||||||
CastingDeviceLegacyWrapper(
|
|
||||||
ChromecastCastingDevice(
|
|
||||||
name,
|
|
||||||
addresses,
|
|
||||||
port
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
deviceUpdater = { d ->
|
|
||||||
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is ChromecastCastingDevice) {
|
|
||||||
return@addOrUpdateCastDevice false;
|
|
||||||
}
|
|
||||||
|
|
||||||
val changed =
|
|
||||||
addresses.contentEquals(d.inner.addresses) || d.name != name || d.inner.port != port;
|
|
||||||
if (changed) {
|
|
||||||
d.inner.name = name;
|
|
||||||
d.inner.addresses = addresses;
|
|
||||||
d.inner.port = port;
|
|
||||||
}
|
|
||||||
|
|
||||||
return@addOrUpdateCastDevice changed;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addOrUpdateAirPlayDevice(name: String, addresses: Array<InetAddress>, port: Int) {
|
|
||||||
return addOrUpdateCastDevice(
|
|
||||||
name,
|
|
||||||
deviceFactory = {
|
|
||||||
CastingDeviceLegacyWrapper(
|
|
||||||
AirPlayCastingDevice(
|
|
||||||
name,
|
|
||||||
addresses,
|
|
||||||
port
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
deviceUpdater = { d ->
|
|
||||||
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is AirPlayCastingDevice) {
|
|
||||||
return@addOrUpdateCastDevice false;
|
|
||||||
}
|
|
||||||
|
|
||||||
val changed =
|
|
||||||
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
|
|
||||||
if (changed) {
|
|
||||||
d.inner.name = name;
|
|
||||||
d.inner.port = port;
|
|
||||||
d.inner.addresses = addresses;
|
|
||||||
}
|
|
||||||
|
|
||||||
return@addOrUpdateCastDevice changed;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addOrUpdateFastCastDevice(name: String, addresses: Array<InetAddress>, port: Int) {
|
|
||||||
return addOrUpdateCastDevice(
|
|
||||||
name,
|
|
||||||
deviceFactory = { CastingDeviceLegacyWrapper(FCastCastingDevice(name, addresses, port)) },
|
|
||||||
deviceUpdater = { d ->
|
|
||||||
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is FCastCastingDevice) {
|
|
||||||
return@addOrUpdateCastDevice false;
|
|
||||||
}
|
|
||||||
|
|
||||||
val changed =
|
|
||||||
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
|
|
||||||
if (changed) {
|
|
||||||
d.inner.name = name;
|
|
||||||
d.inner.port = port;
|
|
||||||
d.inner.addresses = addresses;
|
|
||||||
}
|
|
||||||
|
|
||||||
return@addOrUpdateCastDevice changed;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun addOrUpdateCastDevice(
|
|
||||||
name: String,
|
|
||||||
deviceFactory: () -> CastingDevice,
|
|
||||||
deviceUpdater: (device: CastingDevice) -> Boolean
|
|
||||||
) {
|
|
||||||
var invokeEvents: (() -> Unit)? = null;
|
|
||||||
|
|
||||||
synchronized(devices) {
|
|
||||||
val device = devices[name];
|
|
||||||
if (device != null) {
|
|
||||||
val changed = deviceUpdater(device);
|
|
||||||
if (changed) {
|
|
||||||
invokeEvents = {
|
|
||||||
onDeviceChanged.emit(device);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val newDevice = deviceFactory();
|
|
||||||
this.devices[name] = newDevice
|
|
||||||
|
|
||||||
invokeEvents = {
|
|
||||||
onDeviceAdded.emit(newDevice);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
invokeEvents?.let { _scopeMain.launch { it(); }; };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class FCastNetworkConfig(
|
|
||||||
val name: String,
|
|
||||||
val addresses: List<String>,
|
|
||||||
val services: List<FCastService>
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class FCastService(
|
|
||||||
val port: Int,
|
|
||||||
val type: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = "StateCastingLegacy"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
package com.futo.platformplayer.casting.models
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastPlayMessage(
|
|
||||||
val container: String,
|
|
||||||
val url: String? = null,
|
|
||||||
val content: String? = null,
|
|
||||||
val time: Double? = null,
|
|
||||||
val speed: Double? = null
|
|
||||||
) { }
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastSeekMessage(
|
|
||||||
val time: Double
|
|
||||||
) { }
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastPlaybackUpdateMessage(
|
|
||||||
val generationTime: Long,
|
|
||||||
val time: Double,
|
|
||||||
val duration: Double,
|
|
||||||
val state: Int,
|
|
||||||
val speed: Double
|
|
||||||
) { }
|
|
||||||
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastVolumeUpdateMessage(
|
|
||||||
val generationTime: Long,
|
|
||||||
val volume: Double
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastSetVolumeMessage(
|
|
||||||
val volume: Double
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastSetSpeedMessage(
|
|
||||||
val speed: Double
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastPlaybackErrorMessage(
|
|
||||||
val message: String
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastVersionMessage(
|
|
||||||
val version: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastKeyExchangeMessage(
|
|
||||||
val version: Long,
|
|
||||||
val publicKey: String
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastDecryptedMessage(
|
|
||||||
val opcode: Long,
|
|
||||||
val message: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastEncryptedMessage(
|
|
||||||
val version: Long,
|
|
||||||
val iv: String?,
|
|
||||||
val blob: String
|
|
||||||
)
|
|
||||||
@@ -29,6 +29,8 @@ import com.google.gson.FieldAttributes
|
|||||||
import com.google.gson.GsonBuilder
|
import com.google.gson.GsonBuilder
|
||||||
import com.google.gson.JsonArray
|
import com.google.gson.JsonArray
|
||||||
import com.google.gson.JsonParser
|
import com.google.gson.JsonParser
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.lang.reflect.Field
|
import java.lang.reflect.Field
|
||||||
@@ -269,10 +271,12 @@ class DeveloperEndpoints(private val context: Context) {
|
|||||||
context.respondCode(403, "This plugin doesn't support auth");
|
context.respondCode(403, "This plugin doesn't support auth");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
LoginFragment.showLogin(config){
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
_testPluginVariables.clear();
|
LoginFragment.showLogin(config){
|
||||||
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
|
_testPluginVariables.clear();
|
||||||
};
|
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
|
||||||
|
};
|
||||||
|
}
|
||||||
/*
|
/*
|
||||||
LoginActivity.showLogin(StateApp.instance.context, config) {
|
LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||||
_testPluginVariables.clear();
|
_testPluginVariables.clear();
|
||||||
|
|||||||
@@ -16,9 +16,12 @@ import android.widget.Button
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
import com.futo.platformplayer.UpdateDownloadService
|
||||||
|
import com.futo.platformplayer.UpdateNotificationManager
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.copyToOutputStream
|
import com.futo.platformplayer.copyToOutputStream
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
@@ -34,6 +37,8 @@ import java.io.InputStream
|
|||||||
class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = "AutoUpdateDialog";
|
private val TAG = "AutoUpdateDialog";
|
||||||
|
|
||||||
|
var currentDialog: AutoUpdateDialog? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private lateinit var _buttonNever: Button;
|
private lateinit var _buttonNever: Button;
|
||||||
@@ -46,7 +51,6 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
|||||||
private var _maxVersion: Int = 0;
|
private var _maxVersion: Int = 0;
|
||||||
|
|
||||||
private var _updating: Boolean = false;
|
private var _updating: Boolean = false;
|
||||||
private var _apkFile: File? = null;
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
@@ -61,12 +65,14 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
|||||||
_buttonShowChangelog = findViewById(R.id.button_show_changelog);
|
_buttonShowChangelog = findViewById(R.id.button_show_changelog);
|
||||||
|
|
||||||
_buttonNever.setOnClickListener {
|
_buttonNever.setOnClickListener {
|
||||||
|
UpdateNotificationManager.cancelAll(context)
|
||||||
Settings.instance.autoUpdate.check = 1;
|
Settings.instance.autoUpdate.check = 1;
|
||||||
Settings.instance.save();
|
Settings.instance.save();
|
||||||
dismiss();
|
dismiss();
|
||||||
};
|
};
|
||||||
|
|
||||||
_buttonClose.setOnClickListener {
|
_buttonClose.setOnClickListener {
|
||||||
|
UpdateNotificationManager.cancelAll(context)
|
||||||
dismiss();
|
dismiss();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,23 +82,32 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
_buttonUpdate.setOnClickListener {
|
_buttonUpdate.setOnClickListener {
|
||||||
|
UpdateNotificationManager.cancelAll(context)
|
||||||
|
|
||||||
if (_updating) {
|
if (_updating) {
|
||||||
return@setOnClickListener;
|
return@setOnClickListener;
|
||||||
}
|
}
|
||||||
|
|
||||||
_updating = true;
|
if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
|
||||||
update();
|
val ctx = context.applicationContext;
|
||||||
|
val intent = Intent(ctx, UpdateDownloadService::class.java);
|
||||||
|
intent.putExtra(UpdateDownloadService.EXTRA_VERSION, _maxVersion);
|
||||||
|
ContextCompat.startForegroundService(ctx, intent);
|
||||||
|
UIDialogs.toast(context, "Downloading update in background");
|
||||||
|
dismiss();
|
||||||
|
} else {
|
||||||
|
_updating = true;
|
||||||
|
update();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
fun showPredownloaded(apkFile: File) {
|
currentDialog = this
|
||||||
_apkFile = apkFile;
|
|
||||||
super.show()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun dismiss() {
|
override fun dismiss() {
|
||||||
super.dismiss()
|
super.dismiss()
|
||||||
InstallReceiver.onReceiveResult.clear();
|
InstallReceiver.onReceiveResult.clear();
|
||||||
|
currentDialog = null
|
||||||
Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.")
|
Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,21 +133,14 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
|||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
var inputStream: InputStream? = null;
|
var inputStream: InputStream? = null;
|
||||||
try {
|
try {
|
||||||
val apkFile = _apkFile;
|
val client = ManagedHttpClient();
|
||||||
if (apkFile != null) {
|
val response = client.get(StateUpdate.APK_URL);
|
||||||
inputStream = apkFile.inputStream();
|
if (response.isOk && response.body != null) {
|
||||||
val dataLength = apkFile.length();
|
inputStream = response.body.byteStream();
|
||||||
|
val dataLength = response.body.contentLength();
|
||||||
install(inputStream, dataLength);
|
install(inputStream, dataLength);
|
||||||
} else {
|
} else {
|
||||||
val client = ManagedHttpClient();
|
throw Exception("Failed to download latest version of app.");
|
||||||
val response = client.get(StateUpdate.APK_URL);
|
|
||||||
if (response.isOk && response.body != null) {
|
|
||||||
inputStream = response.body.byteStream();
|
|
||||||
val dataLength = response.body.contentLength();
|
|
||||||
install(inputStream, dataLength);
|
|
||||||
} else {
|
|
||||||
throw Exception("Failed to download latest version of app.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.w(TAG, "Exception thrown while downloading and installing latest version of app.", e);
|
Logger.w(TAG, "Exception thrown while downloading and installing latest version of app.", e);
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package com.futo.platformplayer.dialogs
|
package com.futo.platformplayer.dialogs
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.app.AlertDialog
|
import android.app.AlertDialog
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.*
|
import android.widget.*
|
||||||
@@ -17,82 +21,79 @@ import com.google.android.material.button.MaterialButton
|
|||||||
|
|
||||||
|
|
||||||
class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
|
class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
|
||||||
private lateinit var _buttonStart: LinearLayout;
|
private lateinit var _buttonStart: LinearLayout
|
||||||
private lateinit var _buttonStop: LinearLayout;
|
private lateinit var _buttonStop: LinearLayout
|
||||||
private lateinit var _buttonCancel: ImageButton;
|
private lateinit var _buttonCancel: ImageButton
|
||||||
|
private lateinit var _imm: InputMethodManager
|
||||||
private lateinit var _editPassword: EditText;
|
|
||||||
private lateinit var _editPassword2: EditText;
|
|
||||||
|
|
||||||
private lateinit var _inputMethodManager: InputMethodManager;
|
|
||||||
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup, null));
|
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup, null))
|
||||||
|
|
||||||
_buttonCancel = findViewById(R.id.button_cancel);
|
_buttonCancel = findViewById(R.id.button_cancel)
|
||||||
_buttonStop = findViewById(R.id.button_stop);
|
_buttonStop = findViewById(R.id.button_stop)
|
||||||
_buttonStart = findViewById(R.id.button_start);
|
_buttonStart = findViewById(R.id.button_start)
|
||||||
_editPassword = findViewById(R.id.edit_password);
|
|
||||||
_editPassword2 = findViewById(R.id.edit_password2);
|
|
||||||
|
|
||||||
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
|
_imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
_buttonStart.visibility = if (Settings.instance.backup.autoBackupEnabled) View.GONE else View.VISIBLE
|
||||||
|
_buttonStop.visibility = if (Settings.instance.backup.autoBackupEnabled) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
_buttonCancel.setOnClickListener {
|
_buttonCancel.setOnClickListener {
|
||||||
clearFocus();
|
dismiss()
|
||||||
dismiss();
|
}
|
||||||
};
|
|
||||||
_buttonStop.setOnClickListener {
|
|
||||||
clearFocus();
|
|
||||||
dismiss();
|
|
||||||
Settings.instance.backup.autoBackupPassword = null;
|
|
||||||
Settings.instance.backup.didAskAutoBackup = true;
|
|
||||||
Settings.instance.save();
|
|
||||||
|
|
||||||
UIDialogs.toast(context, "AutoBackup disabled");
|
_buttonStop.setOnClickListener {
|
||||||
|
dismiss()
|
||||||
|
Settings.instance.backup.autoBackupEnabled = false
|
||||||
|
Settings.instance.backup.autoBackupPassword = null
|
||||||
|
Settings.instance.backup.didAskAutoBackup = true
|
||||||
|
Settings.instance.save()
|
||||||
|
UIDialogs.toast(context, context.getString(R.string.automatic_backup_disabled))
|
||||||
}
|
}
|
||||||
|
|
||||||
_buttonStart.setOnClickListener {
|
_buttonStart.setOnClickListener {
|
||||||
val p1 = _editPassword.text.toString();
|
dismiss()
|
||||||
val p2 = _editPassword2.text.toString();
|
Logger.i(TAG, "Enable AutoBackup (unencrypted)")
|
||||||
if(!(p1?.equals(p2) ?: false)) {
|
|
||||||
UIDialogs.toast(context, "Password fields do not match, confirm that you typed it correctly.");
|
val activity = StateApp.instance.activity as? Activity
|
||||||
return@setOnClickListener;
|
if (activity == null) {
|
||||||
|
UIDialogs.toast(context, "No activity available")
|
||||||
|
return@setOnClickListener
|
||||||
}
|
}
|
||||||
|
|
||||||
val pbytes = _editPassword.text.toString().toByteArray();
|
dismiss()
|
||||||
if(pbytes.size < 4 || pbytes.size > 32) {
|
|
||||||
UIDialogs.toast(context, "Password needs to be atleast 4 bytes long and smaller than 32 bytes", false);
|
|
||||||
return@setOnClickListener;
|
|
||||||
}
|
|
||||||
clearFocus();
|
|
||||||
dismiss();
|
|
||||||
|
|
||||||
Logger.i(TAG, "Set AutoBackupPassword");
|
Logger.i(TAG, "Enable AutoBackup")
|
||||||
Settings.instance.backup.autoBackupPassword = _editPassword.text.toString();
|
Settings.instance.backup.autoBackupPassword = null
|
||||||
Settings.instance.backup.didAskAutoBackup = true;
|
Settings.instance.backup.didAskAutoBackup = true
|
||||||
Settings.instance.save();
|
Settings.instance.save()
|
||||||
|
|
||||||
UIDialogs.toast(context, "AutoBackup enabled");
|
UIDialogs.toast(context, "AutoBackup enabled")
|
||||||
try {
|
try {
|
||||||
StateBackup.startAutomaticBackup(true);
|
StateBackup.startAutomaticBackup(true)
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Forced automatic backup failed", ex)
|
||||||
|
UIDialogs.toast(context, "Automatic backup failed due to:\n" + ex.message)
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
|
||||||
Logger.e(TAG, "Forced automatic backup failed", ex);
|
Settings.instance.backup.autoBackupEnabled = true
|
||||||
UIDialogs.toast(context, "Automatic backup failed due to:\n" + ex.message);
|
Settings.instance.backup.autoBackupPassword = null
|
||||||
|
Settings.instance.backup.didAskAutoBackup = true
|
||||||
|
Settings.instance.save()
|
||||||
|
|
||||||
|
UIDialogs.toast(context, context.getString(R.string.automatic_backup_enabled))
|
||||||
|
try {
|
||||||
|
StateBackup.startAutomaticBackup(true)
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Forced automatic backup failed", ex)
|
||||||
|
UIDialogs.toast(context, "Automatic backup failed due to:\n" + ex.message)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);
|
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearFocus() {
|
|
||||||
_editPassword.clearFocus();
|
|
||||||
currentFocus?.let { _inputMethodManager.hideSoftInputFromWindow(it.windowToken, 0) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = "AutomaticBackupDialog";
|
private const val TAG = "AutomaticBackupDialog"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,87 +3,155 @@ package com.futo.platformplayer.dialogs
|
|||||||
import android.app.AlertDialog
|
import android.app.AlertDialog
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.text.InputType
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.*
|
import android.widget.EditText
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.ProgressBar
|
||||||
|
import android.widget.TextView
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.media.PlatformID
|
|
||||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
|
||||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
|
||||||
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
|
||||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
|
|
||||||
import com.futo.platformplayer.constructs.Event1
|
|
||||||
import com.futo.platformplayer.dp
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.selectBestImage
|
|
||||||
import com.futo.platformplayer.states.StateAnnouncement
|
|
||||||
import com.futo.platformplayer.states.StateApp
|
|
||||||
import com.futo.platformplayer.states.StateBackup
|
import com.futo.platformplayer.states.StateBackup
|
||||||
import com.futo.platformplayer.states.StatePolycentric
|
|
||||||
import com.futo.polycentric.core.*
|
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import userpackage.Protocol
|
import kotlinx.coroutines.withContext
|
||||||
import java.time.OffsetDateTime
|
|
||||||
|
|
||||||
|
class AutomaticRestoreDialog(context: Context, private val scope: CoroutineScope) : AlertDialog(context) {
|
||||||
|
|
||||||
class AutomaticRestoreDialog(context: Context, val scope: CoroutineScope) : AlertDialog(context) {
|
private lateinit var _buttonStart: LinearLayout
|
||||||
private lateinit var _buttonStart: LinearLayout;
|
private lateinit var _buttonCancel: MaterialButton
|
||||||
private lateinit var _buttonCancel: MaterialButton;
|
private lateinit var _textReason: TextView
|
||||||
|
private lateinit var _editPassword: EditText
|
||||||
private lateinit var _editPassword: EditText;
|
private lateinit var _passwordContainer: LinearLayout
|
||||||
|
private lateinit var _icon: ImageView
|
||||||
private lateinit var _inputMethodManager: InputMethodManager;
|
private lateinit var _progress: ProgressBar
|
||||||
|
private lateinit var _textStart: TextView
|
||||||
|
private lateinit var _imm: InputMethodManager
|
||||||
|
|
||||||
|
private var _needsPassword: Boolean = true
|
||||||
|
private var _detectJob: Job? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup_restore, null));
|
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup_restore, null))
|
||||||
|
|
||||||
_buttonCancel = findViewById(R.id.button_cancel);
|
_buttonCancel = findViewById(R.id.button_cancel)
|
||||||
_buttonStart = findViewById(R.id.button_start);
|
_buttonStart = findViewById(R.id.button_start)
|
||||||
_editPassword = findViewById(R.id.edit_password);
|
_editPassword = findViewById(R.id.edit_password)
|
||||||
|
_textReason = findViewById(R.id.text_reason)
|
||||||
|
_passwordContainer = findViewById(R.id.password_container)
|
||||||
|
_icon = findViewById(R.id.image_icon)
|
||||||
|
_progress = findViewById(R.id.progress_restore)
|
||||||
|
_textStart = findViewById(R.id.text_start)
|
||||||
|
|
||||||
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
|
_imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
|
||||||
|
_needsPassword = true
|
||||||
|
applyMode(needsPassword = true)
|
||||||
|
setBusy(true, labelRes = R.string.checking_backup, lockCancel = false)
|
||||||
|
|
||||||
_buttonCancel.setOnClickListener {
|
_buttonCancel.setOnClickListener {
|
||||||
clearFocus();
|
clearFocus()
|
||||||
dismiss();
|
dismiss()
|
||||||
};
|
}
|
||||||
|
_buttonStart.setOnClickListener { onStartClicked() }
|
||||||
|
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
|
||||||
|
}
|
||||||
|
|
||||||
_buttonStart.setOnClickListener {
|
override fun onStart() {
|
||||||
val pbytes = _editPassword.text.toString().toByteArray();
|
super.onStart()
|
||||||
if(pbytes.size < 4 || pbytes.size > 32) {
|
|
||||||
UIDialogs.toast(context, "Password needs to be atleast 4 bytes long and less than 32 bytes", false);
|
_detectJob?.cancel()
|
||||||
return@setOnClickListener;
|
_detectJob = scope.launch(Dispatchers.Main) {
|
||||||
|
val needs = try {
|
||||||
|
StateBackup.requiresPasswordForAutomaticBackup(context)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
true
|
||||||
}
|
}
|
||||||
clearFocus();
|
|
||||||
|
|
||||||
|
if (!isShowing) return@launch
|
||||||
|
_needsPassword = needs
|
||||||
|
applyMode(needsPassword = needs)
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
_detectJob?.cancel()
|
||||||
|
_detectJob = null
|
||||||
|
super.onStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyMode(needsPassword: Boolean) {
|
||||||
|
_textStart.setText(R.string.restore)
|
||||||
|
if (needsPassword) {
|
||||||
|
_icon.setImageResource(R.drawable.ic_lock)
|
||||||
|
_passwordContainer.visibility = View.VISIBLE
|
||||||
|
_editPassword.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||||
|
_textReason.setText(R.string.it_appears_an_automatic_backup_exists_on_your_device_if_you_would_like_to_restore_enter_your_backup_password)
|
||||||
|
} else {
|
||||||
|
_icon.setImageResource(R.drawable.ic_move_up)
|
||||||
|
_passwordContainer.visibility = View.GONE
|
||||||
|
_editPassword.setText("")
|
||||||
|
_textReason.setText(R.string.automatic_backup_found_no_password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onStartClicked() {
|
||||||
|
val password = _editPassword.text?.toString() ?: ""
|
||||||
|
|
||||||
|
if (_needsPassword) {
|
||||||
|
val pbytes = password.toByteArray()
|
||||||
|
if (pbytes.size < 4 || pbytes.size > 32) {
|
||||||
|
_editPassword.error = context.getString(R.string.backup_password_length_error)
|
||||||
|
_editPassword.requestFocus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFocus()
|
||||||
|
setBusy(true, labelRes = R.string.restoring, lockCancel = true)
|
||||||
|
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
StateBackup.restoreAutomaticBackup(context, scope, _editPassword.text.toString(), true);
|
StateBackup.restoreAutomaticBackup(context, scope, if (_needsPassword) password else "", true)
|
||||||
dismiss();
|
withContext(Dispatchers.Main) {
|
||||||
|
if (isShowing) dismiss()
|
||||||
|
}
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to restore automatic backup", ex)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
if (!isShowing) return@withContext
|
||||||
|
setBusy(false)
|
||||||
|
UIDialogs.showGeneralErrorDialog(context, "Restore failed", ex)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
}
|
||||||
Logger.e(TAG, "Failed to restore automatic backup", ex);
|
}
|
||||||
//UIDialogs.toast(context, "Restore failed due to:\n" + ex.message);
|
|
||||||
UIDialogs.showGeneralErrorDialog(context, "Restore failed", ex);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);
|
private fun setBusy(busy: Boolean, labelRes: Int = R.string.restore, lockCancel: Boolean = busy) {
|
||||||
|
_progress.visibility = if (busy) View.VISIBLE else View.GONE
|
||||||
|
_buttonCancel.isEnabled = !lockCancel
|
||||||
|
_buttonStart.isEnabled = !busy
|
||||||
|
_editPassword.isEnabled = !busy && _needsPassword
|
||||||
|
_buttonStart.alpha = if (busy) 0.6f else 1.0f
|
||||||
|
_textStart.setText(labelRes)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun clearFocus() {
|
private fun clearFocus() {
|
||||||
_editPassword.clearFocus();
|
_editPassword.clearFocus()
|
||||||
currentFocus?.let { _inputMethodManager.hideSoftInputFromWindow(it.windowToken, 0) };
|
currentFocus?.let { _imm.hideSoftInputFromWindow(it.windowToken, 0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = "AutomaticRestoreDialog";
|
private const val TAG = "AutomaticRestoreDialog"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,13 +40,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
|||||||
_buttonConfirm = findViewById(R.id.button_confirm);
|
_buttonConfirm = findViewById(R.id.button_confirm);
|
||||||
_buttonTutorial = findViewById(R.id.button_tutorial)
|
_buttonTutorial = findViewById(R.id.button_tutorial)
|
||||||
|
|
||||||
val deviceTypeArray = if (Settings.instance.casting.experimentalCasting) {
|
ArrayAdapter.createFromResource(context, R.array.casting_device_type_array, R.layout.spinner_item_simple).also { adapter ->
|
||||||
R.array.exp_casting_device_type_array
|
|
||||||
} else {
|
|
||||||
R.array.casting_device_type_array
|
|
||||||
}
|
|
||||||
|
|
||||||
ArrayAdapter.createFromResource(context, deviceTypeArray, R.layout.spinner_item_simple).also { adapter ->
|
|
||||||
adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
||||||
_spinnerType.adapter = adapter;
|
_spinnerType.adapter = adapter;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import android.widget.ImageView
|
|||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.casting.CastConnectionState
|
import com.futo.platformplayer.casting.CastConnectionState
|
||||||
import com.futo.platformplayer.casting.CastProtocolType
|
import com.futo.platformplayer.casting.CastProtocolType
|
||||||
@@ -174,13 +173,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
|||||||
_textType.text = "AirPlay";
|
_textType.text = "AirPlay";
|
||||||
}
|
}
|
||||||
CastProtocolType.FCAST -> {
|
CastProtocolType.FCAST -> {
|
||||||
_imageDevice.setImageResource(
|
_imageDevice.setImageResource(R.drawable.ic_fc)
|
||||||
if (Settings.instance.casting.experimentalCasting) {
|
|
||||||
R.drawable.ic_exp_fc
|
|
||||||
} else {
|
|
||||||
R.drawable.ic_fc
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_textType.text = "FCast";
|
_textType.text = "FCast";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
package com.futo.platformplayer.downloads
|
package com.futo.platformplayer.downloads
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.media.MediaCodec
|
||||||
|
import android.media.MediaExtractor
|
||||||
|
import android.media.MediaMuxer
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.arthenica.ffmpegkit.FFmpegKit
|
import com.arthenica.ffmpegkit.FFmpegKit
|
||||||
import com.arthenica.ffmpegkit.ReturnCode
|
import com.arthenica.ffmpegkit.ReturnCode
|
||||||
import com.arthenica.ffmpegkit.StatisticsCallback
|
import com.arthenica.ffmpegkit.StatisticsCallback
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
|
import com.futo.platformplayer.api.media.IPlatformClient
|
||||||
import com.futo.platformplayer.api.media.PlatformID
|
import com.futo.platformplayer.api.media.PlatformID
|
||||||
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
|
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
|
||||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||||
@@ -37,10 +42,13 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif
|
|||||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
|
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
|
import com.futo.platformplayer.engine.exceptions.ScriptException
|
||||||
|
import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException
|
||||||
import com.futo.platformplayer.exceptions.DownloadException
|
import com.futo.platformplayer.exceptions.DownloadException
|
||||||
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
|
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
|
||||||
import com.futo.platformplayer.helpers.VideoHelper
|
import com.futo.platformplayer.helpers.VideoHelper
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.others.Language
|
||||||
import com.futo.platformplayer.parsers.HLS
|
import com.futo.platformplayer.parsers.HLS
|
||||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||||
import com.futo.platformplayer.states.StateDownloads
|
import com.futo.platformplayer.states.StateDownloads
|
||||||
@@ -83,6 +91,9 @@ import kotlin.time.times
|
|||||||
class VideoDownload {
|
class VideoDownload {
|
||||||
var state: State = State.QUEUED;
|
var state: State = State.QUEUED;
|
||||||
|
|
||||||
|
@Contextual
|
||||||
|
@Transient
|
||||||
|
var plugin: IPlatformClient? = null;
|
||||||
var video: SerializedPlatformVideo? = null;
|
var video: SerializedPlatformVideo? = null;
|
||||||
var videoDetails: SerializedPlatformVideoDetails? = null;
|
var videoDetails: SerializedPlatformVideoDetails? = null;
|
||||||
|
|
||||||
@@ -98,6 +109,7 @@ class VideoDownload {
|
|||||||
|
|
||||||
var videoSource: VideoUrlSource?;
|
var videoSource: VideoUrlSource?;
|
||||||
var audioSource: AudioUrlSource?;
|
var audioSource: AudioUrlSource?;
|
||||||
|
var overrideResultAudioSource: IAudioSource? = null;
|
||||||
@Contextual
|
@Contextual
|
||||||
@Transient
|
@Transient
|
||||||
val videoSourceToUse: IVideoSource? get () = if(requiresLiveVideoSource) videoSourceLive as IVideoSource? else videoSource as IVideoSource?;
|
val videoSourceToUse: IVideoSource? get () = if(requiresLiveVideoSource) videoSourceLive as IVideoSource? else videoSource as IVideoSource?;
|
||||||
@@ -267,7 +279,7 @@ class VideoDownload {
|
|||||||
|
|
||||||
//Fetch full video object and determine source
|
//Fetch full video object and determine source
|
||||||
if(video != null && videoDetails == null) {
|
if(video != null && videoDetails == null) {
|
||||||
val original = StatePlatform.instance.getContentDetails(video!!.url).await();
|
val original = if (plugin != null) plugin!!.getContentDetails(video!!.url) else StatePlatform.instance.getContentDetails(video!!.url)?.await();
|
||||||
if(original !is IPlatformVideoDetails)
|
if(original !is IPlatformVideoDetails)
|
||||||
throw IllegalStateException("Original content is not media?");
|
throw IllegalStateException("Original content is not media?");
|
||||||
|
|
||||||
@@ -434,6 +446,11 @@ class VideoDownload {
|
|||||||
videoFileNameBase = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}]".sanitizeFileName();
|
videoFileNameBase = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}]".sanitizeFileName();
|
||||||
videoFileNameExt = videoContainerToExtension(actualVideoSource!!.container);
|
videoFileNameExt = videoContainerToExtension(actualVideoSource!!.container);
|
||||||
videoFilePath = File(downloadDir, videoFileName!!).absolutePath;
|
videoFilePath = File(downloadDir, videoFileName!!).absolutePath;
|
||||||
|
if(actualVideoSource is JSDashManifestRawSource && actualAudioSource == null) {
|
||||||
|
audioFileNameBase = "${videoDetails!!.id.value!!}-[unknown]".sanitizeFileName();
|
||||||
|
audioFileNameExt = videoAudioContainerToExtension(actualVideoSource!!.container);
|
||||||
|
audioFilePath = File(downloadDir, audioFileName!!).absolutePath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if(actualAudioSource != null) {
|
if(actualAudioSource != null) {
|
||||||
audioFileNameBase = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}]".sanitizeFileName();
|
audioFileNameBase = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}]".sanitizeFileName();
|
||||||
@@ -487,7 +504,11 @@ class VideoDownload {
|
|||||||
else -> downloadFileSource("Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
else -> downloadFileSource("Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||||
}
|
}
|
||||||
else if(actualVideoSource is JSDashManifestRawSource) {
|
else if(actualVideoSource is JSDashManifestRawSource) {
|
||||||
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback);
|
if(actualAudioSource == null)
|
||||||
|
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback, 3,
|
||||||
|
File(downloadDir, audioFileName!!));
|
||||||
|
else
|
||||||
|
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback, 1);
|
||||||
}
|
}
|
||||||
else throw NotImplementedError("NotImplemented video download: " + actualVideoSource.javaClass.name);
|
else throw NotImplementedError("NotImplemented video download: " + actualVideoSource.javaClass.name);
|
||||||
});
|
});
|
||||||
@@ -527,7 +548,7 @@ class VideoDownload {
|
|||||||
else -> downloadFileSource("Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
else -> downloadFileSource("Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||||
}
|
}
|
||||||
else if(actualAudioSource is JSDashManifestRawAudioSource) {
|
else if(actualAudioSource is JSDashManifestRawAudioSource) {
|
||||||
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback);
|
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback, 2);
|
||||||
}
|
}
|
||||||
else throw NotImplementedError("NotImplemented audio download: " + actualAudioSource.javaClass.name);
|
else throw NotImplementedError("NotImplemented audio download: " + actualAudioSource.javaClass.name);
|
||||||
});
|
});
|
||||||
@@ -585,55 +606,60 @@ class VideoDownload {
|
|||||||
return cipher.doFinal(encryptedSegment)
|
return cipher.doFinal(encryptedSegment)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun remuxWithFfmpegInPlace(inputFile: File): Boolean {
|
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
|
||||||
val inputPath = inputFile.absolutePath
|
require(segmentFiles.isNotEmpty()) { "segmentFiles must not be empty" }
|
||||||
if (!inputFile.exists()) {
|
|
||||||
Logger.w(TAG, "remuxWithFfmpegInPlace: input does not exist: $inputPath")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
val parent = inputFile.parentFile
|
suspendCancellableCoroutine { continuation ->
|
||||||
if (parent == null) {
|
val concatInput = buildString {
|
||||||
Logger.w(TAG, "remuxWithFfmpegInPlace: input has no parent: $inputPath")
|
append("concat:")
|
||||||
return false
|
append(
|
||||||
}
|
segmentFiles.joinToString("|") { file ->
|
||||||
|
file.absolutePath
|
||||||
val tmpFile = File(parent, inputFile.nameWithoutExtension + "_fixed." + inputFile.extension)
|
}
|
||||||
val cmd = buildString {
|
)
|
||||||
append("-y ")
|
|
||||||
append("-i \"").append(inputFile.absolutePath).append("\" ")
|
|
||||||
append("-c copy ")
|
|
||||||
append("-movflags +faststart ")
|
|
||||||
append("\"").append(tmpFile.absolutePath).append("\"")
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "FFmpeg remux command: $cmd")
|
|
||||||
|
|
||||||
val session = FFmpegKit.execute(cmd)
|
|
||||||
val returnCode = session.returnCode
|
|
||||||
|
|
||||||
if (ReturnCode.isSuccess(returnCode)) {
|
|
||||||
val newLen = tmpFile.length()
|
|
||||||
|
|
||||||
if (!inputFile.delete()) {
|
|
||||||
Logger.w(TAG, "remuxWithFfmpegInPlace: failed to delete original: ${inputFile.absolutePath}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tmpFile.renameTo(inputFile)) {
|
val cmd = "-i \"$concatInput\" -c copy \"${targetFile.absolutePath}\""
|
||||||
Logger.w(TAG, "remuxWithFfmpegInPlace: failed to move tmp: ${tmpFile.absolutePath}")
|
|
||||||
} else {
|
val statisticsCallback = StatisticsCallback { _ ->
|
||||||
Logger.i(TAG, "remuxWithFfmpegInPlace: success for $inputPath (size=$newLen bytes)")
|
//No callback
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
val executorService = Executors.newSingleThreadExecutor()
|
||||||
} else {
|
|
||||||
Logger.e(TAG, "FFmpeg remux failed for $inputPath. rc=$returnCode, logs=${session.allLogsAsString}")
|
val session = FFmpegKit.executeAsync(
|
||||||
tmpFile.delete()
|
cmd,
|
||||||
return false
|
{ completedSession ->
|
||||||
|
executorService.shutdown()
|
||||||
|
|
||||||
|
if (ReturnCode.isSuccess(completedSession.returnCode)) {
|
||||||
|
continuation.resumeWith(Result.success(Unit))
|
||||||
|
} else {
|
||||||
|
val errorMessage = if (ReturnCode.isCancel(completedSession.returnCode)) {
|
||||||
|
"Command cancelled"
|
||||||
|
} else {
|
||||||
|
"Command failed with state '${completedSession.state}' " +
|
||||||
|
"and return code ${completedSession.returnCode}, " +
|
||||||
|
"stack trace ${completedSession.failStackTrace}"
|
||||||
|
}
|
||||||
|
continuation.resumeWithException(RuntimeException(errorMessage))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ log ->
|
||||||
|
Logger.v(TAG, log.message)
|
||||||
|
},
|
||||||
|
statisticsCallback,
|
||||||
|
executorService
|
||||||
|
)
|
||||||
|
|
||||||
|
continuation.invokeOnCancellation {
|
||||||
|
session.cancel()
|
||||||
|
executorService.shutdownNow()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, source: JSSource?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, source: JSSource?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||||
if (targetFile.exists())
|
if (targetFile.exists())
|
||||||
targetFile.delete()
|
targetFile.delete()
|
||||||
|
|
||||||
@@ -678,6 +704,7 @@ class VideoDownload {
|
|||||||
.array()
|
.array()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val segmentFiles = arrayListOf<File>()
|
||||||
try {
|
try {
|
||||||
val playlistHeaders = mutableMapOf<String, String>()
|
val playlistHeaders = mutableMapOf<String, String>()
|
||||||
val modifiedPlaylistReq = modifier?.modifyRequest(hlsUrl, playlistHeaders)
|
val modifiedPlaylistReq = modifier?.modifyRequest(hlsUrl, playlistHeaders)
|
||||||
@@ -713,123 +740,134 @@ class VideoDownload {
|
|||||||
val mediaSequence = variantPlaylist.mediaSequence ?: 0L
|
val mediaSequence = variantPlaylist.mediaSequence ?: 0L
|
||||||
val rangeOffsets = mutableMapOf<String, Long>()
|
val rangeOffsets = mutableMapOf<String, Long>()
|
||||||
|
|
||||||
targetFile.outputStream().use { outStr ->
|
if (!variantPlaylist.mapUrl.isNullOrEmpty()) {
|
||||||
if (!variantPlaylist.mapUrl.isNullOrEmpty()) {
|
if (isCancelled) throw CancellationException("Cancelled")
|
||||||
if (isCancelled) throw CancellationException("Cancelled")
|
|
||||||
|
|
||||||
Logger.i(TAG, "Downloading HLS initialization map")
|
Logger.i(TAG, "Downloading HLS initialization map")
|
||||||
|
|
||||||
var mapRangeStart: Long? = null
|
var mapRangeStart: Long? = null
|
||||||
var mapRangeLength: Long? = null
|
var mapRangeLength: Long? = null
|
||||||
|
|
||||||
if (variantPlaylist.mapBytesLength > 0) {
|
if (variantPlaylist.mapBytesLength > 0) {
|
||||||
mapRangeLength = variantPlaylist.mapBytesLength
|
mapRangeLength = variantPlaylist.mapBytesLength
|
||||||
|
|
||||||
val mapUrl = variantPlaylist.mapUrl!!
|
val mapUrl = variantPlaylist.mapUrl
|
||||||
if (variantPlaylist.mapBytesStart >= 0) {
|
if (variantPlaylist.mapBytesStart >= 0) {
|
||||||
mapRangeStart = variantPlaylist.mapBytesStart
|
mapRangeStart = variantPlaylist.mapBytesStart
|
||||||
rangeOffsets[mapUrl] =
|
rangeOffsets[mapUrl] =
|
||||||
variantPlaylist.mapBytesStart + variantPlaylist.mapBytesLength
|
variantPlaylist.mapBytesStart + variantPlaylist.mapBytesLength
|
||||||
} else {
|
} else {
|
||||||
val offset = rangeOffsets[mapUrl] ?: 0L
|
val offset = rangeOffsets[mapUrl] ?: 0L
|
||||||
mapRangeStart = offset
|
mapRangeStart = offset
|
||||||
rangeOffsets[mapUrl] = offset + variantPlaylist.mapBytesLength
|
rangeOffsets[mapUrl] = offset + variantPlaylist.mapBytesLength
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var mapBytes = downloadBytes(variantPlaylist.mapUrl!!, mapRangeStart, mapRangeLength)
|
var mapBytes = downloadBytes(variantPlaylist.mapUrl!!, mapRangeStart, mapRangeLength)
|
||||||
|
|
||||||
if (useDecryption) {
|
if (useDecryption) {
|
||||||
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
|
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
|
||||||
val iv = staticIvBytes
|
val iv = staticIvBytes
|
||||||
?: throw UnsupportedOperationException("Encrypted EXT-X-MAP without explicit IV is not supported.")
|
?: throw UnsupportedOperationException("Encrypted EXT-X-MAP without explicit IV is not supported.")
|
||||||
mapBytes = decryptSegment(mapBytes, kb, iv)
|
mapBytes = decryptSegment(mapBytes, kb, iv)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mapBytes.size.toLong() > Int.MAX_VALUE) {
|
if (mapBytes.size.toLong() > Int.MAX_VALUE) {
|
||||||
throw IllegalStateException("HLS MAP segment too large to handle.")
|
throw IllegalStateException("HLS MAP segment too large to handle.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
|
||||||
|
val outStr = segmentFile.outputStream()
|
||||||
|
try {
|
||||||
|
segmentFiles.add(segmentFile)
|
||||||
outStr.write(mapBytes)
|
outStr.write(mapBytes)
|
||||||
outStr.flush()
|
outStr.flush()
|
||||||
downloadedTotalLength += mapBytes.size
|
} finally {
|
||||||
}
|
outStr.close()
|
||||||
|
|
||||||
val totalSegments = variantPlaylist.segments.size
|
|
||||||
var mediaSegmentIndex = 0
|
|
||||||
|
|
||||||
var bytesSinceLastSpeedUpdate = 0L
|
|
||||||
var lastSpeedUpdateTime = System.currentTimeMillis()
|
|
||||||
var lastSpeed = 0L
|
|
||||||
|
|
||||||
variantPlaylist.segments.forEachIndexed { index, segment ->
|
|
||||||
if (segment !is HLS.MediaSegment) return@forEachIndexed
|
|
||||||
if (isCancelled) throw CancellationException("Cancelled")
|
|
||||||
|
|
||||||
Logger.i(TAG, "Download '$name' segment $index sequential")
|
|
||||||
|
|
||||||
var rangeStart: Long? = null
|
|
||||||
var rangeLength: Long? = null
|
|
||||||
|
|
||||||
if (segment.bytesLength > 0) {
|
|
||||||
rangeLength = segment.bytesLength
|
|
||||||
|
|
||||||
val urlKey = segment.uri
|
|
||||||
if (segment.bytesStart >= 0) {
|
|
||||||
rangeStart = segment.bytesStart
|
|
||||||
rangeOffsets[urlKey] = segment.bytesStart + segment.bytesLength
|
|
||||||
} else {
|
|
||||||
val offset = rangeOffsets[urlKey] ?: 0L
|
|
||||||
rangeStart = offset
|
|
||||||
rangeOffsets[urlKey] = offset + segment.bytesLength
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var segmentBytes = downloadBytes(segment.uri, rangeStart, rangeLength)
|
|
||||||
|
|
||||||
if (useDecryption) {
|
|
||||||
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
|
|
||||||
val ivBytes = if (staticIvBytes != null) {
|
|
||||||
staticIvBytes!!
|
|
||||||
} else {
|
|
||||||
val sequenceNumber = mediaSequence + mediaSegmentIndex
|
|
||||||
buildSequenceIv(sequenceNumber)
|
|
||||||
}
|
|
||||||
|
|
||||||
segmentBytes = decryptSegment(segmentBytes, kb, ivBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
val segmentLength = segmentBytes.size.toLong()
|
|
||||||
if (segmentLength > Int.MAX_VALUE) {
|
|
||||||
throw IllegalStateException("HLS media segment too large to handle.")
|
|
||||||
}
|
|
||||||
|
|
||||||
val avgLen = if (index == 0) {
|
|
||||||
segmentLength
|
|
||||||
} else {
|
|
||||||
if (index > 0) downloadedTotalLength / index else segmentLength
|
|
||||||
}
|
|
||||||
val expectedTotal = avgLen * (totalSegments - 1) + segmentLength
|
|
||||||
|
|
||||||
outStr.write(segmentBytes)
|
|
||||||
downloadedTotalLength += segmentLength
|
|
||||||
|
|
||||||
bytesSinceLastSpeedUpdate += segmentLength
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val elapsed = now - lastSpeedUpdateTime
|
|
||||||
if (elapsed >= 500 && bytesSinceLastSpeedUpdate > 0) {
|
|
||||||
lastSpeed = (bytesSinceLastSpeedUpdate * 1000L / elapsed)
|
|
||||||
bytesSinceLastSpeedUpdate = 0
|
|
||||||
lastSpeedUpdateTime = now
|
|
||||||
}
|
|
||||||
|
|
||||||
onProgress(expectedTotal, downloadedTotalLength, lastSpeed)
|
|
||||||
mediaSegmentIndex++
|
|
||||||
}
|
}
|
||||||
|
downloadedTotalLength += mapBytes.size
|
||||||
}
|
}
|
||||||
|
|
||||||
remuxWithFfmpegInPlace(targetFile)
|
val totalSegments = variantPlaylist.segments.size
|
||||||
|
var mediaSegmentIndex = 0
|
||||||
|
|
||||||
|
var bytesSinceLastSpeedUpdate = 0L
|
||||||
|
var lastSpeedUpdateTime = System.currentTimeMillis()
|
||||||
|
var lastSpeed = 0L
|
||||||
|
|
||||||
|
variantPlaylist.segments.forEachIndexed { index, segment ->
|
||||||
|
if (segment !is HLS.MediaSegment) return@forEachIndexed
|
||||||
|
if (isCancelled) throw CancellationException("Cancelled")
|
||||||
|
|
||||||
|
Logger.i(TAG, "Download '$name' segment $index sequential")
|
||||||
|
|
||||||
|
var rangeStart: Long? = null
|
||||||
|
var rangeLength: Long? = null
|
||||||
|
|
||||||
|
if (segment.bytesLength > 0) {
|
||||||
|
rangeLength = segment.bytesLength
|
||||||
|
|
||||||
|
val urlKey = segment.uri
|
||||||
|
if (segment.bytesStart >= 0) {
|
||||||
|
rangeStart = segment.bytesStart
|
||||||
|
rangeOffsets[urlKey] = segment.bytesStart + segment.bytesLength
|
||||||
|
} else {
|
||||||
|
val offset = rangeOffsets[urlKey] ?: 0L
|
||||||
|
rangeStart = offset
|
||||||
|
rangeOffsets[urlKey] = offset + segment.bytesLength
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var segmentBytes = downloadBytes(segment.uri, rangeStart, rangeLength)
|
||||||
|
|
||||||
|
if (useDecryption) {
|
||||||
|
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
|
||||||
|
val ivBytes = if (staticIvBytes != null) {
|
||||||
|
staticIvBytes
|
||||||
|
} else {
|
||||||
|
val sequenceNumber = mediaSequence + mediaSegmentIndex
|
||||||
|
buildSequenceIv(sequenceNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
segmentBytes = decryptSegment(segmentBytes, kb, ivBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
val segmentLength = segmentBytes.size.toLong()
|
||||||
|
if (segmentLength > Int.MAX_VALUE) {
|
||||||
|
throw IllegalStateException("HLS media segment too large to handle.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val avgLen = if (index == 0) {
|
||||||
|
segmentLength
|
||||||
|
} else {
|
||||||
|
if (index > 0) downloadedTotalLength / index else segmentLength
|
||||||
|
}
|
||||||
|
val expectedTotal = avgLen * (totalSegments - 1) + segmentLength
|
||||||
|
|
||||||
|
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
|
||||||
|
val outStr = segmentFile.outputStream()
|
||||||
|
try {
|
||||||
|
segmentFiles.add(segmentFile)
|
||||||
|
outStr.write(segmentBytes)
|
||||||
|
} finally {
|
||||||
|
outStr.close()
|
||||||
|
}
|
||||||
|
downloadedTotalLength += segmentLength
|
||||||
|
|
||||||
|
bytesSinceLastSpeedUpdate += segmentLength
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val elapsed = now - lastSpeedUpdateTime
|
||||||
|
if (elapsed >= 500 && bytesSinceLastSpeedUpdate > 0) {
|
||||||
|
lastSpeed = (bytesSinceLastSpeedUpdate * 1000L / elapsed)
|
||||||
|
bytesSinceLastSpeedUpdate = 0
|
||||||
|
lastSpeedUpdateTime = now
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress(expectedTotal, downloadedTotalLength, lastSpeed)
|
||||||
|
mediaSegmentIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
combineSegments(context, segmentFiles, targetFile)
|
||||||
Logger.i(TAG, "Finished HLS Source for $name")
|
Logger.i(TAG, "Finished HLS Source for $name")
|
||||||
} catch (ioex: IOException) {
|
} catch (ioex: IOException) {
|
||||||
if (targetFile.exists())
|
if (targetFile.exists())
|
||||||
@@ -843,19 +881,30 @@ class VideoDownload {
|
|||||||
targetFile.delete()
|
targetFile.delete()
|
||||||
throw ex
|
throw ex
|
||||||
}
|
}
|
||||||
|
finally {
|
||||||
|
for (segmentFile in segmentFiles) {
|
||||||
|
segmentFile.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return downloadedTotalLength
|
return downloadedTotalLength
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit, downloadType: Int = 0, targetFileAudio: File? = null): Long {
|
||||||
if(targetFile.exists())
|
if(targetFile.exists())
|
||||||
targetFile.delete();
|
targetFile.delete();
|
||||||
|
if(targetFileAudio?.exists() ?: false)
|
||||||
|
targetFileAudio.delete();
|
||||||
|
|
||||||
targetFile.createNewFile();
|
targetFile.createNewFile();
|
||||||
|
targetFileAudio?.createNewFile();
|
||||||
|
|
||||||
val sourceLength: Long?;
|
val sourceLength: Long?;
|
||||||
|
val sourceLengthAudio: Long?;
|
||||||
val fileStream = FileOutputStream(targetFile);
|
val fileStream = FileOutputStream(targetFile);
|
||||||
|
val fileStream2 = if(targetFileAudio != null) FileOutputStream(targetFileAudio) else null;
|
||||||
|
|
||||||
|
var executor: JSRequestExecutor? = null;
|
||||||
try{
|
try{
|
||||||
var manifest = source.manifest;
|
var manifest = source.manifest;
|
||||||
if(source.hasGenerate)
|
if(source.hasGenerate)
|
||||||
@@ -864,15 +913,28 @@ class VideoDownload {
|
|||||||
throw IllegalStateException("No manifest after generation");
|
throw IllegalStateException("No manifest after generation");
|
||||||
|
|
||||||
//TODO: Temporary naive assume single-sourced dash
|
//TODO: Temporary naive assume single-sourced dash
|
||||||
val foundTemplate = REGEX_DASH_TEMPLATE.find(manifest);
|
val foundTemplates = REGEX_DASH_TEMPLATE_WITH_MIME.findAll(manifest);
|
||||||
if(foundTemplate == null || foundTemplate.groupValues.size != 3)
|
val foundTemplate = when(downloadType) {
|
||||||
|
1 -> foundTemplates.find({ it.groupValues[1].contains("video/") });
|
||||||
|
2 -> foundTemplates.find({ it.groupValues[1].contains("audio/") });
|
||||||
|
else -> foundTemplates.find({ it.groupValues[1].contains("video/") });
|
||||||
|
}
|
||||||
|
if(foundTemplate == null || foundTemplate.groupValues.size != 4)
|
||||||
throw IllegalStateException("No SegmentTemplate found in manifest (unsupported dash?)");
|
throw IllegalStateException("No SegmentTemplate found in manifest (unsupported dash?)");
|
||||||
val foundTemplateUrl = foundTemplate.groupValues[1];
|
val foundTemplateUrl = foundTemplate.groupValues[2];
|
||||||
val foundCues = REGEX_DASH_CUE.findAll(foundTemplate.groupValues[2]);
|
val foundCues = REGEX_DASH_CUE.findAll(foundTemplate.groupValues[3]).toList();
|
||||||
if(foundCues.count() <= 0)
|
if(foundCues.count() <= 0)
|
||||||
throw IllegalStateException("No Cues found in manifest (unsupported dash?)");
|
throw IllegalStateException("No Cues found in manifest (unsupported dash?)");
|
||||||
|
|
||||||
val executor = if(source is JSSource && source.hasRequestExecutor)
|
val foundTemplate2 = if(downloadType == 3) foundTemplates.find({ it.groupValues[1].contains("audio/") }); else null;
|
||||||
|
val foundTemplateUrl2 = if(foundTemplate2 != null) foundTemplate2.groupValues[2] else null;
|
||||||
|
val foundCues2 = if(foundTemplate2 != null) REGEX_DASH_CUE.findAll(foundTemplate2.groupValues[3]).toList() else null;
|
||||||
|
val foundCues2Downloaded = hashSetOf<MatchResult>();
|
||||||
|
|
||||||
|
if(foundTemplate2 != null)
|
||||||
|
overrideResultAudioSource = LocalAudioSource((videoSource?.name)?.let { it + " [audio]" } ?: "audio", "", 0, 0, foundTemplate2.groupValues[1], REGEX_CODECS.find(foundTemplate2.groupValues[0])?.groupValues?.get(1) ?: "", Language.UNKNOWN);
|
||||||
|
|
||||||
|
executor = if(source is JSSource && source.hasRequestExecutor)
|
||||||
source.getRequestExecutor();
|
source.getRequestExecutor();
|
||||||
else
|
else
|
||||||
null;
|
null;
|
||||||
@@ -886,13 +948,17 @@ class VideoDownload {
|
|||||||
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
|
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
|
||||||
|
|
||||||
var written: Long = 0;
|
var written: Long = 0;
|
||||||
|
var written2: Long = 0;
|
||||||
var indexCounter = 0;
|
var indexCounter = 0;
|
||||||
|
var indexCounter2 = 0;
|
||||||
onProgress(foundCues.count().toLong(), 0, 0);
|
onProgress(foundCues.count().toLong(), 0, 0);
|
||||||
|
val totalCues = foundCues.count().toLong() + (foundCues2?.count()?.toLong() ?: 0)
|
||||||
|
val lastCue = foundCues.lastOrNull();
|
||||||
for(cue in foundCues) {
|
for(cue in foundCues) {
|
||||||
val t = cue.groupValues[1];
|
val t = cue.groupValues[1];
|
||||||
val d = cue.groupValues[2];
|
val d = cue.groupValues[2];
|
||||||
|
|
||||||
|
Logger.i(TAG, "Downloading cue ${indexCounter}")
|
||||||
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
|
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
|
||||||
val modified = modifier?.modifyRequest(url, mapOf());
|
val modified = modifier?.modifyRequest(url, mapOf());
|
||||||
|
|
||||||
@@ -908,17 +974,60 @@ class VideoDownload {
|
|||||||
speedTracker.addWork(data.size.toLong());
|
speedTracker.addWork(data.size.toLong());
|
||||||
written += data.size;
|
written += data.size;
|
||||||
|
|
||||||
onProgress(foundCues.count().toLong(), indexCounter.toLong(), speedTracker.lastSpeed);
|
onProgress(totalCues, indexCounter.toLong() + indexCounter2.toLong(), speedTracker.lastSpeed);
|
||||||
|
|
||||||
|
|
||||||
indexCounter++;
|
indexCounter++;
|
||||||
|
|
||||||
|
if(foundCues2 != null && foundTemplateUrl2 != null && fileStream2 != null) {
|
||||||
|
val toDownload = if(lastCue != null && cue == lastCue)
|
||||||
|
foundCues2.filter { !foundCues2Downloaded.contains(it) }.toList() else
|
||||||
|
foundCues2.filter { !foundCues2Downloaded.contains(it) && (it.groupValues[1].toLong()) < t.toLong() }.toList();
|
||||||
|
Logger.i(TAG, "Downloading audio cues (${toDownload.size})")
|
||||||
|
for(cue2 in toDownload) {
|
||||||
|
val index2 = foundCues2.indexOf(cue2);
|
||||||
|
val t2 = cue2.groupValues[1];
|
||||||
|
val d2 = cue2.groupValues[2];
|
||||||
|
val url2 = foundTemplateUrl2!!.replace("\$Number\$", (index2).toString());
|
||||||
|
val modified2 = modifier?.modifyRequest(url, mapOf());
|
||||||
|
|
||||||
|
val data = if(executor != null)
|
||||||
|
executor.executeRequest("GET", modified2?.url ?: url2, null, modified2?.headers ?: mapOf());
|
||||||
|
else {
|
||||||
|
val resp = client.get(modified2?.url ?: url, modified2?.headers?.toMutableMap() ?: mutableMapOf());
|
||||||
|
if(!resp.isOk)
|
||||||
|
throw IllegalStateException("Dash request2 failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
|
||||||
|
resp.body!!.bytes()
|
||||||
|
}
|
||||||
|
fileStream2.write(data, 0, data.size);
|
||||||
|
speedTracker.addWork(data.size.toLong());
|
||||||
|
written2 += data.size;
|
||||||
|
indexCounter2++;
|
||||||
|
|
||||||
|
foundCues2Downloaded.add(cue2);
|
||||||
|
onProgress(totalCues, indexCounter.toLong() + indexCounter2.toLong(), speedTracker.lastSpeed);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sourceLength = written;
|
sourceLength = written;
|
||||||
|
sourceLengthAudio = written2;
|
||||||
|
|
||||||
Logger.i(TAG, "$name downloadSource Finished");
|
Logger.i(TAG, "$name downloadSource Finished");
|
||||||
}
|
}
|
||||||
|
catch(scriptEx: ScriptReloadRequiredException) {
|
||||||
|
if(targetFile.exists() ?: false)
|
||||||
|
targetFile.delete();
|
||||||
|
if(targetFileAudio?.exists() ?: false)
|
||||||
|
targetFileAudio.delete();
|
||||||
|
|
||||||
|
createNewPluginClient();
|
||||||
|
throw scriptEx;
|
||||||
|
}
|
||||||
catch(ioex: IOException) {
|
catch(ioex: IOException) {
|
||||||
if(targetFile.exists() ?: false)
|
if(targetFile.exists() ?: false)
|
||||||
targetFile.delete();
|
targetFile.delete();
|
||||||
|
if(targetFileAudio?.exists() ?: false)
|
||||||
|
targetFileAudio.delete();
|
||||||
if(ioex.message?.contains("ENOSPC") ?: false)
|
if(ioex.message?.contains("ENOSPC") ?: false)
|
||||||
throw Exception("Not enough space on device", ioex);
|
throw Exception("Not enough space on device", ioex);
|
||||||
else
|
else
|
||||||
@@ -927,13 +1036,37 @@ class VideoDownload {
|
|||||||
catch(ex: Throwable) {
|
catch(ex: Throwable) {
|
||||||
if(targetFile.exists() ?: false)
|
if(targetFile.exists() ?: false)
|
||||||
targetFile.delete();
|
targetFile.delete();
|
||||||
|
if(targetFileAudio?.exists() ?: false)
|
||||||
|
targetFileAudio.delete();
|
||||||
throw ex;
|
throw ex;
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
fileStream.close();
|
fileStream.close();
|
||||||
|
fileStream2?.close();
|
||||||
|
executor?.closeAsync()
|
||||||
}
|
}
|
||||||
|
if(sourceLengthAudio != null && sourceLengthAudio > 0)
|
||||||
|
audioFileSize = sourceLengthAudio
|
||||||
return sourceLength!!;
|
return sourceLength!!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun createNewPluginClient() {
|
||||||
|
UIDialogs.appToast("Download creating new client at request of plugin");
|
||||||
|
cleanupPluginClient();
|
||||||
|
plugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null }?.getCopy(false, true);
|
||||||
|
plugin?.initialize();
|
||||||
|
}
|
||||||
|
fun cleanupPluginClient() {
|
||||||
|
val oldPlugin = plugin;
|
||||||
|
plugin = null;
|
||||||
|
try {
|
||||||
|
oldPlugin?.disable();
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to dispose download client: ${ex.message}" , ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun downloadFileSource(name: String, client: ManagedHttpClient, source: JSSource?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
private fun downloadFileSource(name: String, client: ManagedHttpClient, source: JSSource?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||||
if(targetFile.exists())
|
if(targetFile.exists())
|
||||||
targetFile.delete();
|
targetFile.delete();
|
||||||
@@ -1293,7 +1426,7 @@ class VideoDownload {
|
|||||||
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
|
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if(audioSourceToUse != null) {
|
if(audioSourceToUse != null || (videoSourceToUse is IJSDashManifestRawSource)) {
|
||||||
if(audioFilePath == null)
|
if(audioFilePath == null)
|
||||||
throw IllegalStateException("Missing audio file name after download");
|
throw IllegalStateException("Missing audio file name after download");
|
||||||
val expectedFile = File(audioFilePath!!);
|
val expectedFile = File(audioFilePath!!);
|
||||||
@@ -1316,7 +1449,7 @@ class VideoDownload {
|
|||||||
Logger.i(TAG, "VideoDownload Complete [${name}]");
|
Logger.i(TAG, "VideoDownload Complete [${name}]");
|
||||||
val existing = StateDownloads.instance.getCachedVideo(id);
|
val existing = StateDownloads.instance.getCachedVideo(id);
|
||||||
val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSourceToUse!!, it, videoFileSize ?: 0, videoOverrideContainer) };
|
val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSourceToUse!!, it, videoFileSize ?: 0, videoOverrideContainer) };
|
||||||
val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(audioSourceToUse!!, it, audioFileSize ?: 0, audioOverrideContainer) };
|
val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(overrideResultAudioSource ?: audioSourceToUse!!, it, audioFileSize ?: 0, audioOverrideContainer) };
|
||||||
val localSubtitleSource = subtitleFilePath?.let { LocalSubtitleSource.fromSource(subtitleSource!!, it) };
|
val localSubtitleSource = subtitleFilePath?.let { LocalSubtitleSource.fromSource(subtitleSource!!, it) };
|
||||||
|
|
||||||
if(localVideoSource != null && videoSourceToUse != null && videoSourceToUse is IStreamMetaDataSource)
|
if(localVideoSource != null && videoSourceToUse != null && videoSourceToUse is IStreamMetaDataSource)
|
||||||
@@ -1358,6 +1491,10 @@ class VideoDownload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun cleanup(){
|
||||||
|
cleanupPluginClient()
|
||||||
|
}
|
||||||
|
|
||||||
enum class State {
|
enum class State {
|
||||||
QUEUED,
|
QUEUED,
|
||||||
PREPARING,
|
PREPARING,
|
||||||
@@ -1381,6 +1518,8 @@ class VideoDownload {
|
|||||||
const val GROUP_WATCHLATER= "WatchLater";
|
const val GROUP_WATCHLATER= "WatchLater";
|
||||||
|
|
||||||
val REGEX_DASH_TEMPLATE = Regex("<SegmentTemplate .*?media=\"(.*?)\".*?>(.*?)<\\/SegmentTemplate>", RegexOption.DOT_MATCHES_ALL);
|
val REGEX_DASH_TEMPLATE = Regex("<SegmentTemplate .*?media=\"(.*?)\".*?>(.*?)<\\/SegmentTemplate>", RegexOption.DOT_MATCHES_ALL);
|
||||||
|
val REGEX_DASH_TEMPLATE_WITH_MIME = Regex("<Representation.*?mimeType=\\\"(.*?)\\\".*?>.*?<SegmentTemplate .*?media=\\\"(.*?)\\\".*?>(.*?)<\\/SegmentTemplate>", RegexOption.DOT_MATCHES_ALL);
|
||||||
|
val REGEX_CODECS = Regex("codecs=\\\"(.*?)\\\"")
|
||||||
val REGEX_DASH_CUE = Regex("<S .*?t=\"([0-9]*?)\".*?d=\"([0-9]*?)\".*?\\/>", RegexOption.DOT_MATCHES_ALL);
|
val REGEX_DASH_CUE = Regex("<S .*?t=\"([0-9]*?)\".*?d=\"([0-9]*?)\".*?\\/>", RegexOption.DOT_MATCHES_ALL);
|
||||||
|
|
||||||
fun videoContainerToExtension(container: String): String? {
|
fun videoContainerToExtension(container: String): String? {
|
||||||
@@ -1400,6 +1539,16 @@ class VideoDownload {
|
|||||||
return "video";//throw IllegalStateException("Unknown container: " + container)
|
return "video";//throw IllegalStateException("Unknown container: " + container)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//TODO: Change usages of this to an accurate container instead of infering it.
|
||||||
|
fun videoAudioContainerToExtension(container: String): String? {
|
||||||
|
if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl")
|
||||||
|
return "mp4a";
|
||||||
|
else if (container.contains("video/webm"))
|
||||||
|
return "webm";
|
||||||
|
else
|
||||||
|
return "mp4a";//throw IllegalStateException("Unknown container: " + container)
|
||||||
|
}
|
||||||
|
|
||||||
fun audioContainerToExtension(container: String): String {
|
fun audioContainerToExtension(container: String): String {
|
||||||
if (container.contains("audio/mp4"))
|
if (container.contains("audio/mp4"))
|
||||||
return "mp4a";
|
return "mp4a";
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import com.caoccao.javet.values.primitive.V8ValueString
|
|||||||
import com.caoccao.javet.values.reference.IV8ValuePromise
|
import com.caoccao.javet.values.reference.IV8ValuePromise
|
||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
import com.caoccao.javet.values.reference.V8ValuePromise
|
import com.caoccao.javet.values.reference.V8ValuePromise
|
||||||
|
import com.futo.platformplayer.BuildConfig
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
|
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.engine.exceptions.NoInternetException
|
import com.futo.platformplayer.engine.exceptions.NoInternetException
|
||||||
@@ -34,6 +36,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptTimeoutException
|
|||||||
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
|
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
|
||||||
import com.futo.platformplayer.engine.internal.V8Converter
|
import com.futo.platformplayer.engine.internal.V8Converter
|
||||||
import com.futo.platformplayer.engine.packages.PackageBridge
|
import com.futo.platformplayer.engine.packages.PackageBridge
|
||||||
|
import com.futo.platformplayer.engine.packages.PackageBrowser
|
||||||
import com.futo.platformplayer.engine.packages.PackageDOMParser
|
import com.futo.platformplayer.engine.packages.PackageDOMParser
|
||||||
import com.futo.platformplayer.engine.packages.PackageHttp
|
import com.futo.platformplayer.engine.packages.PackageHttp
|
||||||
import com.futo.platformplayer.engine.packages.PackageHttpImp
|
import com.futo.platformplayer.engine.packages.PackageHttpImp
|
||||||
@@ -44,6 +47,7 @@ import com.futo.platformplayer.getOrDefault
|
|||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StateAssets
|
import com.futo.platformplayer.states.StateAssets
|
||||||
|
import com.futo.platformplayer.states.StateDeveloper
|
||||||
import com.futo.platformplayer.toList
|
import com.futo.platformplayer.toList
|
||||||
import com.futo.platformplayer.toV8ValueBlocking
|
import com.futo.platformplayer.toV8ValueBlocking
|
||||||
import com.futo.platformplayer.toV8ValueAsync
|
import com.futo.platformplayer.toV8ValueAsync
|
||||||
@@ -218,6 +222,9 @@ class V8Plugin {
|
|||||||
if(pack is PackageHttp) {
|
if(pack is PackageHttp) {
|
||||||
pack.cleanup();
|
pack.cleanup();
|
||||||
}
|
}
|
||||||
|
else if(pack is PackageBrowser) {
|
||||||
|
pack.deinitialize();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_runtime?.let {
|
_runtime?.let {
|
||||||
@@ -387,6 +394,18 @@ class V8Plugin {
|
|||||||
"HttpImp" -> PackageHttpImp(this, config)
|
"HttpImp" -> PackageHttpImp(this, config)
|
||||||
"Utilities" -> PackageUtilities(this, config)
|
"Utilities" -> PackageUtilities(this, config)
|
||||||
"JSDOM" -> PackageJSDOM(this, config)
|
"JSDOM" -> PackageJSDOM(this, config)
|
||||||
|
"Browser" -> {
|
||||||
|
val isOfficial = (config is SourcePluginConfig && config.isOfficialAuthor());
|
||||||
|
|
||||||
|
if(BuildConfig.DEBUG)
|
||||||
|
PackageBrowser(this)
|
||||||
|
else if(isOfficial)
|
||||||
|
PackageBrowser(this)
|
||||||
|
else if(config is SourcePluginConfig && config.id == StateDeveloper.DEV_ID)
|
||||||
|
PackageBrowser(this)
|
||||||
|
else
|
||||||
|
throw IllegalArgumentException("Browser is only allowed for debug and official plugins due to security");
|
||||||
|
};
|
||||||
else -> if(allowNull) null else throw ScriptCompilationException(config, "Unknown package [${packageName}] required for plugin ${config.name}");
|
else -> if(allowNull) null else throw ScriptCompilationException(config, "Unknown package [${packageName}] required for plugin ${config.name}");
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,7 @@ object Libcurl {
|
|||||||
var body: ByteArray? = null,
|
var body: ByteArray? = null,
|
||||||
var impersonateTarget: String = "chrome136",
|
var impersonateTarget: String = "chrome136",
|
||||||
var useBuiltInHeaders: Boolean = true,
|
var useBuiltInHeaders: Boolean = true,
|
||||||
var timeoutMs: Int = 30_000,
|
var timeoutMs: Int = 30_000
|
||||||
var cookieJarPath: String? = null,
|
|
||||||
var sendCookies: Boolean = true,
|
|
||||||
var persistCookies: Boolean = true,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Keep
|
@Keep
|
||||||
@@ -121,12 +118,6 @@ object Libcurl {
|
|||||||
if (slist != 0L) checkOK(ce_setopt_ptr(easy, CURLOPT.HTTPHEADER, slist))
|
if (slist != 0L) checkOK(ce_setopt_ptr(easy, CURLOPT.HTTPHEADER, slist))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.sendCookies || req.persistCookies) {
|
|
||||||
val jar = (req.cookieJarPath ?: defaultCookieJarPath())
|
|
||||||
if (req.sendCookies) checkOK(ce_setopt_str(easy, CURLOPT.COOKIEFILE, jar))
|
|
||||||
if (req.persistCookies) checkOK(ce_setopt_str(easy, CURLOPT.COOKIEJAR, jar))
|
|
||||||
}
|
|
||||||
|
|
||||||
val method = req.method
|
val method = req.method
|
||||||
if (!method.equals("GET", ignoreCase = true)) {
|
if (!method.equals("GET", ignoreCase = true)) {
|
||||||
checkOK(ce_setopt_str(easy, CURLOPT.CUSTOMREQUEST, method))
|
checkOK(ce_setopt_str(easy, CURLOPT.CUSTOMREQUEST, method))
|
||||||
|
|||||||
@@ -105,6 +105,11 @@ class PackageBridge : V8Package {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@V8Function
|
||||||
|
fun hasPackage(str: String): Boolean {
|
||||||
|
return _plugin.getPackages().any { it.name == str };
|
||||||
|
}
|
||||||
|
|
||||||
@V8Function
|
@V8Function
|
||||||
fun dispose(value: V8Value) {
|
fun dispose(value: V8Value) {
|
||||||
Logger.e(TAG, "Manual dispose: " + value.javaClass.name);
|
Logger.e(TAG, "Manual dispose: " + value.javaClass.name);
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
package com.futo.platformplayer.engine.packages
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Looper
|
||||||
|
import android.webkit.ConsoleMessage
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.ValueCallback
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.collection.emptyLongSet
|
||||||
|
import androidx.webkit.ScriptHandler
|
||||||
|
import com.caoccao.javet.annotations.V8Function
|
||||||
|
import com.caoccao.javet.utils.JavetResourceUtils
|
||||||
|
import com.caoccao.javet.values.reference.V8ValueFunction
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
|
import com.futo.platformplayer.engine.V8Plugin
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StateDeveloper
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import androidx.webkit.WebViewCompat
|
||||||
|
import androidx.webkit.WebViewFeature
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
class PackageBrowser: V8Package {
|
||||||
|
override val name: String get() = "Browser";
|
||||||
|
override val variableName: String = "browser";
|
||||||
|
|
||||||
|
private val _json = Json { };
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private val _pageLoadScriptRefs = ConcurrentHashMap<String, ScriptHandler>()
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private val _pageLoadScriptsFallback = ConcurrentHashMap<String, String>()
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private var _readySemaphore: Semaphore? = null;
|
||||||
|
@Transient
|
||||||
|
private val _callbacks = mutableMapOf<String, (String?)->Unit>();
|
||||||
|
@Transient
|
||||||
|
private val _interop = JSInterop(this);
|
||||||
|
@Transient
|
||||||
|
private var _browser: WebView? = null;
|
||||||
|
private val browser: WebView get() {
|
||||||
|
if(_browser == null)
|
||||||
|
throw IllegalStateException("Browser not initialized");
|
||||||
|
return _browser!!;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(v8Plugin: V8Plugin): super(v8Plugin) {
|
||||||
|
|
||||||
|
}
|
||||||
|
@V8Function
|
||||||
|
fun initialize() {
|
||||||
|
if(_browser == null){
|
||||||
|
StateApp.instance.scope.launch(Dispatchers.Main) {
|
||||||
|
_browser = WebView(StateApp.instance.contextOrNull ?: return@launch);
|
||||||
|
_browser?.settings?.javaScriptEnabled = true;
|
||||||
|
_browser?.settings?.blockNetworkImage = false;
|
||||||
|
_browser?.settings?.blockNetworkLoads = false;
|
||||||
|
_browser?.settings?.allowContentAccess = false;
|
||||||
|
_browser?.settings?.allowFileAccess = false;
|
||||||
|
//_browser?.settings?.useWideViewPort = true;
|
||||||
|
//_browser?.settings?.loadWithOverviewMode = true;
|
||||||
|
_browser?.webViewClient = object : WebViewClient() {
|
||||||
|
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
|
super.onPageStarted(view, url, favicon)
|
||||||
|
|
||||||
|
if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
|
||||||
|
// Best-effort fallback. Not equivalent, but as early as WebView exposes.
|
||||||
|
val scripts = _pageLoadScriptsFallback.values.toList()
|
||||||
|
for (s in scripts) {
|
||||||
|
try { view?.evaluateJavascript(s, null) } catch (_: Throwable) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageCommitVisible(view: WebView?, url: String?) {
|
||||||
|
super.onPageCommitVisible(view, url)
|
||||||
|
_readySemaphore?.release();
|
||||||
|
_readySemaphore = null;
|
||||||
|
Logger.i("PackageBrowser", "Browser loaded");
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_browser?.webChromeClient = object : WebChromeClient() {
|
||||||
|
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
|
||||||
|
if(consoleMessage?.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
|
||||||
|
val msg = "Browser Error:${consoleMessage?.message()} [${consoleMessage?.lineNumber()}]" ?: ""
|
||||||
|
Logger.e("PackageBrowser", msg);
|
||||||
|
if(_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
|
||||||
|
StateDeveloper.instance.logDevException(StateDeveloper.instance.currentDevID ?: "", msg)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
val msg = "Browser Log:" + consoleMessage?.message() ?: "";
|
||||||
|
Logger.e("PackageBrowser", msg);
|
||||||
|
if(_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
|
||||||
|
StateDeveloper.instance.logDevInfo(StateDeveloper.instance.currentDevID ?: "", msg);
|
||||||
|
}
|
||||||
|
return super.onConsoleMessage(consoleMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_browser?.addJavascriptInterface(_interop, "__GJ");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@V8Function
|
||||||
|
fun deinitialize() {
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
|
_browser?.destroy();
|
||||||
|
}
|
||||||
|
_browser = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@V8Function
|
||||||
|
fun getCurrentUrl(): String? {
|
||||||
|
return browser.url;
|
||||||
|
}
|
||||||
|
@V8Function
|
||||||
|
fun waitTillLoaded(timeout: Int = 1000): Boolean {
|
||||||
|
val acquired = _readySemaphore?.let {
|
||||||
|
if(!it.tryAcquire()) {
|
||||||
|
Logger.i("PackageBrowser", "Waiting for browser to be ready");
|
||||||
|
if(!runBlocking {
|
||||||
|
try {
|
||||||
|
return@runBlocking withTimeout(timeout.toLong(), {
|
||||||
|
it.acquire()
|
||||||
|
return@withTimeout true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch(ex: TimeoutCancellationException) {
|
||||||
|
return@runBlocking false;
|
||||||
|
}
|
||||||
|
}) return@let false;
|
||||||
|
}
|
||||||
|
it.release();
|
||||||
|
return@let true;
|
||||||
|
} ?: true;
|
||||||
|
if(acquired)
|
||||||
|
Logger.i("PackageBrowser", "Browser is ready");
|
||||||
|
else
|
||||||
|
Logger.i("PackageBrowser", "Browser failed wait ready");
|
||||||
|
return acquired;
|
||||||
|
}
|
||||||
|
|
||||||
|
@V8Function
|
||||||
|
fun load(url: String) {
|
||||||
|
Logger.i("PackageBrowser", "Browser loading url [${url}]");
|
||||||
|
_readySemaphore = Semaphore(1, 1);
|
||||||
|
StateApp.instance.scope.launch(Dispatchers.Main) {
|
||||||
|
try {
|
||||||
|
browser.loadUrl(url);
|
||||||
|
} catch(ex: Throwable) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@V8Function
|
||||||
|
fun run(js: String, callbackId: String? = null, callback: V8ValueFunction? = null) {
|
||||||
|
waitTillLoaded();
|
||||||
|
val funcClone = callback?.toClone<V8ValueFunction>()
|
||||||
|
if(callbackId != null && callback != null) {
|
||||||
|
synchronized(_callbacks) {
|
||||||
|
_callbacks.put(callbackId, {
|
||||||
|
_plugin.busy {
|
||||||
|
funcClone?.callVoid(null, arrayOf(it));
|
||||||
|
}
|
||||||
|
if (!_plugin.isStopped)
|
||||||
|
JavetResourceUtils.safeClose(funcClone);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StateApp.instance.scope.launch(Dispatchers.Main) {
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
Logger.i("PackageBrowser", "Browser running JS with callback [${callbackId}]\n${(if(js.length > 200) (js.substring(0, 200) + "...") else js)})");
|
||||||
|
browser.evaluateJavascript(js, object : ValueCallback<String> {
|
||||||
|
override fun onReceiveValue(value: String?) {
|
||||||
|
Logger.i("PackageBrowser", "Browser run finished");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e("PackageBrowser", "Browser running failed: " + ex.message, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e("PackageBrowser", "Failed to invoke browser", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@V8Function
|
||||||
|
fun runWithReturn(js: String, callback: V8ValueFunction? = null) {
|
||||||
|
waitTillLoaded();
|
||||||
|
val funcClone = callback?.toClone<V8ValueFunction>()
|
||||||
|
StateApp.instance.scope.launch(Dispatchers.Main) {
|
||||||
|
try {
|
||||||
|
Logger.i("PackageBrowser", "Browser running JS with callback [sync]\n${(if(js.length > 200) (js.substring(0, 200) + "...") else js)})");
|
||||||
|
browser.evaluateJavascript(js, object : ValueCallback<String> {
|
||||||
|
override fun onReceiveValue(value: String?) {
|
||||||
|
Logger.i("PackageBrowser", "Browser run returned: " + (value ?: ""));
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
|
Logger.i("PackageBrowser", "Invoking V8 with result (${funcClone != null})");
|
||||||
|
try {
|
||||||
|
_plugin.busy {
|
||||||
|
if (value != null) {
|
||||||
|
val json = _json.decodeFromString<String>(value);
|
||||||
|
funcClone?.callVoid(null, arrayOf(json));
|
||||||
|
} else
|
||||||
|
funcClone?.callVoid(null, arrayOf((null as String?)));
|
||||||
|
}
|
||||||
|
if (!_plugin.isStopped)
|
||||||
|
JavetResourceUtils.safeClose(funcClone);
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e("PackageBrowser", "Browser Failed to callback: " + ex.message, ex);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e("PackageBrowser", "Browser Failed to invoke browser", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@V8Function
|
||||||
|
fun addScriptOnLoad(js: String): String {
|
||||||
|
require(js.isNotBlank()) { "Script must be non-empty." }
|
||||||
|
|
||||||
|
val id = UUID.randomUUID().toString()
|
||||||
|
|
||||||
|
onMainBlocking {
|
||||||
|
if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
|
||||||
|
val ref = WebViewCompat.addDocumentStartJavaScript(browser, js, setOf("*"))
|
||||||
|
_pageLoadScriptRefs[id] = ref
|
||||||
|
} else {
|
||||||
|
_pageLoadScriptsFallback[id] = js
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.i("PackageBrowser", "addScriptOnLoad() registered (id=$id)")
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("RequiresFeature")
|
||||||
|
@V8Function
|
||||||
|
fun removeScriptOnLoad(identifier: String): Boolean {
|
||||||
|
if (identifier.isBlank()) return false
|
||||||
|
|
||||||
|
val ref = _pageLoadScriptRefs.remove(identifier)
|
||||||
|
val removedFallback = _pageLoadScriptsFallback.remove(identifier) != null
|
||||||
|
|
||||||
|
if (ref != null) {
|
||||||
|
onMainBlocking {
|
||||||
|
try { ref.remove() } catch (_: Throwable) {}
|
||||||
|
}
|
||||||
|
Logger.i("PackageBrowser", "removeScriptOnLoad() removed (id=$identifier)")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedFallback) {
|
||||||
|
Logger.i("PackageBrowser", "removeScriptOnLoad() removed fallback (id=$identifier)")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("RequiresFeature")
|
||||||
|
@V8Function
|
||||||
|
fun clearScriptsOnLoad() {
|
||||||
|
val refs = _pageLoadScriptRefs.values.toList()
|
||||||
|
_pageLoadScriptRefs.clear()
|
||||||
|
_pageLoadScriptsFallback.clear()
|
||||||
|
|
||||||
|
onMainBlocking {
|
||||||
|
for (r in refs) {
|
||||||
|
try { r.remove() } catch (_: Throwable) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.i("PackageBrowser", "clearScriptsOnLoad() cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> onMainBlocking(block: () -> T): T {
|
||||||
|
return if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||||
|
block()
|
||||||
|
} else runBlocking {
|
||||||
|
withContext(Dispatchers.Main) { block() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class JSInterop(private val pack: PackageBrowser) {
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun callback(id: String, result: String) {
|
||||||
|
Logger.i("PackageBrowser", "Browser Callback [${id}]: ${result}");
|
||||||
|
val callback = synchronized(pack._callbacks) { pack._callbacks.remove(id); };
|
||||||
|
if(callback != null) {
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
|
callback.invoke(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun log(msg: String) {
|
||||||
|
Logger.i("PackageBrowser", "Log: " + msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.futo.platformplayer.engine.packages
|
package com.futo.platformplayer.engine.packages
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.net.toUri
|
||||||
import com.caoccao.javet.annotations.V8Convert
|
import com.caoccao.javet.annotations.V8Convert
|
||||||
import com.caoccao.javet.annotations.V8Function
|
import com.caoccao.javet.annotations.V8Function
|
||||||
import com.caoccao.javet.annotations.V8Property
|
import com.caoccao.javet.annotations.V8Property
|
||||||
@@ -10,9 +12,13 @@ import com.caoccao.javet.values.V8Value
|
|||||||
import com.caoccao.javet.values.primitive.V8ValueString
|
import com.caoccao.javet.values.primitive.V8ValueString
|
||||||
import com.caoccao.javet.values.reference.V8ValueTypedArray
|
import com.caoccao.javet.values.reference.V8ValueTypedArray
|
||||||
import com.curlbind.Libcurl
|
import com.curlbind.Libcurl
|
||||||
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
|
||||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||||
import com.futo.platformplayer.engine.V8Plugin
|
import com.futo.platformplayer.engine.V8Plugin
|
||||||
|
import com.futo.platformplayer.engine.exceptions.ScriptException
|
||||||
|
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||||
import com.futo.platformplayer.engine.internal.IV8Convertable
|
import com.futo.platformplayer.engine.internal.IV8Convertable
|
||||||
import com.futo.platformplayer.engine.internal.V8BindObject
|
import com.futo.platformplayer.engine.internal.V8BindObject
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
@@ -43,8 +49,8 @@ class PackageHttpImp : V8Package {
|
|||||||
|
|
||||||
constructor(plugin: V8Plugin, config: IV8PluginConfig) : super(plugin) {
|
constructor(plugin: V8Plugin, config: IV8PluginConfig) : super(plugin) {
|
||||||
_config = config
|
_config = config
|
||||||
_packageClient = PackageHttpClient(this, withAuth = false)
|
_packageClient = PackageHttpClient(this, plugin.httpClient)
|
||||||
_packageClientAuth = PackageHttpClient(this, withAuth = true)
|
_packageClientAuth = PackageHttpClient(this, plugin.httpClientAuth)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cleanup() {
|
fun cleanup() {
|
||||||
@@ -95,7 +101,10 @@ class PackageHttpImp : V8Package {
|
|||||||
|
|
||||||
@V8Function
|
@V8Function
|
||||||
fun newClient(withAuth: Boolean): PackageHttpClient {
|
fun newClient(withAuth: Boolean): PackageHttpClient {
|
||||||
val client = PackageHttpClient(this, withAuth)
|
val httpClient = if(withAuth) _plugin.httpClientAuth.clone() else _plugin.httpClient.clone();
|
||||||
|
if(httpClient is JSHttpClient)
|
||||||
|
_plugin.registerHttpClient(httpClient);
|
||||||
|
val client = PackageHttpClient(this, httpClient)
|
||||||
client.clientId()?.let { _clients[it] = client }
|
client.clientId()?.let { _clients[it] = client }
|
||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
@@ -672,9 +681,6 @@ class PackageHttpImp : V8Package {
|
|||||||
@Transient
|
@Transient
|
||||||
private val _package: PackageHttpImp
|
private val _package: PackageHttpImp
|
||||||
|
|
||||||
@Transient
|
|
||||||
private val _withAuth: Boolean
|
|
||||||
|
|
||||||
val parentConfig: IV8PluginConfig
|
val parentConfig: IV8PluginConfig
|
||||||
get() = _package._config
|
get() = _package._config
|
||||||
|
|
||||||
@@ -686,44 +692,21 @@ class PackageHttpImp : V8Package {
|
|||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var timeoutMs: Int = 30_000
|
private var timeoutMs: Int = 30_000
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var sendCookies: Boolean = true
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var updateCookies: Boolean = true
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var allowNewCookies: Boolean = true
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var cookieJarPath: String? = null
|
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var impersonateTarget: String = "chrome136"
|
private var impersonateTarget: String = "chrome136"
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var useBuiltInHeaders: Boolean = true
|
private var useBuiltInHeaders: Boolean = true
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private val _client: ManagedHttpClient;
|
||||||
|
|
||||||
@V8Property
|
@V8Property
|
||||||
fun clientId(): String? = _clientId
|
fun clientId(): String? = _clientId
|
||||||
|
|
||||||
constructor(pack: PackageHttpImp, withAuth: Boolean) : super() {
|
constructor(pack: PackageHttpImp, baseClient: ManagedHttpClient) : super() {
|
||||||
_package = pack
|
_package = pack
|
||||||
_withAuth = withAuth
|
_client = baseClient
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureCookieJarPath(): String {
|
|
||||||
val existing = cookieJarPath
|
|
||||||
if (existing != null) return existing
|
|
||||||
|
|
||||||
val tmp = System.getProperty("java.io.tmpdir") ?: "/data/local/tmp"
|
|
||||||
val safeName = parentConfig.name.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
|
||||||
val fileName =
|
|
||||||
if (_withAuth) "imphttp.$safeName.auth.cookies.txt" else "imphttp.$safeName.cookies.txt"
|
|
||||||
val path = if (tmp.endsWith("/")) tmp + fileName else "$tmp/$fileName"
|
|
||||||
cookieJarPath = path
|
|
||||||
return path
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@V8Function
|
@V8Function
|
||||||
@@ -737,17 +720,18 @@ class PackageHttpImp : V8Package {
|
|||||||
|
|
||||||
@V8Function
|
@V8Function
|
||||||
fun setDoApplyCookies(apply: Boolean) {
|
fun setDoApplyCookies(apply: Boolean) {
|
||||||
sendCookies = apply
|
if(_client is JSHttpClient)
|
||||||
|
_client.doApplyCookies = apply;
|
||||||
}
|
}
|
||||||
|
|
||||||
@V8Function
|
@V8Function
|
||||||
fun setDoUpdateCookies(update: Boolean) {
|
fun setDoUpdateCookies(update: Boolean) {
|
||||||
updateCookies = update
|
if(_client is JSHttpClient)
|
||||||
|
_client.doUpdateCookies = update;
|
||||||
}
|
}
|
||||||
|
|
||||||
@V8Function
|
@V8Function
|
||||||
fun setDoAllowNewCookies(allow: Boolean) {
|
fun setDoAllowNewCookies(allow: Boolean) {
|
||||||
allowNewCookies = allow
|
if(_client is JSHttpClient)
|
||||||
|
_client.doAllowNewCookies = allow;
|
||||||
}
|
}
|
||||||
|
|
||||||
@V8Function
|
@V8Function
|
||||||
@@ -1060,18 +1044,29 @@ class PackageHttpImp : V8Package {
|
|||||||
private fun performCurl(
|
private fun performCurl(
|
||||||
method: String,
|
method: String,
|
||||||
url: String,
|
url: String,
|
||||||
headers: Map<String, String>,
|
hs: Map<String, String>, //TODO: Why is this not a Map<String, List<String>>
|
||||||
bodyBytes: ByteArray?,
|
bodyBytes: ByteArray?,
|
||||||
impersonateTargetOverride: String? = null,
|
impersonateTargetOverride: String? = null,
|
||||||
useBuiltInHeadersOverride: Boolean? = null,
|
useBuiltInHeadersOverride: Boolean? = null,
|
||||||
timeoutMsOverride: Int? = null
|
timeoutMsOverride: Int? = null
|
||||||
): Libcurl.Response {
|
): Libcurl.Response {
|
||||||
val jar = ensureCookieJarPath()
|
val client = _client
|
||||||
|
if (client is JSHttpClient) {
|
||||||
|
if (!(client.config?.isUrlAllowed(url) ?: false)) {
|
||||||
|
throw Exception( "Attempted to access non-whitelisted url: $url\nAdd it to your config");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val finalImpersonateTarget = impersonateTargetOverride ?: this.impersonateTarget
|
val finalImpersonateTarget = impersonateTargetOverride ?: this.impersonateTarget
|
||||||
val finalUseBuiltInHeaders = useBuiltInHeadersOverride ?: this.useBuiltInHeaders
|
val finalUseBuiltInHeaders = useBuiltInHeadersOverride ?: this.useBuiltInHeaders
|
||||||
val finalTimeoutMs = timeoutMsOverride ?: this.timeoutMs
|
val finalTimeoutMs = timeoutMsOverride ?: this.timeoutMs
|
||||||
|
|
||||||
|
val uri = url.toUri()
|
||||||
|
val headers = hs.toMutableMap()
|
||||||
|
if (client is JSHttpClient) {
|
||||||
|
client.applyHeaders(uri, headers, _client.isLoggedIn, true)
|
||||||
|
}
|
||||||
|
|
||||||
val req = Libcurl.Request(
|
val req = Libcurl.Request(
|
||||||
url = url,
|
url = url,
|
||||||
method = method,
|
method = method,
|
||||||
@@ -1079,12 +1074,13 @@ class PackageHttpImp : V8Package {
|
|||||||
body = bodyBytes,
|
body = bodyBytes,
|
||||||
impersonateTarget = finalImpersonateTarget,
|
impersonateTarget = finalImpersonateTarget,
|
||||||
useBuiltInHeaders = finalUseBuiltInHeaders,
|
useBuiltInHeaders = finalUseBuiltInHeaders,
|
||||||
timeoutMs = finalTimeoutMs,
|
timeoutMs = finalTimeoutMs
|
||||||
cookieJarPath = jar,
|
|
||||||
sendCookies = sendCookies,
|
|
||||||
persistCookies = updateCookies && allowNewCookies
|
|
||||||
)
|
)
|
||||||
return Libcurl.perform(req)
|
val resp = Libcurl.perform(req)
|
||||||
|
if (client is JSHttpClient) {
|
||||||
|
client.processRequest(method, resp.status, uri, resp.headers)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun executeRequest(
|
private fun executeRequest(
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import com.futo.platformplayer.activities.MainActivity
|
|||||||
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
|
||||||
|
|
||||||
open class MainActivityFragment : Fragment() {
|
open class MainActivityFragment : Fragment() {
|
||||||
protected val currentMain : MainFragment
|
protected val currentMain : MainFragment?
|
||||||
get() {
|
get() {
|
||||||
isValidMainActivity();
|
isValidMainActivity();
|
||||||
return (activity as MainActivity).fragCurrent;
|
return (activity as MainActivity).fragCurrent;
|
||||||
|
|||||||
+31
-9
@@ -102,6 +102,8 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
|
|
||||||
private var currentButtonDefinitions: List<ButtonDefinition>? = null;
|
private var currentButtonDefinitions: List<ButtonDefinition>? = null;
|
||||||
|
|
||||||
|
private var moreColumns = 3;
|
||||||
|
|
||||||
constructor(fragment: MenuBottomBarFragment, inflater: LayoutInflater) : super(inflater.context) {
|
constructor(fragment: MenuBottomBarFragment, inflater: LayoutInflater) : super(inflater.context) {
|
||||||
_fragment = fragment;
|
_fragment = fragment;
|
||||||
_inflater = inflater;
|
_inflater = inflater;
|
||||||
@@ -152,6 +154,17 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
else {
|
else {
|
||||||
StateApp.instance.setPrivacyMode(true);
|
StateApp.instance.setPrivacyMode(true);
|
||||||
UIDialogs.appToast("Privacy mode enabled");
|
UIDialogs.appToast("Privacy mode enabled");
|
||||||
|
|
||||||
|
if(Settings.instance.other.showPrivacyModeDialog)
|
||||||
|
UIDialogs.showDialog(it.context ?: return@setOnClickListener, R.drawable.incognito, "Privacy Mode",
|
||||||
|
"All requests will be processed anonymously (any logins will be disabled except for the personalized home page), local playback and history tracking will also be disabled.\n\nTap the icon to disable.", null, 0,
|
||||||
|
UIDialogs.Action("Don't show again", {
|
||||||
|
Settings.instance.other.showPrivacyModeDialog = false;
|
||||||
|
Settings.instance.save();
|
||||||
|
}, UIDialogs.ActionStyle.NONE),
|
||||||
|
UIDialogs.Action("Understood", {
|
||||||
|
|
||||||
|
}, UIDialogs.ActionStyle.PRIMARY));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +183,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
setMoreVisible(false);
|
setMoreVisible(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
moreColumns = columns;
|
||||||
val layoutManager = GridLayoutManager(context, columns, GridLayoutManager.VERTICAL, true);
|
val layoutManager = GridLayoutManager(context, columns, GridLayoutManager.VERTICAL, true);
|
||||||
_layoutMoreButtons.layoutManager = layoutManager;
|
_layoutMoreButtons.layoutManager = layoutManager;
|
||||||
|
|
||||||
@@ -321,29 +335,37 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
_layoutMoreButtons.removeAllViews();
|
_layoutMoreButtons.removeAllViews();
|
||||||
|
|
||||||
var insertedButtons = 0;
|
var insertedButtons = 0;
|
||||||
|
//Force settings to be first
|
||||||
|
val settingsIndex = buttons.indexOfFirst { b -> b.id == 7 };
|
||||||
|
if (settingsIndex != -1) {
|
||||||
|
val button = buttons[settingsIndex]
|
||||||
|
buttons.removeAt(settingsIndex)
|
||||||
|
buttons.add(0, button)
|
||||||
|
//insertedButtons++;
|
||||||
|
}
|
||||||
//Force buy to be on top for more buttons
|
//Force buy to be on top for more buttons
|
||||||
val buyIndex = buttons.indexOfFirst { b -> b.id == 98 };
|
val buyIndex = buttons.indexOfFirst { b -> b.id == 98 };
|
||||||
if (buyIndex != -1) {
|
if (buyIndex != -1) {
|
||||||
val button = buttons[buyIndex]
|
val button = buttons[buyIndex]
|
||||||
buttons.removeAt(buyIndex)
|
buttons.removeAt(buyIndex)
|
||||||
buttons.add(0, button)
|
buttons.add(button)
|
||||||
insertedButtons++;
|
//insertedButtons++;
|
||||||
}
|
}
|
||||||
//Force faq to be second
|
//Force faq to be second
|
||||||
val faqIndex = buttons.indexOfFirst { b -> b.id == 97 };
|
val faqIndex = buttons.indexOfFirst { b -> b.id == 97 };
|
||||||
if (faqIndex != -1) {
|
if (faqIndex != -1) {
|
||||||
val button = buttons[faqIndex]
|
val button = buttons[faqIndex]
|
||||||
buttons.removeAt(faqIndex)
|
buttons.removeAt(faqIndex)
|
||||||
buttons.add(if (insertedButtons == 1) 1 else 0, button)
|
buttons.add(button)
|
||||||
insertedButtons++;
|
//insertedButtons++;
|
||||||
}
|
}
|
||||||
//Force privacy to be third
|
//Force privacy to be third
|
||||||
val privacyIndex = buttons.indexOfFirst { b -> b.id == 96 };
|
val privacyIndex = buttons.indexOfFirst { b -> b.id == 96 };
|
||||||
if (privacyIndex != -1) {
|
if (privacyIndex != -1) {
|
||||||
val button = buttons[privacyIndex]
|
val button = buttons[privacyIndex]
|
||||||
buttons.removeAt(privacyIndex)
|
buttons.removeAt(privacyIndex)
|
||||||
buttons.add(if (insertedButtons == 2) 2 else (if(insertedButtons == 1) 1 else 0), button)
|
buttons.add(button)
|
||||||
insertedButtons++;
|
//insertedButtons++;
|
||||||
}
|
}
|
||||||
|
|
||||||
val newButtons = mutableListOf<MenuButtonItem>();
|
val newButtons = mutableListOf<MenuButtonItem>();
|
||||||
@@ -591,7 +613,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>(withHistory = false) }),
|
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>(withHistory = false) }),
|
||||||
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>(withHistory = false) }),
|
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>(withHistory = false) }),
|
||||||
ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>(withHistory = false) }),
|
ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>(withHistory = false) }),
|
||||||
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, {
|
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { it.currentMain is SettingsFragment }, {
|
||||||
it.navigate<SettingsFragment>();
|
it.navigate<SettingsFragment>();
|
||||||
/*
|
/*
|
||||||
val c = it.context ?: return@ButtonDefinition;
|
val c = it.context ?: return@ButtonDefinition;
|
||||||
@@ -602,7 +624,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
if (c is Activity) {
|
if (c is Activity) {
|
||||||
c.overridePendingTransition(R.anim.slide_in_up, R.anim.slide_darken);
|
c.overridePendingTransition(R.anim.slide_in_up, R.anim.slide_darken);
|
||||||
}*/
|
}*/
|
||||||
}),
|
}),/*
|
||||||
ButtonDefinition(96, R.drawable.ic_disabled_visible, R.drawable.ic_disabled_visible, R.string.privacy_mode, canToggle = true, { false }, {
|
ButtonDefinition(96, R.drawable.ic_disabled_visible, R.drawable.ic_disabled_visible, R.string.privacy_mode, canToggle = true, { false }, {
|
||||||
UIDialogs.showDialog(it.context ?: return@ButtonDefinition, R.drawable.ic_disabled_visible_purple, "Privacy Mode",
|
UIDialogs.showDialog(it.context ?: return@ButtonDefinition, R.drawable.ic_disabled_visible_purple, "Privacy Mode",
|
||||||
"All requests will be processed anonymously (any logins will be disabled except for the personalized home page), local playback and history tracking will also be disabled.\n\nTap the icon to disable.", null, 0,
|
"All requests will be processed anonymously (any logins will be disabled except for the personalized home page), local playback and history tracking will also be disabled.\n\nTap the icon to disable.", null, 0,
|
||||||
@@ -612,7 +634,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
UIDialogs.Action("Enable", {
|
UIDialogs.Action("Enable", {
|
||||||
StateApp.instance.setPrivacyMode(true);
|
StateApp.instance.setPrivacyMode(true);
|
||||||
}, UIDialogs.ActionStyle.PRIMARY));
|
}, UIDialogs.ActionStyle.PRIMARY));
|
||||||
}),
|
}),*/
|
||||||
ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = true, { false }, {
|
ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = true, { false }, {
|
||||||
it.navigate<BrowserFragment>(Settings.URL_FAQ, withHistory = false);
|
it.navigate<BrowserFragment>(Settings.URL_FAQ, withHistory = false);
|
||||||
})
|
})
|
||||||
|
|||||||
+9
-1
@@ -10,6 +10,7 @@ import android.webkit.WebView
|
|||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
import android.widget.AdapterView
|
import android.widget.AdapterView
|
||||||
import android.widget.ArrayAdapter
|
import android.widget.ArrayAdapter
|
||||||
|
import android.widget.LinearLayout
|
||||||
import android.widget.Spinner
|
import android.widget.Spinner
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
@@ -21,6 +22,7 @@ class BrowserFragment : MainFragment() {
|
|||||||
override val isTab: Boolean = false;
|
override val isTab: Boolean = false;
|
||||||
override val hasBottomBar: Boolean get() = true;
|
override val hasBottomBar: Boolean get() = true;
|
||||||
|
|
||||||
|
private var _root: LinearLayout? = null;
|
||||||
private var _webview: WebView? = null;
|
private var _webview: WebView? = null;
|
||||||
private val _webviewWithoutHandling = object: WebViewClient() {
|
private val _webviewWithoutHandling = object: WebViewClient() {
|
||||||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||||
@@ -31,6 +33,7 @@ class BrowserFragment : MainFragment() {
|
|||||||
|
|
||||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
val view = inflater.inflate(R.layout.fragment_browser, container, false);
|
val view = inflater.inflate(R.layout.fragment_browser, container, false);
|
||||||
|
_root = view.findViewById<LinearLayout>(R.id.root);
|
||||||
_webview = view.findViewById<WebView?>(R.id.webview).apply {
|
_webview = view.findViewById<WebView?>(R.id.webview).apply {
|
||||||
this.webViewClient = _webviewWithoutHandling;
|
this.webViewClient = _webviewWithoutHandling;
|
||||||
this.settings.javaScriptEnabled = true;
|
this.settings.javaScriptEnabled = true;
|
||||||
@@ -43,7 +46,12 @@ class BrowserFragment : MainFragment() {
|
|||||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||||
super.onShownWithView(parameter, isBack)
|
super.onShownWithView(parameter, isBack)
|
||||||
|
|
||||||
if(parameter is String) {
|
if(parameter is WebView) {
|
||||||
|
_root?.removeView(_webview);
|
||||||
|
_root?.addView(parameter);
|
||||||
|
_webview = parameter;
|
||||||
|
}
|
||||||
|
else if(parameter is String) {
|
||||||
_webview?.webViewClient = _webviewWithoutHandling;
|
_webview?.webViewClient = _webviewWithoutHandling;
|
||||||
_webview?.loadUrl(parameter);
|
_webview?.loadUrl(parameter);
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-14
@@ -1,8 +1,6 @@
|
|||||||
package com.futo.platformplayer.fragment.mainactivity.main
|
package com.futo.platformplayer.fragment.mainactivity.main
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
@@ -16,6 +14,8 @@ import com.futo.futopay.formatMoney
|
|||||||
import com.futo.platformplayer.BuildConfig
|
import com.futo.platformplayer.BuildConfig
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StatePayment
|
import com.futo.platformplayer.states.StatePayment
|
||||||
import com.futo.platformplayer.views.overlays.LoaderOverlay
|
import com.futo.platformplayer.views.overlays.LoaderOverlay
|
||||||
@@ -68,8 +68,11 @@ class BuyFragment : MainFragment() {
|
|||||||
|
|
||||||
_paymentManager = PaymentManager(StatePayment.instance, fragment, _overlayPaying) { success, _, exception ->
|
_paymentManager = PaymentManager(StatePayment.instance, fragment, _overlayPaying) { success, _, exception ->
|
||||||
if(success) {
|
if(success) {
|
||||||
UIDialogs.showDialog(context, R.drawable.ic_check, context.getString(R.string.payment_succeeded), context.getString(R.string.thanks_for_your_purchase_a_key_will_be_sent_to_your_email_after_your_payment_has_been_received), null, 0, UIDialogs.Action("Ok", {}, UIDialogs.ActionStyle.PRIMARY));
|
UIDialogs.showDialog(context, R.drawable.ic_check, context.getString(R.string.payment_succeeded), context.getString(R.string.thanks_for_your_purchase_a_key_will_be_sent_to_your_email_after_your_payment_has_been_received), null, 0,
|
||||||
_fragment.close(true);
|
UIDialogs.Action("Ok", {
|
||||||
|
(fragment.activity as? MainActivity)?.navigate<SettingsFragment>(withHistory = false);
|
||||||
|
}, UIDialogs.ActionStyle.PRIMARY)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.payment_failed), exception);
|
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.payment_failed), exception);
|
||||||
@@ -89,16 +92,19 @@ class BuyFragment : MainFragment() {
|
|||||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
//Calling this function will cache first call
|
//Calling this function will cache first call
|
||||||
try {
|
try {
|
||||||
val currencies = StatePayment.instance.getAvailableCurrencies("grayjay");
|
// TODO: Restore multi-currency support when payment backend supports it
|
||||||
val prices = StatePayment.instance.getAvailableCurrencyPrices("grayjay");
|
// val currencies = StatePayment.instance.getAvailableCurrencies("grayjay");
|
||||||
val country = StatePayment.instance.getPaymentCountryFromIP(true)?.let { c -> PaymentConfigurations.COUNTRIES.find { it.id.equals(c, ignoreCase = true) } };
|
// val prices = StatePayment.instance.getAvailableCurrencyPrices("grayjay");
|
||||||
val currency = country?.let { c -> PaymentConfigurations.CURRENCIES.find { it.id == c.defaultCurrencyId && (currencies.contains(it.id)) } };
|
// val country = StatePayment.instance.getPaymentCountryFromIP(true)?.let { c -> PaymentConfigurations.COUNTRIES.find { it.id.equals(c, ignoreCase = true) } };
|
||||||
|
// val currency = country?.let { c -> PaymentConfigurations.CURRENCIES.find { it.id == c.defaultCurrencyId && (currencies.contains(it.id)) } };
|
||||||
|
// if(currency != null && prices.containsKey(currency.id)) {
|
||||||
|
// val price = prices[currency.id]!!;
|
||||||
|
// _buttonBuyText.text = formatMoney(country.id, currency.id, price) + context.getString(R.string.plus_tax);
|
||||||
|
// }
|
||||||
|
|
||||||
if(currency != null && prices.containsKey(currency.id)) {
|
val priceCents = StatePayment.instance.getPolarProductPrice(PaymentConfigurations.PolarConfig.PRODUCT_SLUG)
|
||||||
val price = prices[currency.id]!!;
|
withContext(Dispatchers.Main) {
|
||||||
withContext(Dispatchers.Main) {
|
_buttonBuyText.text = formatMoney("US", "usd", priceCents) + context.getString(R.string.plus_tax)
|
||||||
_buttonBuyText.text = formatMoney(country.id, currency.id, price) + context.getString(R.string.plus_tax);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
catch(ex: Throwable) {
|
||||||
@@ -165,4 +171,4 @@ class BuyFragment : MainFragment() {
|
|||||||
fun newInstance() = BuyFragment().apply {}
|
fun newInstance() = BuyFragment().apply {}
|
||||||
private val TAG = "BuyFragment"
|
private val TAG = "BuyFragment"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-4
@@ -20,6 +20,7 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import androidx.viewpager2.widget.ViewPager2
|
import androidx.viewpager2.widget.ViewPager2
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.UISlideOverlays
|
import com.futo.platformplayer.UISlideOverlays
|
||||||
import com.futo.platformplayer.api.media.PlatformID
|
import com.futo.platformplayer.api.media.PlatformID
|
||||||
@@ -55,6 +56,7 @@ import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter
|
|||||||
import com.futo.platformplayer.views.others.CreatorThumbnail
|
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||||
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
||||||
|
import com.futo.platformplayer.withTimestamp
|
||||||
import com.futo.polycentric.core.ApiMethods
|
import com.futo.polycentric.core.ApiMethods
|
||||||
import com.futo.polycentric.core.PolycentricProfile
|
import com.futo.polycentric.core.PolycentricProfile
|
||||||
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
||||||
@@ -198,8 +200,12 @@ class ChannelFragment : MainFragment() {
|
|||||||
adapter.onContentClicked.subscribe { v, _ ->
|
adapter.onContentClicked.subscribe { v, _ ->
|
||||||
when (v) {
|
when (v) {
|
||||||
is IPlatformVideo -> {
|
is IPlatformVideo -> {
|
||||||
StatePlayer.instance.clearQueue()
|
//StatePlayer.instance.clearQueue()
|
||||||
fragment.navigate<VideoDetailFragment>(v).maximizeVideoDetail()
|
if (StatePlayer.instance.hasQueue) {
|
||||||
|
StatePlayer.instance.insertToQueue(v, true);
|
||||||
|
} else {
|
||||||
|
fragment.navigate<VideoDetailFragment>(v).maximizeVideoDetail();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is IPlatformPlaylist -> {
|
is IPlatformPlaylist -> {
|
||||||
@@ -244,7 +250,7 @@ class ChannelFragment : MainFragment() {
|
|||||||
adapter.onContentUrlClicked.subscribe { url, contentType ->
|
adapter.onContentUrlClicked.subscribe { url, contentType ->
|
||||||
when (contentType) {
|
when (contentType) {
|
||||||
ContentType.MEDIA -> {
|
ContentType.MEDIA -> {
|
||||||
StatePlayer.instance.clearQueue()
|
StatePlayer.instance.clearQueue();
|
||||||
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail()
|
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,7 +409,7 @@ class ChannelFragment : MainFragment() {
|
|||||||
_fragment.topBar?.onShown(channel)
|
_fragment.topBar?.onShown(channel)
|
||||||
|
|
||||||
val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) {
|
val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) {
|
||||||
UIDialogs.showConfirmationDialog(context,
|
val dialog = UIDialogs.showConfirmationDialog(context,
|
||||||
context.getString(R.string.do_you_want_to_convert_channel_channelname_to_a_playlist)
|
context.getString(R.string.do_you_want_to_convert_channel_channelname_to_a_playlist)
|
||||||
.replace("{channelName}", channel.name),
|
.replace("{channelName}", channel.name),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
private val _progressBar: ProgressBar;
|
private val _progressBar: ProgressBar;
|
||||||
private val _spinnerSortBy: Spinner;
|
private val _spinnerSortBy: Spinner;
|
||||||
private val _containerSortBy: LinearLayout;
|
private val _containerSortBy: LinearLayout;
|
||||||
private val _announcementView: AnnouncementView;
|
//private val _announcementView: AnnouncementView;
|
||||||
private val _tagsView: TagsView;
|
private val _tagsView: TagsView;
|
||||||
private val _textCentered: TextView;
|
private val _textCentered: TextView;
|
||||||
private val _emptyPagerContainer: FrameLayout;
|
private val _emptyPagerContainer: FrameLayout;
|
||||||
@@ -55,7 +55,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
protected val _toolbarContentView: LinearLayout;
|
protected val _toolbarContentView: LinearLayout;
|
||||||
protected val _bottomContentView: LinearLayout;
|
protected val _bottomContentView: LinearLayout;
|
||||||
|
|
||||||
private var _loading: Boolean = true;
|
private var _loading: Boolean = false;
|
||||||
|
|
||||||
private val _pagerLock = Object();
|
private val _pagerLock = Object();
|
||||||
private var _cache: ItemCache<TResult>? = null;
|
private var _cache: ItemCache<TResult>? = null;
|
||||||
@@ -87,7 +87,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
_textCentered = findViewById(R.id.text_centered);
|
_textCentered = findViewById(R.id.text_centered);
|
||||||
_emptyPagerContainer = findViewById(R.id.empty_pager_container);
|
_emptyPagerContainer = findViewById(R.id.empty_pager_container);
|
||||||
_progressBar = findViewById(R.id.progress_bar);
|
_progressBar = findViewById(R.id.progress_bar);
|
||||||
_announcementView = findViewById(R.id.announcement_view)
|
//_announcementView = findViewById(R.id.announcement_view)
|
||||||
_progressBar.inactiveColor = Color.TRANSPARENT;
|
_progressBar.inactiveColor = Color.TRANSPARENT;
|
||||||
|
|
||||||
_swipeRefresh = findViewById(R.id.swipe_refresh);
|
_swipeRefresh = findViewById(R.id.swipe_refresh);
|
||||||
@@ -180,10 +180,9 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
|
|
||||||
val visibleItemCount = _recyclerResults.childCount;
|
val visibleItemCount = _recyclerResults.childCount;
|
||||||
val firstVisibleItem = recyclerData.layoutManager.findFirstVisibleItemPosition()
|
val firstVisibleItem = recyclerData.layoutManager.findFirstVisibleItemPosition()
|
||||||
//Logger.i(TAG, "onScrolled loadNextPage visibleItemCount=$visibleItemCount firstVisibleItem=$visibleItemCount")
|
//Logger.i(TAG, "onScrolled loadNextPage(): firstVisibleItem=$firstVisibleItem visibleItemCount=$visibleItemCount visibleThreshold=$visibleThreshold recyclerData.results.size=${recyclerData.results.size}")
|
||||||
|
|
||||||
if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= recyclerData.results.size && firstVisibleItem > 0) {
|
if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= recyclerData.results.size) {
|
||||||
//Logger.i(TAG, "onScrolled loadNextPage(): firstVisibleItem=$firstVisibleItem visibleItemCount=$visibleItemCount visibleThreshold=$visibleThreshold recyclerData.results.size=${recyclerData.results.size}")
|
|
||||||
loadNextPage();
|
loadNextPage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,61 +192,48 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected fun showAnnouncementView() {
|
protected fun showAnnouncementView() {
|
||||||
_announcementView.visibility = View.VISIBLE
|
//_announcementView.visibility = View.VISIBLE
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
|
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
|
||||||
val canScroll = if (recyclerData.results.isEmpty()) false else {
|
_recyclerResults.post {
|
||||||
val height = resources.displayMetrics.heightPixels;
|
val canScroll = _recyclerResults.canScrollVertically(1)
|
||||||
|
Logger.i(
|
||||||
|
TAG,
|
||||||
|
"ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter"
|
||||||
|
)
|
||||||
|
if (!canScroll || filteredResults.isEmpty()) {
|
||||||
|
_automaticNextPageCounter++
|
||||||
|
if (_automaticNextPageCounter < _automaticBackoff.size) {
|
||||||
|
if (_automaticNextPageCounter > 0) {
|
||||||
|
val automaticNextPageCounterSaved = _automaticNextPageCounter;
|
||||||
|
fragment.lifecycleScope.launch(Dispatchers.Default) {
|
||||||
|
val backoff = _automaticBackoff[Math.min(
|
||||||
|
_automaticBackoff.size - 1,
|
||||||
|
_automaticNextPageCounter
|
||||||
|
)];
|
||||||
|
|
||||||
val layoutManager = recyclerData.layoutManager
|
|
||||||
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
|
|
||||||
val firstVisibleItemView = if(firstVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(firstVisibleItemPosition) else null;
|
|
||||||
val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition();
|
|
||||||
val lastVisibleItemView = if(lastVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(lastVisibleItemPosition) else null;
|
|
||||||
val rows = if(recyclerData.layoutManager is GridLayoutManager) Math.max(1, recyclerData.results.size / recyclerData.layoutManager.spanCount) else 1;
|
|
||||||
val rowsHeight = (firstVisibleItemView?.height ?: 0) * rows;
|
|
||||||
if(lastVisibleItemView != null && lastVisibleItemPosition == (recyclerData.results.size - 1)) {
|
|
||||||
false;
|
|
||||||
}
|
|
||||||
else if (firstVisibleItemView != null && height != null && rowsHeight < height) {
|
|
||||||
false;
|
|
||||||
} else {
|
|
||||||
true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter")
|
|
||||||
if (!canScroll || filteredResults.isEmpty()) {
|
|
||||||
_automaticNextPageCounter++
|
|
||||||
if(_automaticNextPageCounter < _automaticBackoff.size) {
|
|
||||||
if(_automaticNextPageCounter > 0) {
|
|
||||||
val automaticNextPageCounterSaved = _automaticNextPageCounter;
|
|
||||||
fragment.lifecycleScope.launch(Dispatchers.Default) {
|
|
||||||
val backoff = _automaticBackoff[Math.min(_automaticBackoff.size - 1, _automaticNextPageCounter)];
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
setLoading(true);
|
|
||||||
}
|
|
||||||
delay(backoff.toLong());
|
|
||||||
if(automaticNextPageCounterSaved == _automaticNextPageCounter) {
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
loadNextPage();
|
setLoading(true);
|
||||||
|
}
|
||||||
|
delay(backoff.toLong());
|
||||||
|
if (automaticNextPageCounterSaved == _automaticNextPageCounter) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
loadNextPage();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
} else
|
||||||
withContext(Dispatchers.Main) {
|
loadNextPage();
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
} else {
|
||||||
loadNextPage();
|
Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset");
|
||||||
|
_automaticNextPageCounter = 0;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset");
|
|
||||||
_automaticNextPageCounter = 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun resetAutomaticNextPageCounter(){
|
fun resetAutomaticNextPageCounter(){
|
||||||
@@ -484,7 +470,9 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
recyclerData.resultsUnfiltered.addAll(toAdd);
|
recyclerData.resultsUnfiltered.addAll(toAdd);
|
||||||
recyclerData.adapter.notifyDataSetChanged();
|
recyclerData.adapter.notifyDataSetChanged();
|
||||||
recyclerData.loadedFeedStyle = feedStyle;
|
recyclerData.loadedFeedStyle = feedStyle;
|
||||||
ensureEnoughContentVisible(filteredResults)
|
setLoading(false)
|
||||||
|
if(pager.hasMorePages())
|
||||||
|
ensureEnoughContentVisible(filteredResults)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun detachPagerEvents() {
|
private fun detachPagerEvents() {
|
||||||
|
|||||||
+3
-1
@@ -365,8 +365,10 @@ class HomeFragment : MainFragment() {
|
|||||||
finishRefreshLayoutLoader();
|
finishRefreshLayoutLoader();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setPager(pager);
|
setPager(pager);
|
||||||
if(pager.getResults().isEmpty() && !pager.hasMorePages())
|
if(pager.getResults().isEmpty() && !pager.hasMorePages()) {
|
||||||
|
setLoading(false);
|
||||||
setEmptyPager(true);
|
setEmptyPager(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-4
@@ -124,11 +124,10 @@ class LibraryFilesFragment : MainFragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun leaveDirectory() {
|
fun leaveDirectory() {
|
||||||
if(navStack.size > 1) {
|
if (navStack.size > 1) {
|
||||||
navStack.removeLast();
|
navStack.removeAt(navStack.size - 1)
|
||||||
openDirectory(navStack.last());
|
openDirectory(navStack.last())
|
||||||
}
|
}
|
||||||
else {}
|
|
||||||
}
|
}
|
||||||
fun openDirectory(stack: FileStack, addToStack: Boolean = false) {
|
fun openDirectory(stack: FileStack, addToStack: Boolean = false) {
|
||||||
if(addToStack)
|
if(addToStack)
|
||||||
|
|||||||
+2
-2
@@ -266,7 +266,7 @@ class LibraryFragment : MainFragment() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if(this.allowMusic) {
|
if(this.allowMusic) {
|
||||||
val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount);
|
val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount)
|
||||||
adapterArtists.setData(artists);
|
adapterArtists.setData(artists);
|
||||||
if (artists.size == 0)
|
if (artists.size == 0)
|
||||||
sectionArtists.setEmpty(
|
sectionArtists.setEmpty(
|
||||||
@@ -289,7 +289,7 @@ class LibraryFragment : MainFragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(this.allowMusic) {
|
if(this.allowMusic) {
|
||||||
val albums = StateLibrary.instance.getAlbums();
|
val albums = StateLibrary.instance.getAlbums()
|
||||||
adapterAlbums.setData(albums);
|
adapterAlbums.setData(albums);
|
||||||
if (albums.size == 0)
|
if (albums.size == 0)
|
||||||
sectionAlbums.setEmpty("No albums", "No albums were found on your device", -1);
|
sectionAlbums.setEmpty("No albums", "No albums were found on your device", -1);
|
||||||
|
|||||||
-1
@@ -96,7 +96,6 @@ class LibraryVideosFragment : MainFragment() {
|
|||||||
fun onShown() {
|
fun onShown() {
|
||||||
val initialAlbums = StateLibrary.instance.getAlbums();
|
val initialAlbums = StateLibrary.instance.getAlbums();
|
||||||
Logger.i(TAG, "Initial album count: " + initialAlbums.size);
|
Logger.i(TAG, "Initial album count: " + initialAlbums.size);
|
||||||
val buckets = StateLibrary.instance.getVideoBucketNames();
|
|
||||||
setPager(StateLibrary.instance.getVideos(fragment._toggleBuckets));
|
setPager(StateLibrary.instance.getVideos(fragment._toggleBuckets));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ class LoginFragment : MainFragment() {
|
|||||||
fun showLogin(config: SourcePluginConfig, callback: ((SourceAuth?) -> Unit)? = null) {
|
fun showLogin(config: SourcePluginConfig, callback: ((SourceAuth?) -> Unit)? = null) {
|
||||||
if(_callback != null) _callback?.invoke(null);
|
if(_callback != null) _callback?.invoke(null);
|
||||||
_callback = callback;
|
_callback = callback;
|
||||||
StateApp.instance.activity?.navigate<LoginFragment>(config, false);
|
StateApp.instance.activity?.navigate<LoginFragment>(config, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
@@ -16,6 +16,7 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.futo.platformplayer.*
|
import com.futo.platformplayer.*
|
||||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
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.playlists.IPlatformPlaylistDetails
|
||||||
@@ -363,6 +364,7 @@ class RemotePlaylistFragment : MainFragment() {
|
|||||||
_imagePlaylistThumbnail.let {
|
_imagePlaylistThumbnail.let {
|
||||||
Glide.with(it)
|
Glide.with(it)
|
||||||
.load(video.thumbnails.getHQThumbnail())
|
.load(video.thumbnails.getHQThumbnail())
|
||||||
|
.withMaxSizePx()
|
||||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||||
.crossfade()
|
.crossfade()
|
||||||
.into(it);
|
.into(it);
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package com.futo.platformplayer.fragment.mainactivity.main
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.graphics.drawable.Animatable
|
import android.graphics.drawable.Animatable
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
@@ -13,10 +15,15 @@ import android.widget.ImageView
|
|||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
import androidx.core.graphics.drawable.toDrawable
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.Format
|
import androidx.media3.common.Format
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
|
import com.bumptech.glide.request.target.CustomTarget
|
||||||
|
import com.bumptech.glide.request.transition.Transition
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
@@ -71,6 +78,7 @@ import com.futo.platformplayer.views.video.FutoShortPlayer
|
|||||||
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
|
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
|
||||||
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_AUDIO_CONTAINERS
|
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_AUDIO_CONTAINERS
|
||||||
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_VIDEO_CONTAINERS
|
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_VIDEO_CONTAINERS
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
import com.futo.polycentric.core.ApiMethods
|
import com.futo.polycentric.core.ApiMethods
|
||||||
import com.futo.polycentric.core.ContentType
|
import com.futo.polycentric.core.ContentType
|
||||||
import com.futo.polycentric.core.Models
|
import com.futo.polycentric.core.Models
|
||||||
@@ -851,9 +859,8 @@ class ShortView : FrameLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val thumbnail = videoDetails.thumbnails.getHQThumbnail()
|
val thumbnail = videoDetails.thumbnails.getHQThumbnail()
|
||||||
/*
|
|
||||||
if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap()
|
if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap()
|
||||||
.load(thumbnail).into(object : CustomTarget<Bitmap>() {
|
.load(thumbnail).withMaxSizePx().into(object : CustomTarget<Bitmap>() {
|
||||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||||
player.setArtwork(resource.toDrawable(resources))
|
player.setArtwork(resource.toDrawable(resources))
|
||||||
}
|
}
|
||||||
@@ -863,7 +870,6 @@ class ShortView : FrameLayout {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
else player.setArtwork(null)
|
else player.setArtwork(null)
|
||||||
*/
|
|
||||||
|
|
||||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+13
-1
@@ -309,13 +309,14 @@ class SourceDetailFragment : MainFragment() {
|
|||||||
BigButton(c, context.getString(R.string.logout), context.getString(R.string.sign_out_of_the_platform), R.drawable.ic_logout) {
|
BigButton(c, context.getString(R.string.logout), context.getString(R.string.sign_out_of_the_platform), R.drawable.ic_logout) {
|
||||||
logoutSource();
|
logoutSource();
|
||||||
},
|
},
|
||||||
|
if(!Settings.instance.plugins.shouldClearWebviewCookies())
|
||||||
BigButton(c, "Logout without Clear", "Logout but keep the browser cookies.\nThis allows for quick re-logging.", R.drawable.ic_logout) {
|
BigButton(c, "Logout without Clear", "Logout but keep the browser cookies.\nThis allows for quick re-logging.", R.drawable.ic_logout) {
|
||||||
logoutSource(false);
|
logoutSource(false);
|
||||||
}.apply {
|
}.apply {
|
||||||
this.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
|
this.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
|
||||||
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
|
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
|
||||||
};
|
};
|
||||||
}
|
} else null
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -518,6 +519,17 @@ class SourceDetailFragment : MainFragment() {
|
|||||||
}
|
}
|
||||||
Logger.e(TAG, "Failed to set plugin authentication (loginSource, loginWarning)", e)
|
Logger.e(TAG, "Failed to set plugin authentication (loginSource, loginWarning)", e)
|
||||||
}
|
}
|
||||||
|
finally {
|
||||||
|
if(Settings.instance.plugins.shouldClearWebviewCookies()) {
|
||||||
|
try {
|
||||||
|
val cookieManager: CookieManager =
|
||||||
|
CookieManager.getInstance();
|
||||||
|
cookieManager.removeAllCookies(null);
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to clear cookies", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, UIDialogs.ActionStyle.PRIMARY))
|
}, UIDialogs.ActionStyle.PRIMARY))
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-27
@@ -50,7 +50,7 @@ class VideoDetailFragment() : MainFragment() {
|
|||||||
|
|
||||||
private var _isActive: Boolean = false;
|
private var _isActive: Boolean = false;
|
||||||
|
|
||||||
private var _viewDetail : VideoDetailView? = null;
|
var _viewDetail : VideoDetailView? = null;
|
||||||
private var _view : SingleViewTouchableMotionLayout? = null;
|
private var _view : SingleViewTouchableMotionLayout? = null;
|
||||||
|
|
||||||
var isFullscreen : Boolean = false;
|
var isFullscreen : Boolean = false;
|
||||||
@@ -356,38 +356,46 @@ class VideoDetailFragment() : MainFragment() {
|
|||||||
override fun onTransitionChange(motionLayout: MotionLayout?, startId: Int, endId: Int, progress: Float) {
|
override fun onTransitionChange(motionLayout: MotionLayout?, startId: Int, endId: Int, progress: Float) {
|
||||||
_viewDetail?.stopAllGestures()
|
_viewDetail?.stopAllGestures()
|
||||||
|
|
||||||
if (state != State.MINIMIZED && progress < 0.1) {
|
if (!isTransitioning && (progress < 0.9 && progress > 0.1)) {
|
||||||
state = State.MINIMIZED;
|
|
||||||
isMinimizingFromFullScreen = false
|
|
||||||
onMinimize.emit();
|
|
||||||
}
|
|
||||||
else if (state != State.MAXIMIZED && progress > 0.9) {
|
|
||||||
if (_isInitialMaximize) {
|
|
||||||
state = State.CLOSED;
|
|
||||||
_isInitialMaximize = false;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
state = State.MAXIMIZED;
|
|
||||||
onMaximized.emit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTransitioning && (progress > 0.95 || progress < 0.05)) {
|
|
||||||
isTransitioning = false;
|
|
||||||
onTransitioning.emit(isTransitioning);
|
|
||||||
|
|
||||||
if(isInPictureInPicture) leavePictureInPictureMode(false); //Workaround to prevent getting stuck in p2p
|
|
||||||
}
|
|
||||||
else if (!isTransitioning && (progress < 0.95 && progress > 0.05)) {
|
|
||||||
isTransitioning = true;
|
isTransitioning = true;
|
||||||
onTransitioning.emit(isTransitioning);
|
onTransitioning.emit(isTransitioning);
|
||||||
|
|
||||||
if(isInPictureInPicture) leavePictureInPictureMode(false); //Workaround to prevent getting stuck in p2p
|
if(isInPictureInPicture) leavePictureInPictureMode(false); //Workaround to prevent getting stuck in p2p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) { }
|
override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) {
|
||||||
|
val progress = motionLayout?.progress ?: return;
|
||||||
|
|
||||||
|
if (state != State.MINIMIZED && progress < 0.1) {
|
||||||
|
state = State.MINIMIZED;
|
||||||
|
isMinimizingFromFullScreen = false
|
||||||
|
onMinimize.emit();
|
||||||
|
}
|
||||||
|
else if (state != State.MAXIMIZED && progress > 0.9) {
|
||||||
|
state = State.MAXIMIZED;
|
||||||
|
onMaximized.emit();
|
||||||
|
/*
|
||||||
|
if (_isInitialMaximize) {
|
||||||
|
//state = State.CLOSED; Causes issues? might no longer be needed
|
||||||
|
_isInitialMaximize = false;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
state = State.MAXIMIZED;
|
||||||
|
onMaximized.emit();
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTransitioning && (progress > 0.6 || progress < 0.4)) {
|
||||||
|
isTransitioning = false;
|
||||||
|
onTransitioning.emit(isTransitioning);
|
||||||
|
|
||||||
|
if(isInPictureInPicture) leavePictureInPictureMode(false); //Workaround to prevent getting stuck in p2p
|
||||||
|
}
|
||||||
|
}
|
||||||
override fun onTransitionStarted(p0: MotionLayout?, p1: Int, p2: Int) { }
|
override fun onTransitionStarted(p0: MotionLayout?, p1: Int, p2: Int) { }
|
||||||
override fun onTransitionTrigger(p0: MotionLayout?, p1: Int, p2: Boolean, p3: Float) { }
|
override fun onTransitionTrigger(p0: MotionLayout?, p1: Int, p2: Boolean, p3: Float) {
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
_view?.let {
|
_view?.let {
|
||||||
@@ -446,7 +454,8 @@ class VideoDetailFragment() : MainFragment() {
|
|||||||
if (viewDetail.shouldEnterPictureInPicture) {
|
if (viewDetail.shouldEnterPictureInPicture) {
|
||||||
_leavingPiP = false
|
_leavingPiP = false
|
||||||
}
|
}
|
||||||
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.isAudioOnlyUserAction) {
|
val shouldPiP = Settings.instance.playback.isBackgroundPictureInPicture()
|
||||||
|
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && shouldPiP && !viewDetail.isAudioOnlyUserAction) {
|
||||||
val params = _viewDetail?.getPictureInPictureParams();
|
val params = _viewDetail?.getPictureInPictureParams();
|
||||||
if(params != null) {
|
if(params != null) {
|
||||||
Logger.i(TAG, "enterPictureInPictureMode")
|
Logger.i(TAG, "enterPictureInPictureMode")
|
||||||
|
|||||||
+108
-30
@@ -33,6 +33,7 @@ import android.widget.ImageButton
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.compose.ui.text.toLowerCase
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
@@ -42,6 +43,7 @@ import androidx.media3.datasource.HttpDataSource
|
|||||||
import androidx.media3.ui.PlayerControlView
|
import androidx.media3.ui.PlayerControlView
|
||||||
import androidx.media3.ui.TimeBar
|
import androidx.media3.ui.TimeBar
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.bumptech.glide.request.target.CustomTarget
|
import com.bumptech.glide.request.target.CustomTarget
|
||||||
import com.bumptech.glide.request.transition.Transition
|
import com.bumptech.glide.request.transition.Transition
|
||||||
import com.futo.platformplayer.BuildConfig
|
import com.futo.platformplayer.BuildConfig
|
||||||
@@ -161,6 +163,7 @@ import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
|||||||
import com.futo.platformplayer.views.video.FutoVideoPlayer
|
import com.futo.platformplayer.views.video.FutoVideoPlayer
|
||||||
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
|
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
|
||||||
import com.futo.platformplayer.views.videometa.UpNextView
|
import com.futo.platformplayer.views.videometa.UpNextView
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
import com.futo.polycentric.core.ApiMethods
|
import com.futo.polycentric.core.ApiMethods
|
||||||
import com.futo.polycentric.core.ContentType
|
import com.futo.polycentric.core.ContentType
|
||||||
import com.futo.polycentric.core.Models
|
import com.futo.polycentric.core.Models
|
||||||
@@ -213,6 +216,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
private val _playerProgress: PlayerControlView;
|
private val _playerProgress: PlayerControlView;
|
||||||
private val _timeBar: TimeBar;
|
private val _timeBar: TimeBar;
|
||||||
private var _upNext: UpNextView;
|
private var _upNext: UpNextView;
|
||||||
|
private var _artworkTarget: CustomTarget<Bitmap>? = null
|
||||||
|
|
||||||
private val rootView: ConstraintLayout;
|
private val rootView: ConstraintLayout;
|
||||||
|
|
||||||
@@ -552,12 +556,12 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_buttonMore = buttonMore;
|
_buttonMore = buttonMore;
|
||||||
updateMoreButtons();
|
updateMoreButtons();
|
||||||
|
|
||||||
val handleLoaderGameVisibilityChanged = { b: Boolean ->
|
val handleLoaderGameVisibilityChanged: (Boolean) -> Unit = { b: Boolean ->
|
||||||
_loaderGameVisible = b
|
_loaderGameVisible = b
|
||||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
onShouldEnterPictureInPictureChanged.emit()
|
onShouldEnterPictureInPictureChanged.emit()
|
||||||
|
updateResumeVisibilityFor(lastPositionMilliseconds)
|
||||||
}
|
}
|
||||||
updateResumeVisibilityFor(lastPositionMilliseconds)
|
|
||||||
}
|
}
|
||||||
_player.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
|
_player.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
|
||||||
_cast.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
|
_cast.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
|
||||||
@@ -721,15 +725,17 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
val activeDevice = StateCasting.instance.activeDevice;
|
val activeDevice = StateCasting.instance.activeDevice;
|
||||||
if (activeDevice != null) {
|
if (activeDevice != null) {
|
||||||
handlePlayChanged(it);
|
handlePlayChanged(it);
|
||||||
|
|
||||||
val v = video;
|
|
||||||
if (!it && v != null && v.duration - activeDevice.time.toLong() < 2L) {
|
|
||||||
Log.i(TAG, "Next video (loop?)")
|
|
||||||
nextVideo();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
StateCasting.instance.onActiveDeviceMediaItemEnd.subscribe(this) {
|
||||||
|
val activeDevice = StateCasting.instance.activeDevice;
|
||||||
|
if (activeDevice != null) {
|
||||||
|
Log.i(TAG, "Next video (loop?)")
|
||||||
|
nextVideo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
StateCasting.instance.onActiveDeviceTimeChanged.subscribe(this) {
|
StateCasting.instance.onActiveDeviceTimeChanged.subscribe(this) {
|
||||||
if (_isCasting) {
|
if (_isCasting) {
|
||||||
setLastPositionMilliseconds((it * 1000.0).toLong(), true);
|
setLastPositionMilliseconds((it * 1000.0).toLong(), true);
|
||||||
@@ -877,6 +883,9 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onClose.subscribe {
|
onClose.subscribe {
|
||||||
|
_artworkTarget?.let { Glide.with(context).clear(it) }
|
||||||
|
_artworkTarget = null
|
||||||
|
_player.setArtwork(null)
|
||||||
checkAndRemoveWatchLater();
|
checkAndRemoveWatchLater();
|
||||||
_lastVideoSource = null;
|
_lastVideoSource = null;
|
||||||
_lastAudioSource = null;
|
_lastAudioSource = null;
|
||||||
@@ -1190,6 +1199,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
else if(_didStop) {
|
else if(_didStop) {
|
||||||
_didStop = false;
|
_didStop = false;
|
||||||
Logger.i(TAG, "loadCurrentVideo _lastPosition=${lastPositionMilliseconds}");
|
Logger.i(TAG, "loadCurrentVideo _lastPosition=${lastPositionMilliseconds}");
|
||||||
|
StatePlayer.instance.startOrUpdateMediaSession(context, video);
|
||||||
loadCurrentVideo(lastPositionMilliseconds);
|
loadCurrentVideo(lastPositionMilliseconds);
|
||||||
handlePause();
|
handlePause();
|
||||||
}
|
}
|
||||||
@@ -1259,6 +1269,9 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
fun onDestroy() {
|
fun onDestroy() {
|
||||||
Logger.i(TAG, "onDestroy");
|
Logger.i(TAG, "onDestroy");
|
||||||
_destroyed = true;
|
_destroyed = true;
|
||||||
|
_artworkTarget?.let { Glide.with(context).clear(it) }
|
||||||
|
_artworkTarget = null
|
||||||
|
_player.setArtwork(null)
|
||||||
_taskLoadVideo.cancel();
|
_taskLoadVideo.cancel();
|
||||||
_commentsList.cancel();
|
_commentsList.cancel();
|
||||||
_player.clear();
|
_player.clear();
|
||||||
@@ -1271,6 +1284,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
StateCasting.instance.onActiveDevicePlayChanged.remove(this);
|
StateCasting.instance.onActiveDevicePlayChanged.remove(this);
|
||||||
StateCasting.instance.onActiveDeviceTimeChanged.remove(this);
|
StateCasting.instance.onActiveDeviceTimeChanged.remove(this);
|
||||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
|
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
|
||||||
|
StateCasting.instance.onActiveDeviceMediaItemEnd.remove(this)
|
||||||
StateApp.instance.preventPictureInPicture.remove(this);
|
StateApp.instance.preventPictureInPicture.remove(this);
|
||||||
StatePlayer.instance.onQueueChanged.remove(this);
|
StatePlayer.instance.onQueueChanged.remove(this);
|
||||||
StatePlayer.instance.onVideoChanging.remove(this);
|
StatePlayer.instance.onVideoChanging.remove(this);
|
||||||
@@ -2047,19 +2061,31 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_player.switchToVideoMode()
|
_player.switchToVideoMode()
|
||||||
isAudioOnlyUserAction = false;
|
isAudioOnlyUserAction = false;
|
||||||
} else {
|
} else {
|
||||||
val thumbnail = video.thumbnails.getHQThumbnail();
|
_artworkTarget?.let { Glide.with(context).clear(it) }
|
||||||
if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode
|
_artworkTarget = null
|
||||||
Glide.with(context).asBitmap().load(thumbnail)
|
|
||||||
.into(object: CustomTarget<Bitmap>() {
|
val thumbnail = video.thumbnails.getHQThumbnail()
|
||||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
val showArtwork = _player.isAudioMode || isAudioOnlyUserAction || (videoSource == null)
|
||||||
_player.setArtwork(BitmapDrawable(resources, resource));
|
|
||||||
}
|
if (showArtwork && !thumbnail.isNullOrBlank()) {
|
||||||
override fun onLoadCleared(placeholder: Drawable?) {
|
val target = object : CustomTarget<Bitmap>() {
|
||||||
_player.setArtwork(null);
|
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||||
}
|
_player.setArtwork(BitmapDrawable(resources, resource))
|
||||||
});
|
}
|
||||||
else
|
override fun onLoadCleared(placeholder: Drawable?) {
|
||||||
_player.setArtwork(null);
|
_player.setArtwork(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_artworkTarget = target
|
||||||
|
|
||||||
|
Glide.with(context)
|
||||||
|
.asBitmap()
|
||||||
|
.load(thumbnail)
|
||||||
|
.withMaxSizePx()
|
||||||
|
.into(target)
|
||||||
|
} else {
|
||||||
|
_player.setArtwork(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
@@ -2418,9 +2444,54 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
val doDedup = Settings.instance.playback.simplifySources;
|
val doDedup = Settings.instance.playback.simplifySources;
|
||||||
|
|
||||||
val bestVideoSources = if(doDedup) (videoSources?.map { it.height * it.width }
|
val allLanguages = videoSources?.map { it.language }?.distinct() ?: listOf();
|
||||||
?.distinct()
|
val langResCombinations = if(videoSources != null) allLanguages.flatMap {
|
||||||
?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
|
lang -> videoSources
|
||||||
|
.filter { v -> v.language == lang }
|
||||||
|
.map { it.height * it.width }
|
||||||
|
.distinct()
|
||||||
|
.map { res -> Pair(res, lang) }
|
||||||
|
} else listOf();
|
||||||
|
|
||||||
|
|
||||||
|
Log.i(TAG, "Language count: ${allLanguages}");
|
||||||
|
var videoSourceItems = mutableListOf<SlideUpMenuItem>();
|
||||||
|
var selectedLanguage: String? = null;
|
||||||
|
val languageFilters = if(allLanguages.filter { it != null }.count() > 1)
|
||||||
|
SlideUpMenuButtonList(this.context, null, "language_filter", true).apply {
|
||||||
|
var languageFilterLabels = allLanguages.filterNotNull().toList();
|
||||||
|
val english = languageFilterLabels.find { it?.lowercase() == "en" };
|
||||||
|
val originalLanguage = videoSources?.find { it.original == true }?.language;
|
||||||
|
val primaryLanguage = Settings.instance.playback.getPrimaryLanguage();
|
||||||
|
val hasPrimaryLanguage = videoSources?.any { it.language == primaryLanguage } ?: false;
|
||||||
|
|
||||||
|
if(english != null)
|
||||||
|
languageFilterLabels = listOf(english).plus(languageFilterLabels.filter { it != english }).toList();
|
||||||
|
if(primaryLanguage != null && languageFilterLabels.contains(primaryLanguage))
|
||||||
|
languageFilterLabels = listOf(primaryLanguage).plus(languageFilterLabels.filter { it != primaryLanguage }).toList();
|
||||||
|
if(originalLanguage != null)
|
||||||
|
languageFilterLabels = listOf(originalLanguage).plus(languageFilterLabels.filter { it != originalLanguage }).toList();
|
||||||
|
Log.i(TAG, "Language filtesr: ${languageFilterLabels.joinToString(", ")}");
|
||||||
|
selectedLanguage = originalLanguage ?: (if(hasPrimaryLanguage) primaryLanguage else null);
|
||||||
|
setButtons(languageFilterLabels, selectedLanguage);
|
||||||
|
onClick.subscribe { selected ->
|
||||||
|
setSelected(selected);
|
||||||
|
|
||||||
|
videoSourceItems.forEach {
|
||||||
|
val item = it.itemTag;
|
||||||
|
if(item is IVideoSource) {
|
||||||
|
if(item.language == selected)
|
||||||
|
it.visibility = View.VISIBLE;
|
||||||
|
else
|
||||||
|
it.visibility = View.GONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else null;
|
||||||
|
|
||||||
|
val bestVideoSources = if(doDedup && videoSources != null) (langResCombinations
|
||||||
|
?.map { comb -> VideoHelper.selectBestVideoSource(videoSources.filter { comb.first == it.height * it.width && comb.second == it.language }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
|
||||||
?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource }))
|
?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource }))
|
||||||
?.distinct()
|
?.distinct()
|
||||||
?.filterNotNull()
|
?.filterNotNull()
|
||||||
@@ -2436,7 +2507,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()
|
val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()
|
||||||
val qualityPlaybackSpeedTitle = if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", currentPlaybackRate)})"); } else null;
|
val qualityPlaybackSpeedTitle = if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", currentPlaybackRate)})"); } else null;
|
||||||
_overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString(
|
_overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString(
|
||||||
R.string.quality), null, true,
|
R.string.quality), null, false,
|
||||||
qualityPlaybackSpeedTitle,
|
qualityPlaybackSpeedTitle,
|
||||||
if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply {
|
if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply {
|
||||||
val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds();
|
val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds();
|
||||||
@@ -2526,11 +2597,10 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
call = { _player.selectAudioTrack(it.bitrate) });
|
call = { _player.selectAudioTrack(it.bitrate) });
|
||||||
}.toList().toTypedArray())
|
}.toList().toTypedArray())
|
||||||
else null,
|
else null,
|
||||||
|
if(languageFilters != null) languageFilters else null,
|
||||||
if(bestVideoSources.isNotEmpty())
|
if(bestVideoSources.isNotEmpty())
|
||||||
SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
|
SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
|
||||||
*bestVideoSources
|
(bestVideoSources.map {
|
||||||
.map {
|
|
||||||
val estSize = VideoHelper.estimateSourceSize(it);
|
val estSize = VideoHelper.estimateSourceSize(it);
|
||||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||||
SlideUpMenuItem(this.context,
|
SlideUpMenuItem(this.context,
|
||||||
@@ -2539,8 +2609,14 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "",
|
if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "",
|
||||||
(prefix + it.codec.trim()).trim(),
|
(prefix + it.codec.trim()).trim(),
|
||||||
tag = it,
|
tag = it,
|
||||||
call = { handleSelectVideoTrack(it) });
|
call = { handleSelectVideoTrack(it) }).apply {
|
||||||
}.toList().toTypedArray())
|
videoSourceItems.add(this);
|
||||||
|
if(selectedLanguage != null) {
|
||||||
|
if(it.language != selectedLanguage)
|
||||||
|
this.visibility = View.GONE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}).toList())
|
||||||
else null,
|
else null,
|
||||||
if(bestAudioSources.isNotEmpty())
|
if(bestAudioSources.isNotEmpty())
|
||||||
SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
|
SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
|
||||||
@@ -3357,9 +3433,11 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
false
|
false
|
||||||
else {
|
else {
|
||||||
isLoginStop = true;
|
isLoginStop = true;
|
||||||
|
onMinimize.emit();
|
||||||
StatePlugins.instance.loginPlugin(context, id) {
|
StatePlugins.instance.loginPlugin(context, id) {
|
||||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
fetchVideo();
|
fetchVideo();
|
||||||
|
onMaximize.emit(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -14,6 +14,7 @@ import android.widget.TextView
|
|||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.setPadding
|
import androidx.core.view.setPadding
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.UISlideOverlays
|
import com.futo.platformplayer.UISlideOverlays
|
||||||
@@ -28,6 +29,7 @@ import com.futo.platformplayer.toHumanDuration
|
|||||||
import com.futo.platformplayer.toHumanTime
|
import com.futo.platformplayer.toHumanTime
|
||||||
import com.futo.platformplayer.views.SearchView
|
import com.futo.platformplayer.views.SearchView
|
||||||
import com.futo.platformplayer.views.lists.VideoListEditorView
|
import com.futo.platformplayer.views.lists.VideoListEditorView
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
|
|
||||||
abstract class VideoListEditorView : LinearLayout {
|
abstract class VideoListEditorView : LinearLayout {
|
||||||
private var _videoListEditorView: VideoListEditorView;
|
private var _videoListEditorView: VideoListEditorView;
|
||||||
@@ -211,6 +213,7 @@ abstract class VideoListEditorView : LinearLayout {
|
|||||||
_imagePlaylistThumbnail.let {
|
_imagePlaylistThumbnail.let {
|
||||||
Glide.with(it)
|
Glide.with(it)
|
||||||
.load(video.thumbnails.getHQThumbnail())
|
.load(video.thumbnails.getHQThumbnail())
|
||||||
|
.withMaxSizePx()
|
||||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||||
.crossfade()
|
.crossfade()
|
||||||
.into(it);
|
.into(it);
|
||||||
|
|||||||
+58
-1
@@ -1,11 +1,15 @@
|
|||||||
package com.futo.platformplayer.fragment.mainactivity.topbar
|
package com.futo.platformplayer.fragment.mainactivity.topbar
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.CreatorsFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.CreatorsFragment
|
||||||
@@ -16,18 +20,54 @@ import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment
|
|||||||
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragmentData
|
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragmentData
|
||||||
import com.futo.platformplayer.models.SearchType
|
import com.futo.platformplayer.models.SearchType
|
||||||
|
import com.futo.platformplayer.states.StateAnnouncement
|
||||||
import com.futo.platformplayer.views.casting.CastButton
|
import com.futo.platformplayer.views.casting.CastButton
|
||||||
|
import com.futo.platformplayer.views.notification.NotificationOverlayView
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class GeneralTopBarFragment : TopFragment() {
|
class GeneralTopBarFragment : TopFragment() {
|
||||||
private var _buttonSearch: ImageButton? = null;
|
private var _buttonSearch: ImageButton? = null;
|
||||||
private var _buttonCast: CastButton? = null;
|
private var _buttonCast: CastButton? = null;
|
||||||
|
|
||||||
|
private var _buttonNotifs: ConstraintLayout? = null;
|
||||||
|
private var _buttonNotifIcon: ImageView? = null;
|
||||||
|
private var _buttonNotifCount: TextView? = null;
|
||||||
|
|
||||||
|
init {
|
||||||
|
StateAnnouncement.instance.onAnnouncementChanged.subscribe {
|
||||||
|
lifecycleScope?.launch(Dispatchers.Main) {
|
||||||
|
updateNotifCount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNotifCount() {
|
||||||
|
val currentAnnouncements = StateAnnouncement.instance.getVisibleAnnouncements();
|
||||||
|
if(currentAnnouncements.any())
|
||||||
|
_buttonNotifCount?.let {
|
||||||
|
it.text = currentAnnouncements.size.toString();
|
||||||
|
it.visibility = View.VISIBLE;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
_buttonNotifCount?.let {
|
||||||
|
it.text = currentAnnouncements.size.toString();
|
||||||
|
it.visibility = View.GONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onShown(parameter: Any?) {
|
override fun onShown(parameter: Any?) {
|
||||||
if(currentMain is CreatorsFragment) {
|
if(currentMain is CreatorsFragment) {
|
||||||
_buttonSearch?.setImageResource(R.drawable.ic_person_search_300w);
|
_buttonSearch?.setImageResource(R.drawable.ic_person_search_300w);
|
||||||
} else {
|
} else {
|
||||||
_buttonSearch?.setImageResource(R.drawable.ic_search_300w);
|
_buttonSearch?.setImageResource(R.drawable.ic_search_300w);
|
||||||
}
|
}
|
||||||
|
if(currentMain is NotificationOverlayView.Frag) {
|
||||||
|
_buttonNotifIcon?.setImageResource(R.drawable.ic_notifications_filled)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
_buttonNotifIcon?.setImageResource(R.drawable.ic_notifications)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
override fun onHide() {
|
override fun onHide() {
|
||||||
|
|
||||||
@@ -43,13 +83,30 @@ class GeneralTopBarFragment : TopFragment() {
|
|||||||
val buttonSearch: ImageButton = view.findViewById(R.id.button_search);
|
val buttonSearch: ImageButton = view.findViewById(R.id.button_search);
|
||||||
_buttonCast = view.findViewById(R.id.button_cast);
|
_buttonCast = view.findViewById(R.id.button_cast);
|
||||||
|
|
||||||
|
_buttonNotifs = view.findViewById(R.id.button_notifs);
|
||||||
|
_buttonNotifIcon = view.findViewById(R.id.button_notifs_icon);
|
||||||
|
_buttonNotifCount = view.findViewById(R.id.button_notifs_count);
|
||||||
|
|
||||||
|
updateNotifCount();
|
||||||
|
|
||||||
|
_buttonNotifs?.setOnClickListener {
|
||||||
|
if(currentMain is NotificationOverlayView.Frag)
|
||||||
|
closeSegment();
|
||||||
|
else
|
||||||
|
navigate<NotificationOverlayView.Frag>();
|
||||||
|
}
|
||||||
|
|
||||||
buttonSearch.setOnClickListener {
|
buttonSearch.setOnClickListener {
|
||||||
if(currentMain is CreatorsFragment) {
|
if(currentMain is CreatorsFragment) {
|
||||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.CREATOR));
|
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.CREATOR));
|
||||||
} else if (currentMain is PlaylistsFragment || currentMain is PlaylistFragment) {
|
} else if (currentMain is PlaylistsFragment || currentMain is PlaylistFragment) {
|
||||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.PLAYLIST));
|
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.PLAYLIST));
|
||||||
} else if (currentMain is LibraryFragment) {
|
} else if (currentMain is LibraryFragment) {
|
||||||
navigate<LibrarySearchFragment>();
|
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
UIDialogs.toast("Your Android version is too old for Mediastore search", true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
navigate<LibrarySearchFragment>();
|
||||||
} else {
|
} else {
|
||||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO));
|
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ class VideoHelper {
|
|||||||
fun isDownloadable(source: IVideoSource) = (source is IVideoUrlSource || source is IHLSManifestSource || source is JSDashManifestRawSource) && source !is IWidevineSource
|
fun isDownloadable(source: IVideoSource) = (source is IVideoUrlSource || source is IHLSManifestSource || source is JSDashManifestRawSource) && source !is IWidevineSource
|
||||||
fun isDownloadable(source: IAudioSource) = (source is IAudioUrlSource || source is IHLSManifestAudioSource || source is JSDashManifestRawAudioSource) && source !is IWidevineSource
|
fun isDownloadable(source: IAudioSource) = (source is IAudioUrlSource || source is IHLSManifestAudioSource || source is JSDashManifestRawAudioSource) && source !is IWidevineSource
|
||||||
|
|
||||||
fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers);
|
fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>, preferredLanguage: String? = null) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers, preferredLanguage);
|
||||||
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? {
|
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>, preferredLanguage: String? = null) : IVideoSource? {
|
||||||
val targetVideo = if(desiredPixelCount > 0) {
|
val targetVideo = if(desiredPixelCount > 0) {
|
||||||
sources.toList().minByOrNull { x -> abs(x.height * x.width - desiredPixelCount) };
|
sources.toList().minByOrNull { x -> abs(x.height * x.width - desiredPixelCount) };
|
||||||
} else {
|
} else {
|
||||||
@@ -63,12 +63,34 @@ class VideoHelper {
|
|||||||
val hasPriority = sources.any { it.priority };
|
val hasPriority = sources.any { it.priority };
|
||||||
|
|
||||||
val targetPixelCount = if(targetVideo != null) targetVideo.width * targetVideo.height else desiredPixelCount;
|
val targetPixelCount = if(targetVideo != null) targetVideo.width * targetVideo.height else desiredPixelCount;
|
||||||
val altSources = if(hasPriority) {
|
|
||||||
|
//Filter priority
|
||||||
|
var altSources = if(hasPriority) {
|
||||||
sources.filter { it.priority }.sortedBy { x -> abs(x.height * x.width - targetPixelCount) };
|
sources.filter { it.priority }.sortedBy { x -> abs(x.height * x.width - targetPixelCount) };
|
||||||
} else {
|
} else {
|
||||||
sources.filter { it.height == (targetVideo?.height ?: 0) };
|
sources.filter { it.height == (targetVideo?.height ?: 0) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//Filter Original
|
||||||
|
val hasOriginal = altSources.any { it.original == true };
|
||||||
|
if(hasOriginal && Settings.instance.playback.preferOriginalAudio)
|
||||||
|
altSources = altSources.filter { it.original == true };
|
||||||
|
|
||||||
|
//Filter Language
|
||||||
|
val languageToFilter = if(preferredLanguage != null && altSources.any { it.language == preferredLanguage }) {
|
||||||
|
preferredLanguage
|
||||||
|
} else {
|
||||||
|
if(altSources.any { it.language == Language.ENGLISH })
|
||||||
|
Language.ENGLISH;
|
||||||
|
else
|
||||||
|
Language.UNKNOWN;
|
||||||
|
}
|
||||||
|
if(altSources.any { it.language == languageToFilter }) {
|
||||||
|
altSources.filter { it.language == languageToFilter }.sortedBy { it.bitrate }.toList();
|
||||||
|
} else {
|
||||||
|
altSources.sortedBy { it.bitrate }
|
||||||
|
}
|
||||||
|
|
||||||
var bestSource = altSources.firstOrNull();
|
var bestSource = altSources.firstOrNull();
|
||||||
for (prefContainer in prefContainers) {
|
for (prefContainer in prefContainers) {
|
||||||
val betterSource = altSources.firstOrNull { it.container == prefContainer };
|
val betterSource = altSources.firstOrNull { it.container == prefContainer };
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import android.graphics.drawable.Drawable
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.RequestBuilder
|
import com.bumptech.glide.RequestBuilder
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
|
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
|
||||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
|
|
||||||
class GlideHelper {
|
class GlideHelper {
|
||||||
|
|
||||||
@@ -14,7 +16,7 @@ class GlideHelper {
|
|||||||
fun ImageView.loadThumbnails(thumbnails: Thumbnails, isHQ: Boolean = true, continuation: ((RequestBuilder<Drawable>) -> Unit)? = null) {
|
fun ImageView.loadThumbnails(thumbnails: Thumbnails, isHQ: Boolean = true, continuation: ((RequestBuilder<Drawable>) -> Unit)? = null) {
|
||||||
val url = if(isHQ) thumbnails.getHQThumbnail() ?: thumbnails.getLQThumbnail() else thumbnails.getLQThumbnail();
|
val url = if(isHQ) thumbnails.getHQThumbnail() ?: thumbnails.getLQThumbnail() else thumbnails.getLQThumbnail();
|
||||||
|
|
||||||
val req = Glide.with(this).load(url);
|
val req = Glide.with(this).load(url).withMaxSizePx()
|
||||||
|
|
||||||
if (thumbnails.hasMultiple() && false) { //TODO: Resolve issue where fallback triggered on second loads?
|
if (thumbnails.hasMultiple() && false) { //TODO: Resolve issue where fallback triggered on second loads?
|
||||||
val fallbackUrl = if (isHQ) thumbnails.getLQThumbnail() else thumbnails.getHQThumbnail();
|
val fallbackUrl = if (isHQ) thumbnails.getLQThumbnail() else thumbnails.getHQThumbnail();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.futo.platformplayer.receivers
|
package com.futo.platformplayer.receivers
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -26,14 +27,24 @@ class InstallReceiver : BroadcastReceiver() {
|
|||||||
val activityIntent: Intent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
val activityIntent: Intent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
||||||
} else {
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activityIntent == null) {
|
if (activityIntent == null) {
|
||||||
Logger.w(TAG, "Received STATUS_PENDING_USER_ACTION and activity intent is null.")
|
Logger.w(TAG, "Received STATUS_PENDING_USER_ACTION and activity intent is null.")
|
||||||
|
onReceiveResult.emit(context.getString(R.string.install_failed_device_installer_broken))
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
context.startActivity(activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
|
||||||
|
activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
|
||||||
|
try {
|
||||||
|
context.startActivity(activityIntent)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
Logger.e(TAG, "System installer cannot handle CONFIRM_INSTALL intent. ROM is broken; falling back / reporting error.", e)
|
||||||
|
onReceiveResult.emit(context.getString(R.string.install_failed_device_installer_broken))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
PackageInstaller.STATUS_SUCCESS -> onReceiveResult.emit(null);
|
PackageInstaller.STATUS_SUCCESS -> onReceiveResult.emit(null);
|
||||||
PackageInstaller.STATUS_FAILURE -> onReceiveResult.emit(context.getString(R.string.general_failure));
|
PackageInstaller.STATUS_FAILURE -> onReceiveResult.emit(context.getString(R.string.general_failure));
|
||||||
@@ -45,6 +56,7 @@ class InstallReceiver : BroadcastReceiver() {
|
|||||||
PackageInstaller.STATUS_FAILURE_STORAGE -> onReceiveResult.emit(context.getString(R.string.not_enough_storage));
|
PackageInstaller.STATUS_FAILURE_STORAGE -> onReceiveResult.emit(context.getString(R.string.not_enough_storage));
|
||||||
else -> {
|
else -> {
|
||||||
val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
|
val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
|
||||||
|
Logger.w(TAG, "Received unknown install status $status, message=$msg")
|
||||||
onReceiveResult.emit(msg)
|
onReceiveResult.emit(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import com.futo.platformplayer.Settings
|
|||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.downloads.VideoDownload
|
import com.futo.platformplayer.downloads.VideoDownload
|
||||||
|
import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException
|
||||||
import com.futo.platformplayer.exceptions.DownloadException
|
import com.futo.platformplayer.exceptions.DownloadException
|
||||||
import com.futo.platformplayer.getNowDiffMinutes
|
import com.futo.platformplayer.getNowDiffMinutes
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
@@ -169,6 +170,7 @@ class DownloadService : Service() {
|
|||||||
Thread.sleep(500);
|
Thread.sleep(500);
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
catch(ex: Throwable) {
|
||||||
|
//if(ex is ScriptReloadRequiredException)
|
||||||
Logger.e(TAG, "Download failed", ex);
|
Logger.e(TAG, "Download failed", ex);
|
||||||
if(currentVideo.video == null && currentVideo.videoDetails == null) {
|
if(currentVideo.video == null && currentVideo.videoDetails == null) {
|
||||||
//Corrupt?
|
//Corrupt?
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import android.util.Log
|
|||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.bumptech.glide.request.target.CustomTarget
|
import com.bumptech.glide.request.target.CustomTarget
|
||||||
import com.bumptech.glide.request.transition.Transition
|
import com.bumptech.glide.request.transition.Transition
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
@@ -38,6 +39,7 @@ import com.futo.platformplayer.receivers.MediaControlReceiver
|
|||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.states.StatePlayer
|
import com.futo.platformplayer.states.StatePlayer
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
|
|
||||||
class MediaPlaybackService : Service() {
|
class MediaPlaybackService : Service() {
|
||||||
private val TAG = "MediaPlaybackService";
|
private val TAG = "MediaPlaybackService";
|
||||||
@@ -172,21 +174,26 @@ class MediaPlaybackService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun closeMediaSession() {
|
fun closeMediaSession() {
|
||||||
Logger.v(TAG, "closeMediaSession");
|
Logger.v(TAG, "closeMediaSession")
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE);
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
|
||||||
abandonAudioFocus()
|
abandonAudioFocus()
|
||||||
|
|
||||||
val notifManager = _notificationManager;
|
val notifManager = _notificationManager
|
||||||
Logger.i(TAG, "Cancelling playback notification (notifManager: ${notifManager != null})");
|
Logger.i(TAG, "Cancelling playback notification (notifManager: ${notifManager != null})")
|
||||||
notifManager?.cancel(MEDIA_NOTIF_ID);
|
notifManager?.cancel(MEDIA_NOTIF_ID)
|
||||||
_notif_last_video = null;
|
|
||||||
_notif_last_bitmap = null;
|
|
||||||
_mediaSession = null;
|
|
||||||
|
|
||||||
if(_instance == this)
|
_notif_last_video = null
|
||||||
_instance = null;
|
_notif_last_bitmap = null
|
||||||
this.stopSelf();
|
|
||||||
|
_mediaSession?.isActive = false
|
||||||
|
_mediaSession?.release()
|
||||||
|
_mediaSession = null
|
||||||
|
|
||||||
|
if (_instance == this)
|
||||||
|
_instance = null
|
||||||
|
|
||||||
|
stopSelf()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateMediaSession(videoUpdated: IPlatformVideo?) {
|
fun updateMediaSession(videoUpdated: IPlatformVideo?) {
|
||||||
@@ -206,37 +213,37 @@ class MediaPlaybackService : Service() {
|
|||||||
if(_notificationChannel == null || _mediaSession == null)
|
if(_notificationChannel == null || _mediaSession == null)
|
||||||
setupNotificationRequirements();
|
setupNotificationRequirements();
|
||||||
|
|
||||||
_mediaSession?.setMetadata(
|
updateMediaMetadata(video, lastBitmap)
|
||||||
MediaMetadataCompat.Builder()
|
|
||||||
.putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name)
|
|
||||||
.putString(MediaMetadata.METADATA_KEY_TITLE, video.name)
|
|
||||||
.putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000)
|
|
||||||
.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, lastBitmap)
|
|
||||||
.build());
|
|
||||||
|
|
||||||
val thumbnail = video.thumbnails.getHQThumbnail();
|
val thumbnail = video.thumbnails.getHQThumbnail();
|
||||||
|
|
||||||
_notif_last_video = video;
|
_notif_last_video = video;
|
||||||
|
|
||||||
if(isUpdating)
|
if(isUpdating)
|
||||||
notifyMediaSession(video, _notif_last_bitmap);
|
notifyMediaSession(video, _notif_last_bitmap?.takeIf { !it.isRecycled });
|
||||||
else if(thumbnail != null) {
|
else if(thumbnail != null) {
|
||||||
notifyMediaSession(video, null);
|
notifyMediaSession(video, null);
|
||||||
val tag = video;
|
val tag = video;
|
||||||
Glide.with(this).asBitmap()
|
Glide.with(this).asBitmap()
|
||||||
.load(thumbnail)
|
.load(thumbnail)
|
||||||
|
.withMaxSizePx()
|
||||||
.into(object: CustomTarget<Bitmap>() {
|
.into(object: CustomTarget<Bitmap>() {
|
||||||
override fun onResourceReady(resource: Bitmap,transition: Transition<in Bitmap>?) {
|
override fun onResourceReady(resource: Bitmap,transition: Transition<in Bitmap>?) {
|
||||||
if(tag == _notif_last_video) {
|
if (tag != _notif_last_video) return
|
||||||
notifyMediaSession(video, resource)
|
if (resource.isRecycled) {
|
||||||
_mediaSession?.setMetadata(
|
notifyMediaSession(video, null)
|
||||||
MediaMetadataCompat.Builder()
|
return
|
||||||
.putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name)
|
|
||||||
.putString(MediaMetadata.METADATA_KEY_TITLE, video.name)
|
|
||||||
.putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000)
|
|
||||||
.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, resource)
|
|
||||||
.build());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val albumArt = resource.copy(
|
||||||
|
resource.config ?: Bitmap.Config.ARGB_8888,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
_notif_last_bitmap = albumArt
|
||||||
|
|
||||||
|
notifyMediaSession(video, albumArt)
|
||||||
|
updateMediaMetadata(video, albumArt)
|
||||||
}
|
}
|
||||||
override fun onLoadCleared(placeholder: Drawable?) {
|
override fun onLoadCleared(placeholder: Drawable?) {
|
||||||
if(tag == _notif_last_video)
|
if(tag == _notif_last_video)
|
||||||
@@ -247,6 +254,19 @@ class MediaPlaybackService : Service() {
|
|||||||
else
|
else
|
||||||
notifyMediaSession(video, null);
|
notifyMediaSession(video, null);
|
||||||
}
|
}
|
||||||
|
private fun updateMediaMetadata(video: IPlatformVideo, bitmap: Bitmap?) {
|
||||||
|
val builder = MediaMetadataCompat.Builder()
|
||||||
|
.putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name)
|
||||||
|
.putString(MediaMetadata.METADATA_KEY_TITLE, video.name)
|
||||||
|
.putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000)
|
||||||
|
|
||||||
|
val safeBitmap = bitmap?.takeIf { !it.isRecycled }
|
||||||
|
if (safeBitmap != null) {
|
||||||
|
builder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, safeBitmap)
|
||||||
|
}
|
||||||
|
|
||||||
|
_mediaSession?.setMetadata(builder.build())
|
||||||
|
}
|
||||||
private fun generateMediaAction(icon: Int, title: String, intent: PendingIntent) : NotificationCompat.Action {
|
private fun generateMediaAction(icon: Int, title: String, intent: PendingIntent) : NotificationCompat.Action {
|
||||||
return NotificationCompat.Action.Builder(icon, title, intent).build();
|
return NotificationCompat.Action.Builder(icon, title, intent).build();
|
||||||
}
|
}
|
||||||
@@ -436,26 +456,19 @@ class MediaPlaybackService : Service() {
|
|||||||
|
|
||||||
val audioFocusLossDuration = _audioFocusLossTime_ms?.let { System.currentTimeMillis() - it }
|
val audioFocusLossDuration = _audioFocusLossTime_ms?.let { System.currentTimeMillis() - it }
|
||||||
_audioFocusLossTime_ms = null
|
_audioFocusLossTime_ms = null
|
||||||
|
|
||||||
Log.i(TAG, "Audio focus gained (restartPlaybackAfterLoss = ${Settings.instance.playback.restartPlaybackAfterLoss}, _audioFocusLossTime_ms = $_audioFocusLossTime_ms, audioFocusLossDuration = ${audioFocusLossDuration})");
|
Log.i(TAG, "Audio focus gained (restartPlaybackAfterLoss = ${Settings.instance.playback.restartPlaybackAfterLoss}, _audioFocusLossTime_ms = $_audioFocusLossTime_ms, audioFocusLossDuration = ${audioFocusLossDuration})");
|
||||||
|
|
||||||
if (Settings.instance.playback.restartPlaybackAfterLoss == 1) {
|
if (audioFocusLossDuration == null) return@OnAudioFocusChangeListener
|
||||||
if (audioFocusLossDuration != null && audioFocusLossDuration < 1000 * 10) {
|
when (Settings.instance.playback.restartPlaybackAfterLoss) {
|
||||||
MediaControlReceiver.onPlayReceived.emit()
|
1 -> if (audioFocusLossDuration < 10_000) MediaControlReceiver.onPlayReceived.emit()
|
||||||
}
|
2 -> if (audioFocusLossDuration < 30_000) MediaControlReceiver.onPlayReceived.emit()
|
||||||
} else if (Settings.instance.playback.restartPlaybackAfterLoss == 2) {
|
3 -> MediaControlReceiver.onPlayReceived.emit()
|
||||||
if (audioFocusLossDuration != null && audioFocusLossDuration < 1000 * 30) {
|
|
||||||
MediaControlReceiver.onPlayReceived.emit()
|
|
||||||
}
|
|
||||||
} else if (Settings.instance.playback.restartPlaybackAfterLoss == 3) {
|
|
||||||
MediaControlReceiver.onPlayReceived.emit()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||||
_audioFocusLossTime_ms = if (isPlaying) {
|
val wasPlaying = isPlaying
|
||||||
System.currentTimeMillis()
|
_audioFocusLossTime_ms = if (wasPlaying) System.currentTimeMillis() else null
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
_hasFocus = false;
|
_hasFocus = false;
|
||||||
_isTransientLoss = true;
|
_isTransientLoss = true;
|
||||||
@@ -468,11 +481,8 @@ class MediaPlaybackService : Service() {
|
|||||||
_isTransientLoss = true;
|
_isTransientLoss = true;
|
||||||
}
|
}
|
||||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||||
_audioFocusLossTime_ms = if (isPlaying) {
|
val wasPlaying = isPlaying
|
||||||
System.currentTimeMillis()
|
_audioFocusLossTime_ms = if (wasPlaying) System.currentTimeMillis() else null
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
MediaControlReceiver.onPauseReceived.emit();
|
MediaControlReceiver.onPauseReceived.emit();
|
||||||
abandonAudioFocus();
|
abandonAudioFocus();
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
package com.futo.platformplayer.states
|
package com.futo.platformplayer.states
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import android.view.WindowManager
|
||||||
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
import com.futo.platformplayer.constructs.Event0
|
import com.futo.platformplayer.constructs.Event0
|
||||||
|
import com.futo.platformplayer.constructs.Event1
|
||||||
|
import com.futo.platformplayer.constructs.Event2
|
||||||
|
import com.futo.platformplayer.dialogs.PluginUpdateDialog
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.models.ImageVariable
|
||||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
import com.futo.platformplayer.stores.StringHashSetStorage
|
import com.futo.platformplayer.stores.StringHashSetStorage
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -110,6 +119,48 @@ class StateAnnouncement {
|
|||||||
onAnnouncementChanged.emit();
|
onAnnouncementChanged.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//Special Announcements
|
||||||
|
fun registerPluginUpdate(oldConfig: SourcePluginConfig, newConfig: SourcePluginConfig): SessionAnnouncement {
|
||||||
|
val announcement = SessionAnnouncement(
|
||||||
|
"update-plugin-" + UUID.randomUUID().toString(),
|
||||||
|
"${newConfig.name} update v${newConfig.version} available!",
|
||||||
|
"An update is available to upgrade from ${oldConfig.version} to ${newConfig.version}.",
|
||||||
|
AnnouncementType.SESSION,
|
||||||
|
null, "updates", "Update", StateAnnouncement.ACTION_UPDATE_PLUGIN,
|
||||||
|
null, null,oldConfig.id,
|
||||||
|
newConfig?.absoluteIconUrl?.let { ImageVariable.fromUrl(it) }
|
||||||
|
).withExtraAction("Changelog", StateAnnouncement.ACTION_CHANGELOG, oldConfig.id);
|
||||||
|
registerAnnouncementSession(announcement);
|
||||||
|
return announcement;
|
||||||
|
}
|
||||||
|
fun registerPluginUpdated(newConfig: SourcePluginConfig) {
|
||||||
|
registerAnnouncementSession(SessionAnnouncement(
|
||||||
|
"updated-plugin-" + UUID.randomUUID().toString(),
|
||||||
|
"${newConfig.name} updated to v${newConfig.version}!",
|
||||||
|
"You have succesfully been updated to v${newConfig.version}.",
|
||||||
|
AnnouncementType.SESSION,
|
||||||
|
null, "updates", null, null,
|
||||||
|
null, null,null,
|
||||||
|
newConfig?.absoluteIconUrl?.let { ImageVariable.fromUrl(it) }
|
||||||
|
).withExtraAction("Changelog", StateAnnouncement.ACTION_CHANGELOG, newConfig.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
fun registerLoading(title: String, description: String, icon: ImageVariable? = null, customId: String? = null): SessionAnnouncement {
|
||||||
|
val id = "loading-" + UUID.randomUUID().toString();
|
||||||
|
val announcement = SessionAnnouncement(
|
||||||
|
customId ?: id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
AnnouncementType.ONGOING,
|
||||||
|
null, "loading", null, null,
|
||||||
|
null, null,null, icon
|
||||||
|
);
|
||||||
|
registerAnnouncementSession(announcement);
|
||||||
|
return announcement;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fun getVisibleAnnouncements(category: String? = null): List<Announcement> {
|
fun getVisibleAnnouncements(category: String? = null): List<Announcement> {
|
||||||
synchronized(_lock) {
|
synchronized(_lock) {
|
||||||
if (category != null) {
|
if (category != null) {
|
||||||
@@ -122,7 +173,9 @@ class StateAnnouncement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun closeAnnouncement(id: String) {
|
fun closeAnnouncement(id: String?) {
|
||||||
|
if(id == null)
|
||||||
|
return;
|
||||||
val item: Announcement?;
|
val item: Announcement?;
|
||||||
synchronized(_lock) {
|
synchronized(_lock) {
|
||||||
item = _announcementsStore.findItem { it.id == id };
|
item = _announcementsStore.findItem { it.id == id };
|
||||||
@@ -164,6 +217,7 @@ class StateAnnouncement {
|
|||||||
cancelAction?.invoke(item);
|
cancelAction?.invoke(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
onAnnouncementChanged?.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteAllAnnouncements() {
|
fun deleteAllAnnouncements() {
|
||||||
@@ -194,7 +248,9 @@ class StateAnnouncement {
|
|||||||
|
|
||||||
onAnnouncementChanged.emit();
|
onAnnouncementChanged.emit();
|
||||||
}
|
}
|
||||||
fun neverAnnouncement(id: String) {
|
fun neverAnnouncement(id: String?) {
|
||||||
|
if(id == null)
|
||||||
|
return;
|
||||||
synchronized(_lock) {
|
synchronized(_lock) {
|
||||||
val item = _announcementsStore.findItem { it.id == id };
|
val item = _announcementsStore.findItem { it.id == id };
|
||||||
if (item != null && !_announcementsNever.contains(id))
|
if (item != null && !_announcementsNever.contains(id))
|
||||||
@@ -208,19 +264,26 @@ class StateAnnouncement {
|
|||||||
_announcementsNever.save();
|
_announcementsNever.save();
|
||||||
onAnnouncementChanged.emit();
|
onAnnouncementChanged.emit();
|
||||||
}
|
}
|
||||||
fun actionAnnouncement(id: String) {
|
fun actionAnnouncement(id: String?, extra: Boolean = false) {
|
||||||
|
if(id == null)
|
||||||
|
return;
|
||||||
val item = _announcementsStore.findItem { it.id == id } ?: _sessionAnnouncements[id];
|
val item = _announcementsStore.findItem { it.id == id } ?: _sessionAnnouncements[id];
|
||||||
if(item != null)
|
if(item != null)
|
||||||
actionAnnouncement(item);
|
actionAnnouncement(item, extra);
|
||||||
}
|
}
|
||||||
fun actionAnnouncement(item: Announcement) {
|
fun actionAnnouncement(item: Announcement, extra: Boolean = false) {
|
||||||
|
val actionId = if(!extra) item.actionId else if(item is SessionAnnouncement) item.extraActionId else null;
|
||||||
|
val actionData = if(!extra) item.actionData else if(item is SessionAnnouncement) item.extraActionData else null;
|
||||||
|
|
||||||
val action = _sessionActions[item.id];
|
val action = _sessionActions[item.id];
|
||||||
if (action != null) {
|
if (action != null) {
|
||||||
action(item);
|
action(item);
|
||||||
} else {
|
} else {
|
||||||
when (item.actionId) {
|
when (actionId) {
|
||||||
ACTION_NEVER -> neverAnnouncement(item.id);
|
ACTION_NEVER -> neverAnnouncement(item.id);
|
||||||
ACTION_SOMETHING -> actionSomething();
|
ACTION_SOMETHING -> actionSomething();
|
||||||
|
ACTION_CHANGELOG -> actionChangelog(actionData);
|
||||||
|
ACTION_UPDATE_PLUGIN -> actionUpdatePlugin(item.id, actionData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,6 +314,84 @@ class StateAnnouncement {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun actionChangelog(id: String?) {
|
||||||
|
if(id == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
StateApp.instance.contextOrNull?.let { context ->
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
|
val plugin = StatePlugins.instance.getPlugin(id);
|
||||||
|
if (plugin == null)
|
||||||
|
return@launch
|
||||||
|
val update = StatePlugins.instance.checkForUpdates(plugin.config);
|
||||||
|
if(update == null)
|
||||||
|
return@launch;
|
||||||
|
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
|
UIDialogs.showChangelogDialog(context, update.version, update.changelog!!.filterKeys { it.toIntOrNull() != null }
|
||||||
|
.mapKeys { it.key.toInt() }
|
||||||
|
.mapValues { update.getChangelogString(it.key.toString()) ?: "" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private fun actionUpdatePlugin(notifId: String?, id: String?) {
|
||||||
|
if(id == null)
|
||||||
|
return;
|
||||||
|
val plugin = StatePlugins.instance.getPlugin(id);
|
||||||
|
if (plugin == null)
|
||||||
|
return
|
||||||
|
|
||||||
|
closeAnnouncement(notifId);
|
||||||
|
val loadingAnnouncement = registerLoading("Updating ${plugin.config.name}..", "An update is in progress for ${plugin.config.name}.",
|
||||||
|
if(plugin.config.absoluteIconUrl != null) ImageVariable.fromUrl(plugin.config.absoluteIconUrl!!) else null);
|
||||||
|
|
||||||
|
val loadingId = loadingAnnouncement.id;
|
||||||
|
|
||||||
|
StateApp.instance.contextOrNull?.let { context ->
|
||||||
|
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val update = StatePlugins.instance.checkForUpdates(plugin.config);
|
||||||
|
if (update == null)
|
||||||
|
return@launch;
|
||||||
|
|
||||||
|
val client = ManagedHttpClient();
|
||||||
|
client.setTimeout(10000);
|
||||||
|
val script = StatePlugins.instance.getScript(plugin.config.id) ?: "";
|
||||||
|
val newScript = client.get(update.absoluteScriptUrl)?.body?.string();
|
||||||
|
if(newScript.isNullOrEmpty())
|
||||||
|
throw IllegalStateException("No script found");
|
||||||
|
|
||||||
|
if(true || plugin.config.isLowRiskUpdate(script, update, newScript)) {
|
||||||
|
StatePlugins.instance.installPluginBackground(context, StateApp.instance.scope, update, newScript,
|
||||||
|
{ text: String, progress: Double -> },
|
||||||
|
{ ex ->
|
||||||
|
if(ex == null) {
|
||||||
|
registerPluginUpdated(update);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
UIDialogs.appToast("Update for ${update.name} failed\n" + ex.message);
|
||||||
|
}
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
|
closeAnnouncement(loadingId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
|
closeAnnouncement(loadingId);
|
||||||
|
UIDialogs.showPluginUpdateDialog(context, plugin.config, update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to trigger update from announcement", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun registerDefaultHandlerAnnouncement() {
|
fun registerDefaultHandlerAnnouncement() {
|
||||||
registerAnnouncement(
|
registerAnnouncement(
|
||||||
"default-url-handler",
|
"default-url-handler",
|
||||||
@@ -279,6 +420,8 @@ class StateAnnouncement {
|
|||||||
|
|
||||||
|
|
||||||
const val ACTION_SOMETHING = "SOMETHING";
|
const val ACTION_SOMETHING = "SOMETHING";
|
||||||
|
const val ACTION_CHANGELOG = "CHANGELOG";
|
||||||
|
const val ACTION_UPDATE_PLUGIN = "UPDATE_PLUGIN";
|
||||||
const val ACTION_NEVER = "NEVER";
|
const val ACTION_NEVER = "NEVER";
|
||||||
private const val TAG = "StateAnnouncement";
|
private const val TAG = "StateAnnouncement";
|
||||||
}
|
}
|
||||||
@@ -294,7 +437,8 @@ open class Announcement(
|
|||||||
val time: OffsetDateTime? = null,
|
val time: OffsetDateTime? = null,
|
||||||
val category: String? = null,
|
val category: String? = null,
|
||||||
val actionName: String? = null,
|
val actionName: String? = null,
|
||||||
val actionId: String? = null
|
val actionId: String? = null,
|
||||||
|
val actionData: String? = null
|
||||||
);
|
);
|
||||||
class SessionAnnouncement(
|
class SessionAnnouncement(
|
||||||
id: String,
|
id: String,
|
||||||
@@ -306,7 +450,9 @@ class SessionAnnouncement(
|
|||||||
actionName: String? = null,
|
actionName: String? = null,
|
||||||
actionId: String? = null,
|
actionId: String? = null,
|
||||||
val cancelName: String? = null,
|
val cancelName: String? = null,
|
||||||
val cancelActionId: String? = null
|
val cancelActionId: String? = null,
|
||||||
|
actionData: String? = null,
|
||||||
|
val icon: ImageVariable? = null
|
||||||
): Announcement(
|
): Announcement(
|
||||||
id= id,
|
id= id,
|
||||||
title = title,
|
title = title,
|
||||||
@@ -315,13 +461,40 @@ class SessionAnnouncement(
|
|||||||
time = time,
|
time = time,
|
||||||
category = category,
|
category = category,
|
||||||
actionName = actionName,
|
actionName = actionName,
|
||||||
actionId = actionId
|
actionId = actionId,
|
||||||
);
|
actionData = actionData
|
||||||
|
) {
|
||||||
|
var extraActionName: String? = null;
|
||||||
|
var extraActionId: String? = null;
|
||||||
|
var extraActionData: String? = null;
|
||||||
|
|
||||||
|
var extraObj: Any? = null;
|
||||||
|
|
||||||
|
var progress: Double? = null;
|
||||||
|
val onProgressChanged = Event1<SessionAnnouncement>();
|
||||||
|
|
||||||
|
fun withExtraAction(name: String, id: String, data: String? = null): SessionAnnouncement {
|
||||||
|
extraActionName = name;
|
||||||
|
extraActionId = id;
|
||||||
|
extraActionData = data;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setProgress(progress: Double) {
|
||||||
|
this.progress = progress;
|
||||||
|
onProgressChanged?.emit(this);
|
||||||
|
}
|
||||||
|
fun setProgress(progress: Int) {
|
||||||
|
this.progress = progress.toDouble().div(100);
|
||||||
|
onProgressChanged?.emit(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum class AnnouncementType(val value : Int) {
|
enum class AnnouncementType(val value : Int) {
|
||||||
DELETABLE(0), //Close button deletes announcement (generally for actions)
|
DELETABLE(0), //Close button deletes announcement (generally for actions)
|
||||||
RECURRING(1), //Shows up till never is pressed (generally for patchnotes etc)
|
RECURRING(1), //Shows up till never is pressed (generally for patchnotes etc)
|
||||||
PERMANENT(2), //Shows up until deleted through other means (action)
|
PERMANENT(2), //Shows up until deleted through other means (action)
|
||||||
SESSION(3), //Not persistent, only during this session
|
SESSION(3), //Not persistent, only during this session
|
||||||
SESSION_RECURRING(4); //Not persistent, only during this session, recurring id
|
SESSION_RECURRING(4), //Not persistent, only during this session, recurring id
|
||||||
|
ONGOING(5);
|
||||||
}
|
}
|
||||||
@@ -13,9 +13,12 @@ import android.net.Network
|
|||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.net.NetworkRequest
|
import android.net.NetworkRequest
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import android.util.DisplayMetrics
|
import android.util.DisplayMetrics
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.webkit.CookieManager
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
@@ -43,6 +46,7 @@ import com.futo.platformplayer.logging.AndroidLogConsumer
|
|||||||
import com.futo.platformplayer.logging.FileLogConsumer
|
import com.futo.platformplayer.logging.FileLogConsumer
|
||||||
import com.futo.platformplayer.logging.LogLevel
|
import com.futo.platformplayer.logging.LogLevel
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.models.ImageVariable
|
||||||
import com.futo.platformplayer.receivers.AudioNoisyReceiver
|
import com.futo.platformplayer.receivers.AudioNoisyReceiver
|
||||||
import com.futo.platformplayer.services.DownloadService
|
import com.futo.platformplayer.services.DownloadService
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
@@ -306,49 +310,45 @@ class StateApp {
|
|||||||
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit) {
|
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit) {
|
||||||
return requestDirectoryAccess(activity, name, purpose, path, handle, false);
|
return requestDirectoryAccess(activity, name, purpose, path, handle, false);
|
||||||
}
|
}
|
||||||
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit, skipDialog: Boolean = false)
|
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?) -> Unit, skipDialog: Boolean = false) {
|
||||||
{
|
if (Looper.myLooper() != Looper.getMainLooper()) {
|
||||||
if(activity is Context)
|
Handler(Looper.getMainLooper()).post {
|
||||||
{
|
requestDirectoryAccess(activity, name, purpose, path, handle, skipDialog)
|
||||||
if(skipDialog) {
|
}
|
||||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
return
|
||||||
if(path != null)
|
}
|
||||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
|
|
||||||
|
if (activity is Context) {
|
||||||
|
if (skipDialog) {
|
||||||
|
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||||
|
if (path != null) intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path)
|
||||||
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
.or(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
|
.or(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
|
||||||
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
|
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION)
|
||||||
|
|
||||||
activity.launchForResult(intent, 99) {
|
activity.launchForResult(intent, 99) {
|
||||||
if(it.resultCode == Activity.RESULT_OK) {
|
if (it.resultCode == Activity.RESULT_OK) handle(it.data?.data)
|
||||||
handle(it.data?.data);
|
else UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted")
|
||||||
}
|
}
|
||||||
else
|
} else {
|
||||||
UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted");
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
UIDialogs.showDialog(activity, R.drawable.ic_security, "Directory required for\n${name}", "Please select a directory for ${name}.\n${purpose}".trim(), null, 0,
|
UIDialogs.showDialog(activity, R.drawable.ic_security, "Directory required for\n${name}", "Please select a directory for ${name}.\n${purpose}".trim(), null, 0,
|
||||||
UIDialogs.Action("Cancel", {}),
|
UIDialogs.Action("Cancel", {}),
|
||||||
UIDialogs.Action("Ok", {
|
UIDialogs.Action("Ok", {
|
||||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||||
if(path != null)
|
if (path != null) intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path)
|
||||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
|
|
||||||
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
.or(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
|
.or(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
|
||||||
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
|
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION)
|
||||||
|
|
||||||
activity.launchForResult(intent, 99) {
|
activity.launchForResult(intent, 99) {
|
||||||
if(it.resultCode == Activity.RESULT_OK) {
|
if (it.resultCode == Activity.RESULT_OK) handle(it.data?.data)
|
||||||
handle(it.data?.data);
|
else UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted")
|
||||||
}
|
}
|
||||||
else
|
}, UIDialogs.ActionStyle.PRIMARY)
|
||||||
UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted");
|
)
|
||||||
};
|
|
||||||
}, UIDialogs.ActionStyle.PRIMARY));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,9 +436,9 @@ class StateApp {
|
|||||||
try {
|
try {
|
||||||
val caFile = AppCaUpdater.ensureCaBundle(context)
|
val caFile = AppCaUpdater.ensureCaBundle(context)
|
||||||
Libcurl.setDefaultCAPath(caFile.absolutePath)
|
Libcurl.setDefaultCAPath(caFile.absolutePath)
|
||||||
|
Logger.i(TAG, "Libcurl initialized")
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
val fallback = File(context.noBackupFilesDir, "curl-ca-bundle.pem")
|
Logger.e(TAG, "Failed to initialize Libcurl", t);
|
||||||
if (fallback.exists()) Libcurl.setDefaultCAPath(fallback.absolutePath)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,6 +447,17 @@ class StateApp {
|
|||||||
_cacheDirectory?.let { ApiMethods.initCache(it) };
|
_cacheDirectory?.let { ApiMethods.initCache(it) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(Settings.instance.plugins.shouldClearWebviewCookies()) {
|
||||||
|
try {
|
||||||
|
Logger.i(TAG, "Clearing cookies on startup");
|
||||||
|
val cookieManager: CookieManager =
|
||||||
|
CookieManager.getInstance();
|
||||||
|
cookieManager.removeAllCookies(null);
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
Logger.e(SourceDetailFragment.Companion.TAG, "Failed to clear cookies", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Logger.i(TAG, "MainApp Starting: Initializing [ModerationsManager]");
|
Logger.i(TAG, "MainApp Starting: Initializing [ModerationsManager]");
|
||||||
ModerationsManager.initialize(context);
|
ModerationsManager.initialize(context);
|
||||||
|
|
||||||
@@ -572,30 +583,39 @@ class StateApp {
|
|||||||
DownloadService.getOrCreateService(context);
|
DownloadService.getOrCreateService(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate]");
|
if (Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
|
||||||
val autoUpdateEnabled = Settings.instance.autoUpdate.isAutoUpdateEnabled();
|
if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
|
||||||
val shouldDownload = Settings.instance.autoUpdate.shouldDownload();
|
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate Background]");
|
||||||
val backgroundDownload = Settings.instance.autoUpdate.backgroundDownload == 1;
|
val constraints = Constraints.Builder()
|
||||||
when {
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
//Background download
|
.build();
|
||||||
autoUpdateEnabled && shouldDownload && backgroundDownload -> {
|
|
||||||
StateUpdate.instance.setShouldBackgroundUpdate(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
autoUpdateEnabled && !shouldDownload && backgroundDownload -> {
|
val periodicRequest = PeriodicWorkRequest.Builder(
|
||||||
Logger.i(TAG, "Auto update skipped due to wrong network state");
|
UpdateCheckWorker::class.java,
|
||||||
}
|
12, TimeUnit.HOURS
|
||||||
|
)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build();
|
||||||
|
|
||||||
//Foreground download
|
val wm = WorkManager.getInstance(context);
|
||||||
autoUpdateEnabled -> {
|
wm.enqueueUniquePeriodicWork(
|
||||||
|
UpdateCheckWorker.UNIQUE_WORK_NAME,
|
||||||
|
ExistingPeriodicWorkPolicy.UPDATE,
|
||||||
|
periodicRequest
|
||||||
|
);
|
||||||
|
|
||||||
|
val oneTimeRequest = OneTimeWorkRequest.Builder(UpdateCheckWorker::class.java)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build();
|
||||||
|
wm.enqueue(oneTimeRequest);
|
||||||
|
} else {
|
||||||
|
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate]");
|
||||||
scopeOrNull?.launch(Dispatchers.IO) {
|
scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
StateUpdate.instance.checkForUpdates(context, false)
|
StateUpdate.instance.checkForUpdates(context, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
else -> {
|
Logger.i(TAG, "AutoUpdate disabled");
|
||||||
Logger.i(TAG, "Auto update disabled");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.i(TAG, "MainApp Started: Initialize [Noisy]");
|
Logger.i(TAG, "MainApp Started: Initialize [Noisy]");
|
||||||
@@ -650,9 +670,7 @@ class StateApp {
|
|||||||
scheduleBackgroundWork(context, interval != 0, interval);
|
scheduleBackgroundWork(context, interval != 0, interval);
|
||||||
|
|
||||||
Logger.i(TAG, "MainApp Started: Initialize [AutoBackup]");
|
Logger.i(TAG, "MainApp Started: Initialize [AutoBackup]");
|
||||||
Settings.instance.backup.didAskAutoBackup = true; //Some users have issues with it
|
|
||||||
if(!Settings.instance.backup.didAskAutoBackup && !Settings.instance.backup.shouldAutomaticBackup()) {
|
if(!Settings.instance.backup.didAskAutoBackup && !Settings.instance.backup.shouldAutomaticBackup()) {
|
||||||
/*
|
|
||||||
StateAnnouncement.instance.registerAnnouncement("backup", "Set Automatic Backup", "Configure daily backups of your data to restore in case of catastrophic failure.", AnnouncementType.SESSION, null, null, "Configure", {
|
StateAnnouncement.instance.registerAnnouncement("backup", "Set Automatic Backup", "Configure daily backups of your data to restore in case of catastrophic failure.", AnnouncementType.SESSION, null, null, "Configure", {
|
||||||
if(context is IWithResultLauncher && !Settings.instance.storage.isStorageMainValid(context)) {
|
if(context is IWithResultLauncher && !Settings.instance.storage.isStorageMainValid(context)) {
|
||||||
UIDialogs.toast("Missing general directory");
|
UIDialogs.toast("Missing general directory");
|
||||||
@@ -669,7 +687,6 @@ class StateApp {
|
|||||||
Settings.instance.backup.didAskAutoBackup = true;
|
Settings.instance.backup.didAskAutoBackup = true;
|
||||||
Settings.instance.save();
|
Settings.instance.save();
|
||||||
});
|
});
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
else if(Settings.instance.backup.didAskAutoBackup && Settings.instance.backup.shouldAutomaticBackup() && !Settings.instance.storage.isStorageMainValid(context)) {
|
else if(Settings.instance.backup.didAskAutoBackup && Settings.instance.backup.shouldAutomaticBackup() && !Settings.instance.storage.isStorageMainValid(context)) {
|
||||||
if(context is IWithResultLauncher) {
|
if(context is IWithResultLauncher) {
|
||||||
@@ -723,8 +740,10 @@ class StateApp {
|
|||||||
));
|
));
|
||||||
|
|
||||||
for(update in updateAvailable)
|
for(update in updateAvailable)
|
||||||
if(StatePlatform.instance.isClientEnabled(update.first.id))
|
if(StatePlatform.instance.isClientEnabled(update.first.id)) {
|
||||||
UIDialogs.showPluginUpdateDialog(context, update.first, update.second);
|
//UIDialogs.showPluginUpdateDialog(context, update.first, update.second);
|
||||||
|
StateAnnouncement.instance.registerPluginUpdate(update.first, update.second);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -781,24 +800,20 @@ class StateApp {
|
|||||||
Logger.i("StateApp", "No AutoBackup configured");
|
Logger.i("StateApp", "No AutoBackup configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun scheduleBackgroundWork(context: Context, active: Boolean = true, intervalMinutes: Int = 60 * 12) {
|
fun scheduleBackgroundWork(context: Context, active: Boolean = true, intervalMinutes: Int = 60 * 12) {
|
||||||
try {
|
try {
|
||||||
val wm = WorkManager.getInstance(context);
|
val wm = WorkManager.getInstance(context);
|
||||||
|
|
||||||
if(active) {
|
if (active) {
|
||||||
if(BuildConfig.DEBUG)
|
if (BuildConfig.DEBUG)
|
||||||
UIDialogs.toast(context, "Scheduling background every ${intervalMinutes} minutes");
|
UIDialogs.toast(context, "Scheduling background every ${intervalMinutes} minutes");
|
||||||
|
|
||||||
val req = PeriodicWorkRequest.Builder(BackgroundWorker::class.java, intervalMinutes.toLong(), TimeUnit.MINUTES, 5, TimeUnit.MINUTES)
|
val req = PeriodicWorkRequest.Builder(BackgroundWorker::class.java, intervalMinutes.toLong(), TimeUnit.MINUTES, 5, TimeUnit.MINUTES)
|
||||||
.setConstraints(Constraints.Builder()
|
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.UNMETERED).build()).build();
|
||||||
.setRequiredNetworkType(NetworkType.UNMETERED)
|
|
||||||
.build())
|
|
||||||
.build();
|
|
||||||
wm.enqueueUniquePeriodicWork("backgroundSubscriptions", ExistingPeriodicWorkPolicy.UPDATE, req);
|
wm.enqueueUniquePeriodicWork("backgroundSubscriptions", ExistingPeriodicWorkPolicy.UPDATE, req);
|
||||||
|
} else {
|
||||||
|
wm.cancelUniqueWork("backgroundSubscriptions");
|
||||||
}
|
}
|
||||||
else
|
|
||||||
wm.cancelAllWork();
|
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.e(TAG, "Failed to schedule background subscription updates.", e)
|
Logger.e(TAG, "Failed to schedule background subscription updates.", e)
|
||||||
UIDialogs.toast(context, "Background subscription update failed: " + e.message)
|
UIDialogs.toast(context, "Background subscription update failed: " + e.message)
|
||||||
@@ -806,6 +821,7 @@ class StateApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private suspend fun migrateStores(context: Context, managedStores: List<ManagedStore<*>>, index: Int) {
|
private suspend fun migrateStores(context: Context, managedStores: List<ManagedStore<*>>, index: Int) {
|
||||||
if(managedStores.size <= index)
|
if(managedStores.size <= index)
|
||||||
return;
|
return;
|
||||||
@@ -903,15 +919,6 @@ class StateApp {
|
|||||||
try {
|
try {
|
||||||
if(FragmentedStorage.isInitialized && Settings.instance.downloads.shouldDownload())
|
if(FragmentedStorage.isInitialized && Settings.instance.downloads.shouldDownload())
|
||||||
StateDownloads.instance.checkForDownloadsTodos();
|
StateDownloads.instance.checkForDownloadsTodos();
|
||||||
|
|
||||||
val autoUpdateEnabled = Settings.instance.autoUpdate.isAutoUpdateEnabled();
|
|
||||||
val shouldDownload = Settings.instance.autoUpdate.shouldDownload();
|
|
||||||
val backgroundDownload = Settings.instance.autoUpdate.backgroundDownload == 1;
|
|
||||||
if (autoUpdateEnabled && shouldDownload && backgroundDownload) {
|
|
||||||
StateUpdate.instance.setShouldBackgroundUpdate(true);
|
|
||||||
} else {
|
|
||||||
StateUpdate.instance.setShouldBackgroundUpdate(false);
|
|
||||||
}
|
|
||||||
} catch(ex: Throwable) {
|
} catch(ex: Throwable) {
|
||||||
Logger.w(TAG, "Failed to handle capabilities changed event", ex);
|
Logger.w(TAG, "Failed to handle capabilities changed event", ex);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class StateAssets {
|
|||||||
if(part == "." || part == "..") {
|
if(part == "." || part == "..") {
|
||||||
if(parentAllowance <= 0)
|
if(parentAllowance <= 0)
|
||||||
throw IllegalStateException("Path [${path}] attempted to escape path..");
|
throw IllegalStateException("Path [${path}] attempted to escape path..");
|
||||||
parts1.removeLast();
|
parts1.removeAt(parts1.size - 1);
|
||||||
toSkip++;
|
toSkip++;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -49,6 +49,33 @@ class StateBackup {
|
|||||||
|
|
||||||
private val _autoBackupLock = Object();
|
private val _autoBackupLock = Object();
|
||||||
|
|
||||||
|
private val AUTO_MAGIC = byteArrayOf(
|
||||||
|
0x11.toByte(), 0x22.toByte(), 0x33.toByte(), 0x44.toByte()
|
||||||
|
)
|
||||||
|
|
||||||
|
private val AUTO_MARKER = byteArrayOf(
|
||||||
|
'G'.code.toByte(), 'J'.code.toByte()
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val AUTO_FORMAT_VERSION: Byte = 1
|
||||||
|
private const val FLAG_ENCRYPTED: Byte = 0x01
|
||||||
|
|
||||||
|
private fun ByteArray.startsWithZipSignature(): Boolean =
|
||||||
|
this.size >= 2 && this[0] == 0x50.toByte() && this[1] == 0x4B.toByte()
|
||||||
|
|
||||||
|
private fun ByteArray.hasAutoMagic(): Boolean =
|
||||||
|
this.size >= 4 &&
|
||||||
|
this[0] == AUTO_MAGIC[0] &&
|
||||||
|
this[1] == AUTO_MAGIC[1] &&
|
||||||
|
this[2] == AUTO_MAGIC[2] &&
|
||||||
|
this[3] == AUTO_MAGIC[3]
|
||||||
|
|
||||||
|
private fun ByteArray.hasNewAutoHeader(): Boolean =
|
||||||
|
this.size >= 8 &&
|
||||||
|
this.hasAutoMagic() &&
|
||||||
|
this[4] == AUTO_MARKER[0] &&
|
||||||
|
this[5] == AUTO_MARKER[1]
|
||||||
|
|
||||||
private fun getAutomaticBackupDocumentFiles(context: Context, create: Boolean = false): Pair<DocumentFile?, DocumentFile?> {
|
private fun getAutomaticBackupDocumentFiles(context: Context, create: Boolean = false): Pair<DocumentFile?, DocumentFile?> {
|
||||||
if(!Settings.instance.storage.isStorageMainValid(context))
|
if(!Settings.instance.storage.isStorageMainValid(context))
|
||||||
return Pair(null, null);
|
return Pair(null, null);
|
||||||
@@ -76,14 +103,13 @@ class StateBackup {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun requireLegacyBackupPassword(password: String): String {
|
||||||
private fun getAutomaticBackupPassword(customPassword: String? = null): String {
|
val pbytes = password.toByteArray()
|
||||||
val password = customPassword ?: Settings.instance.backup.autoBackupPassword ?: "";
|
if (pbytes.size < 4 || pbytes.size > 32)
|
||||||
val pbytes = password.toByteArray();
|
throw IllegalStateException("Password must be at least 4 bytes and smaller than 32 bytes")
|
||||||
if(pbytes.size < 4 || pbytes.size > 32)
|
return password
|
||||||
throw IllegalStateException("Automatic backup passwords should atleast be 4 character and smaller than 32");
|
|
||||||
return password;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hasAutomaticBackup(): Boolean {
|
fun hasAutomaticBackup(): Boolean {
|
||||||
val context = StateApp.instance.contextOrNull ?: return false;
|
val context = StateApp.instance.contextOrNull ?: return false;
|
||||||
if(!Settings.instance.storage.isStorageMainValid(context))
|
if(!Settings.instance.storage.isStorageMainValid(context))
|
||||||
@@ -106,8 +132,6 @@ class StateBackup {
|
|||||||
val data = export();
|
val data = export();
|
||||||
val zip = data.asZip();
|
val zip = data.asZip();
|
||||||
|
|
||||||
//Prepend some magic bytes to identify everything version 1 and up
|
|
||||||
val encryptedZip = byteArrayOf(0x11, 0x22, 0x33, 0x44, GPasswordEncryptionProvider.version.toByte()) + GPasswordEncryptionProvider.instance.encrypt(zip, getAutomaticBackupPassword());
|
|
||||||
if(!Settings.instance.storage.isStorageMainValid(context)) {
|
if(!Settings.instance.storage.isStorageMainValid(context)) {
|
||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
UIDialogs.toast("Missing permissions for auto-backup, please set the external general directory in settings");
|
UIDialogs.toast("Missing permissions for auto-backup, please set the external general directory in settings");
|
||||||
@@ -118,7 +142,8 @@ class StateBackup {
|
|||||||
val exportFile = backupFiles.first;
|
val exportFile = backupFiles.first;
|
||||||
if (exportFile?.exists() == true && backupFiles.second != null)
|
if (exportFile?.exists() == true && backupFiles.second != null)
|
||||||
exportFile.copyTo(context, backupFiles.second!!);
|
exportFile.copyTo(context, backupFiles.second!!);
|
||||||
exportFile!!.writeBytes(context, encryptedZip);
|
val backupBytes = AUTO_MAGIC + AUTO_MARKER + byteArrayOf(AUTO_FORMAT_VERSION, 0x00.toByte()) + zip
|
||||||
|
exportFile!!.writeBytes(context, backupBytes)
|
||||||
|
|
||||||
Settings.instance.backup.lastAutoBackupTime = OffsetDateTime.now(); //OffsetDateTime.now();
|
Settings.instance.backup.lastAutoBackupTime = OffsetDateTime.now(); //OffsetDateTime.now();
|
||||||
Settings.instance.save();
|
Settings.instance.save();
|
||||||
@@ -137,69 +162,105 @@ class StateBackup {
|
|||||||
|
|
||||||
//TODO: This goes has recently changed to use DocumentFiles and DocumentTree, and might need additional checks/edgecases covered.
|
//TODO: This goes has recently changed to use DocumentFiles and DocumentTree, and might need additional checks/edgecases covered.
|
||||||
fun restoreAutomaticBackup(context: Context, scope: CoroutineScope, password: String, ifExists: Boolean = false) {
|
fun restoreAutomaticBackup(context: Context, scope: CoroutineScope, password: String, ifExists: Boolean = false) {
|
||||||
if(ifExists && !hasAutomaticBackup()) {
|
if (ifExists && !hasAutomaticBackup()) {
|
||||||
Logger.i(TAG, "No AutoBackup exists, not restoring");
|
Logger.i(TAG, "No AutoBackup exists, not restoring")
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.i(TAG, "Starting AutoBackup restore");
|
Logger.i(TAG, "Starting AutoBackup restore")
|
||||||
synchronized(_autoBackupLock) {
|
var permissionRequest: Pair<IWithResultLauncher, android.net.Uri?>? = null
|
||||||
|
val backupBytesEncrypted: ByteArray? = synchronized(_autoBackupLock) {
|
||||||
|
val backupFiles = getAutomaticBackupDocumentFiles(StateApp.instance.context, false)
|
||||||
|
|
||||||
val backupFiles = getAutomaticBackupDocumentFiles(StateApp.instance.context, false);
|
fun read(doc: DocumentFile?): ByteArray? = doc?.readBytes(context)
|
||||||
try {
|
try {
|
||||||
if (backupFiles.first?.exists() != true)
|
if (backupFiles.first?.exists() != true)
|
||||||
throw IllegalStateException("Backup file does not exist");
|
throw IllegalStateException("Backup file does not exist")
|
||||||
|
|
||||||
val backupBytesEncrypted = backupFiles.first!!.readBytes(context) ?: throw IllegalStateException("Could not read stream of [${backupFiles.first?.uri}]");
|
read(backupFiles.first) ?: throw IllegalStateException("Could not read stream of [${backupFiles.first?.uri}]")
|
||||||
importEncryptedZipBytes(context, scope, backupBytesEncrypted, password);
|
} catch (ex: Throwable) {
|
||||||
Logger.i(TAG, "Finished AutoBackup restore");
|
if (ex is FileNotFoundException || ex is SecurityException) {
|
||||||
}
|
val activity = (StateApp.instance.activity as? IWithResultLauncher)
|
||||||
catch (exSec: FileNotFoundException) {
|
?: (if (StateApp.instance.isMainActive) StateApp.instance.contextOrNull as? IWithResultLauncher else null)
|
||||||
Logger.e(TAG, "Failed to access backup file", exSec);
|
|
||||||
val activity = if(StateApp.instance.activity != null)
|
if (activity != null) {
|
||||||
StateApp.instance.activity
|
permissionRequest = Pair(activity, backupFiles.first?.parentFile?.uri)
|
||||||
else if(StateApp.instance.isMainActive)
|
return@synchronized null
|
||||||
StateApp.instance.contextOrNull;
|
}
|
||||||
else null;
|
}
|
||||||
if(activity != null) {
|
|
||||||
if(activity is IWithResultLauncher)
|
// Otherwise, try the .old file
|
||||||
StateApp.instance.requestDirectoryAccess(activity, "Grayjay Backup Directory", "Allows restoring of a backup", backupFiles.first?.parentFile?.uri) {
|
if (backupFiles.second?.exists() == true) {
|
||||||
if(it != null) {
|
read(backupFiles.second) ?: throw IllegalStateException("Could not read stream of [${backupFiles.second?.uri}]")
|
||||||
val customFiles = StateBackup.getAutomaticBackupDocumentFiles(activity);
|
} else {
|
||||||
if(customFiles.first != null && customFiles.first!!.isFile && customFiles.first!!.exists() && customFiles.first!!.canRead())
|
throw ex
|
||||||
restoreAutomaticBackup(context, scope, password, ifExists);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (ex: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed main AutoBackup restore", ex)
|
|
||||||
if (backupFiles.second?.exists() != true)
|
|
||||||
throw ex;
|
|
||||||
|
|
||||||
val backupBytesEncrypted = backupFiles.second!!.readBytes(context) ?: throw IllegalStateException("Could not read stream of [${backupFiles.second?.uri}]");
|
|
||||||
importEncryptedZipBytes(context, scope, backupBytesEncrypted, password);
|
|
||||||
Logger.i(TAG, "Finished AutoBackup restore");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (backupBytesEncrypted == null && permissionRequest != null) {
|
||||||
|
val (activity, initialUri) = permissionRequest
|
||||||
|
StateApp.instance.requestDirectoryAccess(activity, "Grayjay Backup Directory", "Allows restoring of a backup", initialUri) { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
restoreAutomaticBackup(context, scope, password, ifExists)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
importEncryptedZipBytes(context, scope, backupBytesEncrypted!!, password)
|
||||||
|
Logger.i(TAG, "Finished AutoBackup restore")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun importEncryptedZipBytes(context: Context, scope: CoroutineScope, backupBytesEncrypted: ByteArray, password: String) {
|
private fun importEncryptedZipBytes(context: Context, scope: CoroutineScope, backupBytesEncrypted: ByteArray, password: String) {
|
||||||
val backupBytes: ByteArray;
|
if (backupBytesEncrypted.startsWithZipSignature()) {
|
||||||
//Check magic bytes indicating version 1 and up
|
importZipBytes(context, scope, backupBytesEncrypted)
|
||||||
if (backupBytesEncrypted[0] == 0x11.toByte() && backupBytesEncrypted[1] == 0x22.toByte() && backupBytesEncrypted[2] == 0x33.toByte() && backupBytesEncrypted[3] == 0x44.toByte()) {
|
return
|
||||||
val version = backupBytesEncrypted[4].toInt();
|
|
||||||
if (version != GPasswordEncryptionProvider.version) {
|
|
||||||
throw Exception("Invalid encryption version");
|
|
||||||
}
|
|
||||||
|
|
||||||
backupBytes = GPasswordEncryptionProvider.instance.decrypt(backupBytesEncrypted.sliceArray(IntRange(5, backupBytesEncrypted.size - 1)), getAutomaticBackupPassword(password))
|
|
||||||
} else {
|
|
||||||
//Else its a version 0
|
|
||||||
backupBytes = GPasswordEncryptionProviderV0(getAutomaticBackupPassword(password).padStart(32, '9')).decrypt(backupBytesEncrypted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
importZipBytes(context, scope, backupBytes);
|
// New unencrypted header (magic + "GJ" + format + flags)
|
||||||
|
if (backupBytesEncrypted.hasNewAutoHeader()) {
|
||||||
|
val formatVersion = backupBytesEncrypted[6].toInt()
|
||||||
|
val flags = backupBytesEncrypted[7].toInt()
|
||||||
|
var offset = 8
|
||||||
|
|
||||||
|
if (formatVersion != AUTO_FORMAT_VERSION.toInt()) {
|
||||||
|
throw IllegalStateException("Unsupported backup format version: $formatVersion")
|
||||||
|
}
|
||||||
|
|
||||||
|
val isEncrypted = (flags and FLAG_ENCRYPTED.toInt()) != 0
|
||||||
|
if (!isEncrypted) {
|
||||||
|
val zipBytes = backupBytesEncrypted.copyOfRange(offset, backupBytesEncrypted.size)
|
||||||
|
importZipBytes(context, scope, zipBytes)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
throw IllegalStateException("Encrypted backups with new header are not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old encrypted v1+ header (magic + providerVersion + ciphertext)
|
||||||
|
if (backupBytesEncrypted.hasAutoMagic()) {
|
||||||
|
if (backupBytesEncrypted.size < 6) {
|
||||||
|
throw IllegalStateException("Invalid backup: too small")
|
||||||
|
}
|
||||||
|
|
||||||
|
val version = backupBytesEncrypted[4].toInt()
|
||||||
|
if (version != GPasswordEncryptionProvider.version) {
|
||||||
|
throw Exception("Invalid encryption version")
|
||||||
|
}
|
||||||
|
|
||||||
|
val ciphertext = backupBytesEncrypted.copyOfRange(5, backupBytesEncrypted.size)
|
||||||
|
val plaintextZip = GPasswordEncryptionProvider.instance.decrypt(ciphertext, requireLegacyBackupPassword(password))
|
||||||
|
|
||||||
|
importZipBytes(context, scope, plaintextZip)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old encrypted v0 (no magic)
|
||||||
|
val plaintextZip = GPasswordEncryptionProviderV0(requireLegacyBackupPassword(password).padStart(32, '9')).decrypt(backupBytesEncrypted)
|
||||||
|
importZipBytes(context, scope, plaintextZip)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveExternalBackup(activity: IWithResultLauncher) {
|
fun saveExternalBackup(activity: IWithResultLauncher) {
|
||||||
@@ -234,6 +295,47 @@ class StateBackup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun requiresPasswordForAutomaticBackup(context: Context): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val files = getAutomaticBackupDocumentFiles(context, create = false)
|
||||||
|
|
||||||
|
// Prefer main, fallback to .old
|
||||||
|
val doc = when {
|
||||||
|
files.first?.exists() == true -> files.first
|
||||||
|
files.second?.exists() == true -> files.second
|
||||||
|
else -> return@withContext true // if nothing exists, keep old behavior
|
||||||
|
} ?: return@withContext true
|
||||||
|
|
||||||
|
val header = try {
|
||||||
|
context.contentResolver.openInputStream(doc.uri)?.use { input ->
|
||||||
|
val buf = ByteArray(16)
|
||||||
|
val n = input.read(buf)
|
||||||
|
if (n <= 0) ByteArray(0) else buf.copyOf(n)
|
||||||
|
} ?: return@withContext true
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
return@withContext true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw zip ("PK") => not encrypted
|
||||||
|
if (header.size >= 2 && header[0] == 0x50.toByte() && header[1] == 0x4B.toByte()) {
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
|
||||||
|
// New unencrypted header (magic + "GJ" + format + flags)
|
||||||
|
if (header.size >= 8 && header[0] == AUTO_MAGIC[0] && header[1] == AUTO_MAGIC[1] && header[2] == AUTO_MAGIC[2] && header[3] == AUTO_MAGIC[3] && header[4] == AUTO_MARKER[0] && header[5] == AUTO_MARKER[1]) {
|
||||||
|
val flags = header[7].toInt()
|
||||||
|
val isEncrypted = (flags and FLAG_ENCRYPTED.toInt()) != 0
|
||||||
|
return@withContext isEncrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old encrypted v1+ header (magic + providerVersion + ciphertext) => needs password
|
||||||
|
if (header.size >= 5 && header[0] == AUTO_MAGIC[0] && header[1] == AUTO_MAGIC[1] && header[2] == AUTO_MAGIC[2] && header[3] == AUTO_MAGIC[3]) {
|
||||||
|
return@withContext true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise assume legacy v0 encrypted (no magic) => needs password
|
||||||
|
return@withContext true
|
||||||
|
}
|
||||||
|
|
||||||
fun export(): ExportStructure {
|
fun export(): ExportStructure {
|
||||||
val exportInfo = mapOf(
|
val exportInfo = mapOf(
|
||||||
Pair("version", "1")
|
Pair("version", "1")
|
||||||
@@ -303,186 +405,172 @@ class StateBackup {
|
|||||||
var doEnablePlugins = false;
|
var doEnablePlugins = false;
|
||||||
var doImportStores = false;
|
var doImportStores = false;
|
||||||
Logger.i(TAG, "Starting import choices");
|
Logger.i(TAG, "Starting import choices");
|
||||||
UIDialogs.multiShowDialog(context, {
|
|
||||||
Logger.i(TAG, "Starting import");
|
|
||||||
if(!doImport)
|
|
||||||
return@multiShowDialog;
|
|
||||||
val enabledBefore = StatePlatform.instance.getEnabledClients().map { it.id };
|
|
||||||
|
|
||||||
val onConclusion = {
|
scope.launch(Dispatchers.Main) {
|
||||||
|
UIDialogs.multiShowDialog(context, {
|
||||||
|
Logger.i(TAG, "Starting import");
|
||||||
|
if (!doImport)
|
||||||
|
return@multiShowDialog;
|
||||||
|
val enabledBefore = StatePlatform.instance.getEnabledClients().map { it.id };
|
||||||
|
|
||||||
|
val onConclusion = {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
StatePlatform.instance.selectClients(*enabledBefore.toTypedArray());
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
UIDialogs.showDialog(context, R.drawable.ic_update_success_251dp, "Import has finished", null, null,0, UIDialogs.Action("Ok", {}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
//TODO: Probably restructure this to be less nested
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
StatePlatform.instance.selectClients(*enabledBefore.toTypedArray());
|
try {
|
||||||
|
if (doImportSettings && export.settings != null) {
|
||||||
withContext(Dispatchers.Main) {
|
Logger.i(TAG, "Importing settings");
|
||||||
UIDialogs.showDialog(context, R.drawable.ic_update_success_251dp,
|
try {
|
||||||
"Import has finished", null, null, 0, UIDialogs.Action("Ok", {}));
|
Settings.replace(export.settings);
|
||||||
}
|
} catch (ex: Throwable) {
|
||||||
}
|
UIDialogs.toast(
|
||||||
};
|
context,
|
||||||
//TODO: Probably restructure this to be less nested
|
"Failed to import settings\n(" + ex.message + ")"
|
||||||
scope.launch(Dispatchers.IO) {
|
);
|
||||||
try {
|
|
||||||
if (doImportSettings && export.settings != null) {
|
|
||||||
Logger.i(TAG, "Importing settings");
|
|
||||||
try {
|
|
||||||
Settings.replace(export.settings);
|
|
||||||
}
|
|
||||||
catch(ex: Throwable) {
|
|
||||||
UIDialogs.toast(context, "Failed to import settings\n(" + ex.message + ")");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val afterPluginInstalls = {
|
|
||||||
scope.launch(Dispatchers.IO) {
|
|
||||||
if (doEnablePlugins) {
|
|
||||||
val availableClients = StatePlatform.instance.getEnabledClients().toMutableList();
|
|
||||||
availableClients.addAll(StatePlatform.instance.getAvailableClients().filter { !availableClients.contains(it) });
|
|
||||||
|
|
||||||
Logger.i(TAG, "Import enabling plugins [${availableClients.map{it.name}.joinToString(", ")}]");
|
|
||||||
StatePlatform.instance.updateAvailableClients(context, false);
|
|
||||||
StatePlatform.instance.selectClients(*availableClients.map { it.id }.toTypedArray());
|
|
||||||
}
|
}
|
||||||
if(doImportPluginSettings) {
|
}
|
||||||
for(settings in export.pluginSettings) {
|
|
||||||
Logger.i(TAG, "Importing Plugin settings [${settings.key}]");
|
val afterPluginInstalls = {
|
||||||
StatePlugins.instance.setPluginSettings(settings.key, settings.value);
|
scope.launch(Dispatchers.IO) {
|
||||||
|
if (doEnablePlugins) {
|
||||||
|
val availableClients = StatePlatform.instance.getEnabledClients().toMutableList();
|
||||||
|
availableClients.addAll(StatePlatform.instance.getAvailableClients().filter { !availableClients.contains(it) });
|
||||||
|
Logger.i(TAG, "Import enabling plugins [${availableClients.map { it.name }.joinToString(", ")}]");
|
||||||
|
StatePlatform.instance.updateAvailableClients(context, false);
|
||||||
|
StatePlatform.instance.selectClients(*availableClients.map { it.id }.toTypedArray());
|
||||||
}
|
}
|
||||||
}
|
if (doImportPluginSettings) {
|
||||||
val toAwait = export.stores.map { it.key }.toMutableList();
|
for (settings in export.pluginSettings) {
|
||||||
if(doImportStores) {
|
Logger.i(TAG, "Importing Plugin settings [${settings.key}]");
|
||||||
for(store in export.stores) {
|
StatePlugins.instance.setPluginSettings(settings.key, settings.value);
|
||||||
Logger.i(TAG, "Importing store [${store.key}]");
|
}
|
||||||
if(store.key == "history") {
|
}
|
||||||
withContext(Dispatchers.Main) {
|
val toAwait = export.stores.map { it.key }.toMutableList();
|
||||||
UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import History", "Would you like to import history?", null, 0,
|
if (doImportStores) {
|
||||||
UIDialogs.Action("No", {
|
for (store in export.stores) {
|
||||||
}, UIDialogs.ActionStyle.NONE),
|
Logger.i(TAG, "Importing store [${store.key}]");
|
||||||
UIDialogs.Action("Yes", {
|
if (store.key == "history") {
|
||||||
for(historyStr in store.value) {
|
withContext(Dispatchers.Main) {
|
||||||
try {
|
UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import History", "Would you like to import history?", null, 0,
|
||||||
val histObj = HistoryVideo.fromReconString(historyStr) { url ->
|
UIDialogs.Action("No", {
|
||||||
return@fromReconString export.cache?.videos?.firstOrNull { it.url == url };
|
}, UIDialogs.ActionStyle.NONE),
|
||||||
|
UIDialogs.Action("Yes", {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
for (historyStr in store.value) {
|
||||||
|
try {
|
||||||
|
val histObj = HistoryVideo.fromReconString(historyStr) { url -> return@fromReconString export.cache?.videos?.firstOrNull { it.url == url }; }
|
||||||
|
val hist = StateHistory.instance.getHistoryByVideo(histObj.video, true, histObj.date);
|
||||||
|
if (hist != null)
|
||||||
|
StateHistory.instance.updateHistoryPosition(histObj.video, hist, true, histObj.position, histObj.date, false, histObj.playlistId);
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to import subscription group", ex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val hist = StateHistory.instance.getHistoryByVideo(histObj.video, true, histObj.date);
|
|
||||||
if(hist != null)
|
|
||||||
StateHistory.instance.updateHistoryPosition(histObj.video, hist, true, histObj.position, histObj.date, false, histObj.playlistId);
|
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
}, UIDialogs.ActionStyle.PRIMARY)
|
||||||
Logger.e(TAG, "Failed to import subscription group", ex);
|
)
|
||||||
|
}
|
||||||
|
} else if (store.key == "subscription_groups") {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import Subscription Groups", "Would you like to import subscription groups?\nExisting groups with the same id will be overridden!", null, 0,
|
||||||
|
UIDialogs.Action("No", {
|
||||||
|
}, UIDialogs.ActionStyle.NONE),
|
||||||
|
UIDialogs.Action("Yes", {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
for (groupStr in store.value) {
|
||||||
|
try {
|
||||||
|
val group = Json.decodeFromString<SubscriptionGroup>(groupStr);
|
||||||
|
val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id);
|
||||||
|
if (existing != null)
|
||||||
|
StateSubscriptionGroups.instance.deleteSubscriptionGroup(existing.id, false);
|
||||||
|
StateSubscriptionGroups.instance.updateSubscriptionGroup(group);
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to import subscription group", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}, UIDialogs.ActionStyle.PRIMARY)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val relevantStore = availableStores.find { it.name == store.key };
|
||||||
|
if (relevantStore == null) {
|
||||||
|
Logger.w(TAG, "Unknown store [${store.key}] import");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
UIDialogs.showImportDialog(context, relevantStore, store.key, store.value, export.cache) {
|
||||||
|
synchronized(toAwait) {
|
||||||
|
toAwait.remove(store.key);
|
||||||
|
if (toAwait.isEmpty())
|
||||||
|
onConclusion();
|
||||||
}
|
}
|
||||||
}, UIDialogs.ActionStyle.PRIMARY))
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else if(store.key == "subscription_groups") {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import Subscription Groups", "Would you like to import subscription groups?\nExisting groups with the same id will be overridden!", null, 0,
|
|
||||||
UIDialogs.Action("No", {
|
|
||||||
}, UIDialogs.ActionStyle.NONE),
|
|
||||||
UIDialogs.Action("Yes", {
|
|
||||||
for(groupStr in store.value) {
|
|
||||||
try {
|
|
||||||
val group = Json.decodeFromString<SubscriptionGroup>(groupStr);
|
|
||||||
val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id);
|
|
||||||
if(existing != null)
|
|
||||||
StateSubscriptionGroups.instance.deleteSubscriptionGroup(existing.id, false);
|
|
||||||
StateSubscriptionGroups.instance.updateSubscriptionGroup(group);
|
|
||||||
}
|
|
||||||
catch(ex: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to import subscription group", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, UIDialogs.ActionStyle.PRIMARY))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
val relevantStore = availableStores.find { it.name == store.key };
|
|
||||||
if (relevantStore == null) {
|
|
||||||
Logger.w(TAG, "Unknown store [${store.key}] import");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
UIDialogs.showImportDialog(context, relevantStore, store.key, store.value, export.cache) {
|
|
||||||
synchronized(toAwait) {
|
|
||||||
toAwait.remove(store.key);
|
|
||||||
if(toAwait.isEmpty())
|
|
||||||
onConclusion();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (doImportPlugins) {
|
if (doImportPlugins) {
|
||||||
Logger.i(TAG, "Importing plugins");
|
Logger.i(TAG, "Importing plugins");
|
||||||
StatePlugins.instance.installPlugins(context, scope, unknownPlugins.map { it.value }) {
|
StatePlugins.instance.installPlugins(context, scope, unknownPlugins.map { it.value }) {
|
||||||
|
afterPluginInstalls();
|
||||||
|
}
|
||||||
|
} else
|
||||||
afterPluginInstalls();
|
afterPluginInstalls();
|
||||||
}
|
} catch (ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Import failed", ex);
|
||||||
|
UIDialogs.showGeneralErrorDialog(context, "Import failed", ex);
|
||||||
}
|
}
|
||||||
else
|
|
||||||
afterPluginInstalls();
|
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
},
|
||||||
Logger.e(TAG, "Import failed", ex);
|
UIDialogs.Descriptor(R.drawable.ic_move_up, "Do you want to import data?", "Several dialogs will follow asking individual parts",
|
||||||
UIDialogs.showGeneralErrorDialog(context, "Import failed", ex);
|
"Settings: ${export.settings != null}\n" +
|
||||||
}
|
"Plugins: ${unknownPlugins.size}\n" +
|
||||||
}
|
"Plugin Settings: ${export.pluginSettings.size}\n" +
|
||||||
},
|
export.stores.map { "${it.key}: ${it.value.size}" }.joinToString("\n").trim(),
|
||||||
UIDialogs.Descriptor(R.drawable.ic_move_up,
|
1,
|
||||||
"Do you want to import data?",
|
UIDialogs.Action("Import", {
|
||||||
"Several dialogs will follow asking individual parts",
|
doImport = true;
|
||||||
"Settings: ${export.settings != null}\n" +
|
}, UIDialogs.ActionStyle.PRIMARY),
|
||||||
"Plugins: ${unknownPlugins.size}\n" +
|
UIDialogs.Action("Cancel", { doImport = false })
|
||||||
"Plugin Settings: ${export.pluginSettings.size}\n" +
|
),
|
||||||
export.stores.map { "${it.key}: ${it.value.size}" }.joinToString("\n").trim()
|
if (export.settings != null) UIDialogs.Descriptor(R.drawable.ic_settings, "Would you like to import settings", "These are the settings that configure how your app works", null, 0,
|
||||||
, 1,
|
UIDialogs.Action("Yes", {
|
||||||
UIDialogs.Action("Import", {
|
doImportSettings = true;
|
||||||
doImport = true;
|
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
||||||
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("Cancel", { doImport = false})
|
).withCondition { doImport } else null,
|
||||||
),
|
if (unknownPlugins.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_sources, "Would you like to import plugins?", "Your import contains the following plugins", unknownPlugins.map { it.value }.joinToString("\n"), 1,
|
||||||
if(export.settings != null) UIDialogs.Descriptor(R.drawable.ic_settings,
|
UIDialogs.Action("Yes", {
|
||||||
"Would you like to import settings",
|
doImportPlugins = true;
|
||||||
"These are the settings that configure how your app works",
|
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
||||||
null, 0,
|
).withCondition { doImport } else null,
|
||||||
UIDialogs.Action("Yes", {
|
if (export.pluginSettings.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_sources, "Would you like to import plugin settings?", null, null, 1,
|
||||||
doImportSettings = true;
|
UIDialogs.Action("Yes", {
|
||||||
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
doImportPluginSettings = true;
|
||||||
).withCondition { doImport } else null,
|
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
||||||
if(unknownPlugins.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_sources,
|
).withCondition { doImport } else null,
|
||||||
"Would you like to import plugins?",
|
UIDialogs.Descriptor(R.drawable.ic_sources, "Would you like to enable all plugins?", "Enabling all plugins ensures all required plugins are available during import", null, 0,
|
||||||
"Your import contains the following plugins",
|
UIDialogs.Action("Yes", {
|
||||||
unknownPlugins.map { it.value }.joinToString("\n"), 1,
|
doEnablePlugins = true;
|
||||||
UIDialogs.Action("Yes", {
|
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
||||||
doImportPlugins = true;
|
).withCondition { doImport },
|
||||||
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
if (export.stores.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_move_up, "Would you like to import stores", "Stores contain playlists, watch later, subscriptions, etc", null, 0,
|
||||||
).withCondition { doImport } else null,
|
UIDialogs.Action("Yes", {
|
||||||
if(export.pluginSettings.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_sources,
|
doImportStores = true;
|
||||||
"Would you like to import plugin settings?",
|
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
||||||
null, null, 1,
|
).withCondition { doImport } else null
|
||||||
UIDialogs.Action("Yes", {
|
);
|
||||||
doImportPluginSettings = true;
|
}
|
||||||
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
|
||||||
).withCondition { doImport } else null,
|
|
||||||
UIDialogs.Descriptor(R.drawable.ic_sources,
|
|
||||||
"Would you like to enable all plugins?",
|
|
||||||
"Enabling all plugins ensures all required plugins are available during import",
|
|
||||||
null, 0,
|
|
||||||
UIDialogs.Action("Yes", {
|
|
||||||
doEnablePlugins = true;
|
|
||||||
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
|
||||||
).withCondition { doImport },
|
|
||||||
if(export.stores.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_move_up,
|
|
||||||
"Would you like to import stores",
|
|
||||||
"Stores contain playlists, watch later, subscriptions, etc",
|
|
||||||
null, 0,
|
|
||||||
UIDialogs.Action("Yes", {
|
|
||||||
doImportStores = true;
|
|
||||||
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
|
||||||
).withCondition { doImport } else null
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun importTxt(context: MainActivity, text: String, allowFailure: Boolean = false): Boolean {
|
fun importTxt(context: MainActivity, text: String, allowFailure: Boolean = false): Boolean {
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ class StateDownloads {
|
|||||||
|
|
||||||
fun removeDownload(download: VideoDownload) {
|
fun removeDownload(download: VideoDownload) {
|
||||||
download.isCancelled = true;
|
download.isCancelled = true;
|
||||||
|
download.cleanup();
|
||||||
_downloading.delete(download);
|
_downloading.delete(download);
|
||||||
onDownloadsChanged.emit();
|
onDownloadsChanged.emit();
|
||||||
}
|
}
|
||||||
@@ -482,9 +483,9 @@ class StateDownloads {
|
|||||||
var localVideos = StateDownloads.instance.getDownloadedVideosPlaylist(playlistId);
|
var localVideos = StateDownloads.instance.getDownloadedVideosPlaylist(playlistId);
|
||||||
if(playlist != null) {
|
if(playlist != null) {
|
||||||
val missing = playlist.videos
|
val missing = playlist.videos
|
||||||
.filter { vid -> !localVideos.any { it.id.value == null || it.id.value == vid.id.value } }
|
.filter { vid -> !localVideos.any { it.id.value == null || it.id.value == vid.id.value } }
|
||||||
.map { getCachedVideo(it.id) }
|
.map { getCachedVideo(it.id) }
|
||||||
.filterNotNull();
|
.filterNotNull();
|
||||||
if(missing.size > 0)
|
if(missing.size > 0)
|
||||||
localVideos = localVideos + missing;
|
localVideos = localVideos + missing;
|
||||||
};
|
};
|
||||||
@@ -499,7 +500,6 @@ class StateDownloads {
|
|||||||
for (video in localVideos) {
|
for (video in localVideos) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
it.setText("Exporting videos...(${i}/${localVideos.size})");
|
it.setText("Exporting videos...(${i}/${localVideos.size})");
|
||||||
//it.setProgress(i.toDouble() / localVideos.size);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.futo.platformplayer.states
|
package com.futo.platformplayer.states
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.provider.MediaStore.Audio.Artists
|
import android.provider.MediaStore.Audio.Artists
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
@@ -154,34 +156,101 @@ class StateLibrary {
|
|||||||
fun getArtist(id: Long): Artist? {
|
fun getArtist(id: Long): Artist? {
|
||||||
return Artist.getArtist(id);
|
return Artist.getArtist(id);
|
||||||
}
|
}
|
||||||
|
fun getVideos(
|
||||||
|
buckets: List<String>? = null,
|
||||||
|
pageSize: Int = 20
|
||||||
|
): IPager<IPlatformContent> {
|
||||||
|
val resolver = StateApp.instance.contextOrNull?.contentResolver ?: return EmptyPager()
|
||||||
|
val selection: String?
|
||||||
|
val selectionArgs: Array<String>?
|
||||||
|
|
||||||
fun getVideos(buckets: List<String>? = null): IPager<IPlatformContent> {
|
if (!buckets.isNullOrEmpty()) {
|
||||||
var query = if(buckets != null) "${MediaStore.Video.Media.BUCKET_DISPLAY_NAME} IN " + "(" + buckets.map { "'${it}'" }.joinToString(",") + ")" else null;
|
val placeholders = buckets.joinToString(",") { "?" }
|
||||||
val cursor = StateApp.instance.contextOrNull?.contentResolver?.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, PROJECTION_VIDEO,
|
selection = "${MediaStore.Video.Media.BUCKET_DISPLAY_NAME} IN ($placeholders)"
|
||||||
query,
|
selectionArgs = buckets.toTypedArray()
|
||||||
null,
|
} else {
|
||||||
MediaStore.Video.Media.DATE_ADDED + " DESC") ?: return EmptyPager();
|
selection = null
|
||||||
|
selectionArgs = null
|
||||||
|
}
|
||||||
|
|
||||||
//Ongoing usage of cursor..todo disposal
|
val collectionUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
//return cursor.use {
|
MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||||
cursor.moveToFirst();
|
} else {
|
||||||
val list = mutableListOf<IPlatformVideo>()
|
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||||
while(!cursor.isAfterLast && list.size < 10) {
|
}
|
||||||
list.add(videoFromCursor(cursor));
|
|
||||||
cursor.moveToNext();
|
var nextPageIndex = 0
|
||||||
|
fun loadPage(pageIndex: Int): List<IPlatformContent> {
|
||||||
|
Logger.i(TAG, "loadPage $pageIndex")
|
||||||
|
val offset = pageIndex * pageSize
|
||||||
|
|
||||||
|
val queryArgs = Bundle().apply {
|
||||||
|
selection?.let {
|
||||||
|
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, it)
|
||||||
|
}
|
||||||
|
selectionArgs?.let {
|
||||||
|
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, it)
|
||||||
|
}
|
||||||
|
|
||||||
|
putStringArray(
|
||||||
|
ContentResolver.QUERY_ARG_SORT_COLUMNS,
|
||||||
|
arrayOf(
|
||||||
|
MediaStore.Video.Media.DATE_ADDED,
|
||||||
|
MediaStore.Video.Media._ID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
putInt(
|
||||||
|
ContentResolver.QUERY_ARG_SORT_DIRECTION,
|
||||||
|
ContentResolver.QUERY_SORT_DIRECTION_DESCENDING
|
||||||
|
)
|
||||||
|
|
||||||
|
putInt(ContentResolver.QUERY_ARG_LIMIT, pageSize)
|
||||||
|
putInt(ContentResolver.QUERY_ARG_OFFSET, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
return AdhocPager<IPlatformContent>({
|
val cursor = resolver.query(
|
||||||
val list = mutableListOf<IPlatformContent>()
|
collectionUri,
|
||||||
while(!cursor.isAfterLast && list.size < 10) {
|
PROJECTION_VIDEO,
|
||||||
list.add(videoFromCursor(cursor));
|
queryArgs,
|
||||||
cursor.moveToNext();
|
null
|
||||||
|
)
|
||||||
|
|
||||||
|
if (cursor == null) {
|
||||||
|
Logger.i(TAG, "loadPage $pageIndex null, returning empty list")
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor.use { c ->
|
||||||
|
if (!c.moveToFirst()) {
|
||||||
|
Logger.i(TAG, "loadPage $pageIndex moveToFirst failed, returning empty list")
|
||||||
|
return emptyList()
|
||||||
}
|
}
|
||||||
Logger.i(TAG, "Videos nextPage: ${list.size}")
|
|
||||||
return@AdhocPager list;
|
val list = ArrayList<IPlatformContent>(pageSize)
|
||||||
}, list);
|
do {
|
||||||
//}
|
list.add(videoFromCursor(c))
|
||||||
|
} while (c.moveToNext() && list.size < pageSize)
|
||||||
|
|
||||||
|
Logger.i(TAG, "loadPage $pageIndex found ${list.size} items")
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val firstPage = loadPage(0)
|
||||||
|
if (firstPage.isEmpty()) {
|
||||||
|
return EmptyPager()
|
||||||
|
}
|
||||||
|
nextPageIndex = 1
|
||||||
|
|
||||||
|
return AdhocPager<IPlatformContent>({
|
||||||
|
val page = loadPage(nextPageIndex)
|
||||||
|
nextPageIndex++
|
||||||
|
|
||||||
|
Logger.i(TAG, "loadPage nextPage: ${page.size}")
|
||||||
|
page
|
||||||
|
}, firstPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getRecentVideos(buckets: List<String>? = null, count: Int = 20): List<IPlatformVideo> {
|
fun getRecentVideos(buckets: List<String>? = null, count: Int = 20): List<IPlatformVideo> {
|
||||||
val videoPager = getVideos(buckets);
|
val videoPager = getVideos(buckets);
|
||||||
val items = mutableListOf<IPlatformVideo>();
|
val items = mutableListOf<IPlatformVideo>();
|
||||||
@@ -193,48 +262,80 @@ class StateLibrary {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
private var _cacheBucketNames: List<Bucket>? = null;
|
@Volatile
|
||||||
fun getVideoBucketNames(): List<Bucket> {
|
private var _cachedVideoBuckets: List<Bucket>? = null
|
||||||
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
|
private val _bucketCacheLock = Any()
|
||||||
return listOf();
|
|
||||||
if(_cacheBucketNames != null)
|
|
||||||
return _cacheBucketNames ?: listOf();
|
|
||||||
try {
|
|
||||||
val cur: Cursor = StateApp.instance.contextOrNull?.contentResolver?.query(
|
|
||||||
MediaStore.Video.Media.EXTERNAL_CONTENT_URI, arrayOf(
|
|
||||||
MediaStore.Video.Media.BUCKET_ID,
|
|
||||||
MediaStore.Video.Media.BUCKET_DISPLAY_NAME,
|
|
||||||
), null, null, null
|
|
||||||
) ?: return listOf();
|
|
||||||
|
|
||||||
return cur.use {
|
fun getVideoBucketNames(forceRefresh: Boolean = false): List<Bucket> {
|
||||||
val buckets = mutableListOf<Bucket>();
|
if (!forceRefresh) {
|
||||||
val list = HashSet<Long>();
|
_cachedVideoBuckets?.let { return it }
|
||||||
if (cur.moveToFirst()) {
|
}
|
||||||
var id: Long;
|
|
||||||
var bucket: String
|
val resolver = StateApp.instance.contextOrNull?.contentResolver
|
||||||
do {
|
?: return emptyList()
|
||||||
try {
|
|
||||||
id = cur.getLong(0);
|
val projection = arrayOf(
|
||||||
bucket = cur.getStringOrNull(1) ?: continue;
|
MediaStore.Video.VideoColumns.BUCKET_ID,
|
||||||
if (!list.contains(id)) {
|
MediaStore.Video.VideoColumns.BUCKET_DISPLAY_NAME
|
||||||
list.add(id);
|
)
|
||||||
buckets.add(Bucket(id, bucket));
|
|
||||||
}
|
val sortOrder = "${MediaStore.Video.VideoColumns.BUCKET_DISPLAY_NAME} COLLATE NOCASE ASC"
|
||||||
} catch (ex: Throwable) {
|
val loadedBuckets: List<Bucket> = try {
|
||||||
Logger.e(TAG, "Failed to parse bucket due to ${ex.message}", ex);
|
resolver.query(
|
||||||
}
|
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
|
||||||
} while (cur.moveToNext())
|
projection,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
sortOrder
|
||||||
|
)?.use { cursor ->
|
||||||
|
if (!cursor.moveToFirst()) {
|
||||||
|
return@use emptyList<Bucket>()
|
||||||
}
|
}
|
||||||
_cacheBucketNames = buckets.toList()
|
|
||||||
return@use _cacheBucketNames ?: listOf();
|
val idxId = cursor.getColumnIndexOrThrow(MediaStore.Video.VideoColumns.BUCKET_ID)
|
||||||
|
val idxName = cursor.getColumnIndexOrThrow(MediaStore.Video.VideoColumns.BUCKET_DISPLAY_NAME)
|
||||||
|
val seenIds = HashSet<Long>()
|
||||||
|
val buckets = ArrayList<Bucket>()
|
||||||
|
|
||||||
|
do {
|
||||||
|
try {
|
||||||
|
val id = cursor.getLong(idxId)
|
||||||
|
if (!seenIds.add(id)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val name = cursor.getStringOrNull(idxName) ?: continue
|
||||||
|
buckets.add(Bucket(id, name))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to parse video bucket row: ${e.message}", e)
|
||||||
|
}
|
||||||
|
} while (cursor.moveToNext())
|
||||||
|
|
||||||
|
buckets
|
||||||
|
} ?: emptyList()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Buckets loading failed, returning empty: ${e.message}", e)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadedBuckets.isEmpty()) {
|
||||||
|
if (!forceRefresh) {
|
||||||
|
_cachedVideoBuckets?.let { return it }
|
||||||
}
|
}
|
||||||
|
return emptyList()
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
|
||||||
Logger.e(TAG, "Buckets loading failed, returning empty");
|
synchronized(_bucketCacheLock) {
|
||||||
return listOf();
|
if (!forceRefresh) {
|
||||||
|
_cachedVideoBuckets?.let { return it }
|
||||||
|
}
|
||||||
|
_cachedVideoBuckets = loadedBuckets
|
||||||
|
return loadedBuckets
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fun invalidateVideoBucketNamesCache() {
|
||||||
|
_cachedVideoBuckets = null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -243,7 +344,8 @@ class StateLibrary {
|
|||||||
MediaStore.Video.Media.DISPLAY_NAME,
|
MediaStore.Video.Media.DISPLAY_NAME,
|
||||||
MediaStore.Video.Media.DATE_ADDED,
|
MediaStore.Video.Media.DATE_ADDED,
|
||||||
MediaStore.Video.Media.MIME_TYPE,
|
MediaStore.Video.Media.MIME_TYPE,
|
||||||
MediaStore.Video.Media.BUCKET_DISPLAY_NAME
|
MediaStore.Video.Media.BUCKET_DISPLAY_NAME,
|
||||||
|
MediaStore.Video.Media.DURATION
|
||||||
);
|
);
|
||||||
val PROJECTION_MEDIA = arrayOf(
|
val PROJECTION_MEDIA = arrayOf(
|
||||||
MediaStore.Audio.Media._ID, //0
|
MediaStore.Audio.Media._ID, //0
|
||||||
@@ -386,9 +488,10 @@ class StateLibrary {
|
|||||||
"";
|
"";
|
||||||
|
|
||||||
|
|
||||||
val albumContentUrl = if(albumId > 0)
|
val albumArtBase = Uri.parse("content://media/external/audio/albumart")
|
||||||
ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, albumId)?.toString()
|
val albumContentUrl = if (albumId > 0)
|
||||||
else null;
|
ContentUris.withAppendedId(albumArtBase, albumId).toString()
|
||||||
|
else null
|
||||||
|
|
||||||
val dateObj = if(date > 0)
|
val dateObj = if(date > 0)
|
||||||
OffsetDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneOffset.UTC)
|
OffsetDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneOffset.UTC)
|
||||||
@@ -414,6 +517,8 @@ class StateLibrary {
|
|||||||
val date = cursor.getLong(2);
|
val date = cursor.getLong(2);
|
||||||
val contentType = cursor.getString(3);
|
val contentType = cursor.getString(3);
|
||||||
val category = cursor.getString(4);
|
val category = cursor.getString(4);
|
||||||
|
val durationMs = cursor.getLong(5)
|
||||||
|
val duration = if (durationMs > 0) durationMs / 1000 else -1
|
||||||
|
|
||||||
val idLong = id.toLongOrNull();
|
val idLong = id.toLongOrNull();
|
||||||
val contentUrl = if(idLong != null )
|
val contentUrl = if(idLong != null )
|
||||||
@@ -433,7 +538,7 @@ class StateLibrary {
|
|||||||
PlatformID("FILE", contentUrl, null, 0, -1),
|
PlatformID("FILE", contentUrl, null, 0, -1),
|
||||||
displayName, Thumbnails(arrayOf(
|
displayName, Thumbnails(arrayOf(
|
||||||
Thumbnail(contentUrl, 0)
|
Thumbnail(contentUrl, 0)
|
||||||
)), authorObj, contentUrl, -1, contentType, dateObj);
|
)), authorObj, contentUrl, duration, contentType, dateObj);
|
||||||
}
|
}
|
||||||
|
|
||||||
private var _instance : StateLibrary? = null;
|
private var _instance : StateLibrary? = null;
|
||||||
@@ -521,11 +626,12 @@ class Artist {
|
|||||||
val numTracks = cursor.getInt(2);
|
val numTracks = cursor.getInt(2);
|
||||||
val numAlbums = cursor.getInt(3);
|
val numAlbums = cursor.getInt(3);
|
||||||
|
|
||||||
val idLong = id.toLongOrNull();
|
val idLong = id.toLongOrNull()
|
||||||
val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null;
|
val uri = if (idLong != null)
|
||||||
|
ContentUris.withAppendedId(MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI, idLong)
|
||||||
|
else null
|
||||||
|
|
||||||
return Artist(artist, numTracks, numAlbums, null, id, uri?.toString());
|
return Artist(artist, numTracks, numAlbums, null, id, uri?.toString()) }
|
||||||
}
|
|
||||||
|
|
||||||
fun getArtist(id: Long): Artist? {
|
fun getArtist(id: Long): Artist? {
|
||||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||||
@@ -629,9 +735,10 @@ class Album {
|
|||||||
val numTracks = cursor.getInt(2);
|
val numTracks = cursor.getInt(2);
|
||||||
val artist = cursor.getString(3);
|
val artist = cursor.getString(3);
|
||||||
|
|
||||||
val idLong = id.toLongOrNull();
|
val idLong = id.toLongOrNull()
|
||||||
val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null;
|
val albumArtBase = Uri.parse("content://media/external/audio/albumart")
|
||||||
return Album(album, numTracks, artist, id, uri?.toString());
|
val uri = if (idLong != null) ContentUris.withAppendedId(albumArtBase, idLong) else null
|
||||||
|
return Album(album, numTracks, artist, id, uri?.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAlbumTracks(albumId: Long): List<IPlatformVideo> {
|
fun getAlbumTracks(albumId: Long): List<IPlatformVideo> {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import android.graphics.drawable.Drawable
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.bumptech.glide.request.target.CustomTarget
|
import com.bumptech.glide.request.target.CustomTarget
|
||||||
import com.bumptech.glide.request.transition.Transition
|
import com.bumptech.glide.request.transition.Transition
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
@@ -22,6 +23,7 @@ import com.futo.platformplayer.serializers.PlatformContentSerializer
|
|||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
import com.futo.platformplayer.toHumanNowDiffString
|
import com.futo.platformplayer.toHumanNowDiffString
|
||||||
import com.futo.platformplayer.toHumanNowDiffStringMinDay
|
import com.futo.platformplayer.toHumanNowDiffStringMinDay
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
class StateNotifications {
|
class StateNotifications {
|
||||||
@@ -96,6 +98,7 @@ class StateNotifications {
|
|||||||
if(thumbnail != null)
|
if(thumbnail != null)
|
||||||
Glide.with(context).asBitmap()
|
Glide.with(context).asBitmap()
|
||||||
.load(thumbnail)
|
.load(thumbnail)
|
||||||
|
.withMaxSizePx()
|
||||||
.into(object: CustomTarget<Bitmap>() {
|
.into(object: CustomTarget<Bitmap>() {
|
||||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||||
notifyNewContent(context, manager, notificationChannel, id, content, resource);
|
notifyNewContent(context, manager, notificationChannel, id, content, resource);
|
||||||
|
|||||||
@@ -18,7 +18,14 @@ class StatePayment : PaymentState(if(!isTestingPayment) VERIFICATION_PUBLIC_KEY
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val VERIFICATION_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzJqqETLa42xw4AfbNOLQolMdMiGgg8DAC4RXEcH4/gytLhaqp1XsjiiMkADi1C7sDtGj6kOuAuQkqXQKpZ2dJSZsO+GPyop6DmgfAM6MQgOgFUpwsb3Lt3SvskJcls8MeOC+jg+GjjcuJI8qOfYevj4/7wAOpqzAwocTYnJivlK5nrC+qNtUC2HZX93OVu69aU5yvA1SQe9GiiU7vBld+CbzHxTcABCK/THu/BpLtGx0M7W3HNMKK1Z79dopCL9ZZWbWdkGDY8Zf39Gn/WVrs5elBvPzU+AfNYty77vx2r+sKgyohlbz4KVYpnw8HfawKcwuRE/GUyD3F2hUcXy8dQIDAQAB";
|
private val VERIFICATION_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzJqqETLa42xw4AfbNOLQ" +
|
||||||
|
"olMdMiGgg8DAC4RXEcH4/gytLhaqp1XsjiiMkADi1C7sDtGj6kOuAuQkqXQKpZ2d" +
|
||||||
|
"JSZsO+GPyop6DmgfAM6MQgOgFUpwsb3Lt3SvskJcls8MeOC+jg+GjjcuJI8qOfYe" +
|
||||||
|
"vj4/7wAOpqzAwocTYnJivlK5nrC+qNtUC2HZX93OVu69aU5yvA1SQe9GiiU7vBld" +
|
||||||
|
"+CbzHxTcABCK/THu/BpLtGx0M7W3HNMKK1Z79dopCL9ZZWbWdkGDY8Zf39Gn/WVr" +
|
||||||
|
"s5elBvPzU+AfNYty77vx2r+sKgyohlbz4KVYpnw8HfawKcwuRE/GUyD3F2hUcXy8" +
|
||||||
|
"dQIDAQAB"
|
||||||
|
|
||||||
private val VERIFICATION_PUBLIC_KEY_TESTING = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqyDuxsRtD5gmBoLCNoZa" +
|
private val VERIFICATION_PUBLIC_KEY_TESTING = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqyDuxsRtD5gmBoLCNoZa" +
|
||||||
"XSRTwyUxgzcPHzLZkvomXVSQqzD+3aOKngcTKAZ83rm4GvoyMlBukxQMLShannSx" +
|
"XSRTwyUxgzcPHzLZkvomXVSQqzD+3aOKngcTKAZ83rm4GvoyMlBukxQMLShannSx" +
|
||||||
"k8GQGTCT7VStQKNc4lKVER5ASB6aEaypaFMIYI3rXN1xLF1LqY/j7cu5GgMsvAuU" +
|
"k8GQGTCT7VStQKNc4lKVER5ASB6aEaypaFMIYI3rXN1xLF1LqY/j7cu5GgMsvAuU" +
|
||||||
@@ -34,4 +41,4 @@ class StatePayment : PaymentState(if(!isTestingPayment) VERIFICATION_PUBLIC_KEY
|
|||||||
return _instance!!;
|
return _instance!!;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
package com.futo.platformplayer.states
|
package com.futo.platformplayer.states
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.exoplayer.DefaultLoadControl
|
import androidx.media3.exoplayer.DefaultLoadControl
|
||||||
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.Renderer
|
||||||
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
|
import androidx.media3.exoplayer.text.TextOutput
|
||||||
|
import androidx.media3.exoplayer.text.TextRenderer
|
||||||
import androidx.media3.exoplayer.upstream.DefaultAllocator
|
import androidx.media3.exoplayer.upstream.DefaultAllocator
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||||
@@ -21,8 +26,10 @@ import com.futo.platformplayer.logging.Logger
|
|||||||
import com.futo.platformplayer.models.Playlist
|
import com.futo.platformplayer.models.Playlist
|
||||||
import com.futo.platformplayer.services.MediaPlaybackService
|
import com.futo.platformplayer.services.MediaPlaybackService
|
||||||
import com.futo.platformplayer.video.PlayerManager
|
import com.futo.platformplayer.video.PlayerManager
|
||||||
|
import com.google.common.collect.Iterables
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
|
||||||
/***
|
/***
|
||||||
* Used to keep track of queue and other player related stuff
|
* Used to keep track of queue and other player related stuff
|
||||||
*/
|
*/
|
||||||
@@ -240,17 +247,29 @@ class StatePlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun createShuffledQueue() {
|
private fun createShuffledQueue() {
|
||||||
val currentItem = getCurrentQueueItem();
|
if (_queue.isEmpty()) {
|
||||||
if (_queuePosition == -1 || currentItem == null) {
|
_queueShuffled = mutableListOf()
|
||||||
_queueShuffled = _queue.shuffled().toMutableList()
|
return
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val nextItems = _queue.subList(Math.min(_queuePosition + 1, _queue.size - 1), _queue.size).shuffled();
|
val currentItem = getCurrentQueueItem()
|
||||||
val previousItems = _queue.subList(0, _queuePosition).shuffled();
|
if (currentItem == null || _queuePosition !in _queue.indices) {
|
||||||
_queueShuffled = (previousItems + currentItem + nextItems).toMutableList();
|
_queueShuffled = _queue.shuffled().toMutableList()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val previousItems = _queue
|
||||||
|
.take(_queuePosition)
|
||||||
|
.shuffled()
|
||||||
|
|
||||||
|
val nextItems = _queue
|
||||||
|
.drop(_queuePosition + 1)
|
||||||
|
.shuffled()
|
||||||
|
|
||||||
|
_queueShuffled = (previousItems + currentItem + nextItems).toMutableList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun addToShuffledQueue(video: IPlatformVideo) {
|
private fun addToShuffledQueue(video: IPlatformVideo) {
|
||||||
val isLastVideo = _queuePosition + 1 >= _queue.size;
|
val isLastVideo = _queuePosition + 1 >= _queue.size;
|
||||||
if (isLastVideo) {
|
if (isLastVideo) {
|
||||||
@@ -662,6 +681,30 @@ class StatePlayer {
|
|||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
private fun createExoPlayer(context : Context): ExoPlayer {
|
private fun createExoPlayer(context : Context): ExoPlayer {
|
||||||
return ExoPlayer.Builder(context)
|
return ExoPlayer.Builder(context)
|
||||||
|
.setRenderersFactory(
|
||||||
|
object : DefaultRenderersFactory(context) {
|
||||||
|
override fun buildTextRenderers(
|
||||||
|
context: Context,
|
||||||
|
output: TextOutput,
|
||||||
|
outputLooper: Looper,
|
||||||
|
extensionRendererMode: Int,
|
||||||
|
out: java.util.ArrayList<Renderer>
|
||||||
|
) {
|
||||||
|
super.buildTextRenderers(
|
||||||
|
context,
|
||||||
|
output,
|
||||||
|
outputLooper,
|
||||||
|
extensionRendererMode,
|
||||||
|
out
|
||||||
|
)
|
||||||
|
(Iterables.getLast<Renderer?>(out) as TextRenderer)
|
||||||
|
.experimentalSetLegacyDecodingEnabled(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.setMediaSourceFactory(
|
||||||
|
DefaultMediaSourceFactory(context)
|
||||||
|
.experimentalParseSubtitlesDuringExtraction(false)
|
||||||
|
)
|
||||||
.setLoadControl(
|
.setLoadControl(
|
||||||
DefaultLoadControl.Builder()
|
DefaultLoadControl.Builder()
|
||||||
.setAllocator(DefaultAllocator(true, BUFFER_SIZE))
|
.setAllocator(DefaultAllocator(true, BUFFER_SIZE))
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ class StatePlugins {
|
|||||||
_updatesAvailableMap = updatesAvailableFor
|
_updatesAvailableMap = updatesAvailableFor
|
||||||
return@withContext configs;
|
return@withContext configs;
|
||||||
}
|
}
|
||||||
private suspend fun checkForUpdates(c: SourcePluginConfig): SourcePluginConfig? = withContext(Dispatchers.IO) {
|
suspend fun checkForUpdates(c: SourcePluginConfig): SourcePluginConfig? = withContext(Dispatchers.IO) {
|
||||||
val sourceUrl = c.sourceUrl ?: return@withContext null;
|
val sourceUrl = c.sourceUrl ?: return@withContext null;
|
||||||
|
|
||||||
Logger.i(TAG, "Check for source updates '${c.name}'.");
|
Logger.i(TAG, "Check for source updates '${c.name}'.");
|
||||||
@@ -169,6 +169,9 @@ class StatePlugins {
|
|||||||
return false;
|
return false;
|
||||||
|
|
||||||
LoginFragment.showLogin(config) {//LoginActivity.showLogin(context, config) {
|
LoginFragment.showLogin(config) {//LoginActivity.showLogin(context, config) {
|
||||||
|
|
||||||
|
if(it == null)
|
||||||
|
return@showLogin;
|
||||||
try {
|
try {
|
||||||
StatePlugins.instance.setPluginAuth(config.id, it);
|
StatePlugins.instance.setPluginAuth(config.id, it);
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
|
|||||||
@@ -15,146 +15,6 @@ import java.io.InputStream
|
|||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
|
||||||
class StateUpdate {
|
class StateUpdate {
|
||||||
private var _backgroundUpdateFinished = false;
|
|
||||||
private var _gettingOrDownloadingLastApk = false;
|
|
||||||
private var _shouldBackgroundUpdate = false;
|
|
||||||
private val _lockObject = Object();
|
|
||||||
|
|
||||||
private fun getOrDownloadLastApkFile(filesDir: File): File? {
|
|
||||||
try {
|
|
||||||
Logger.i(TAG, "Started getting or downloading latest APK file.");
|
|
||||||
|
|
||||||
if (!_shouldBackgroundUpdate) {
|
|
||||||
Logger.i(TAG, "Update download cancelled 1.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Started background update download.");
|
|
||||||
val client = ManagedHttpClient();
|
|
||||||
val latestVersion = downloadVersionCode(client);
|
|
||||||
if (!_shouldBackgroundUpdate) {
|
|
||||||
Logger.i(TAG, "Update download cancelled 2.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (latestVersion != null) {
|
|
||||||
val currentVersion = BuildConfig.VERSION_CODE;
|
|
||||||
Logger.i(TAG, "Current version ${currentVersion} latest version ${latestVersion}.");
|
|
||||||
|
|
||||||
if (latestVersion <= currentVersion) {
|
|
||||||
Logger.i(TAG, "Already up to date.");
|
|
||||||
_backgroundUpdateFinished = true;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
val outputDirectory = File(filesDir, "autoupdate");
|
|
||||||
if (!outputDirectory.exists()) {
|
|
||||||
outputDirectory.mkdirs();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_shouldBackgroundUpdate) {
|
|
||||||
Logger.i(TAG, "Update download cancelled 3.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
val apkOutputFile = File(outputDirectory, "last_version.apk");
|
|
||||||
val versionOutputFile = File(outputDirectory, "last_version.txt");
|
|
||||||
|
|
||||||
var cachedVersionInvalid = false;
|
|
||||||
if (!versionOutputFile.exists() || !apkOutputFile.exists()) {
|
|
||||||
Logger.i(TAG, "No downloaded version exists.");
|
|
||||||
cachedVersionInvalid = true;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
val downloadedVersion = versionOutputFile.readText().toInt();
|
|
||||||
Logger.i(TAG, "Downloaded version is $downloadedVersion.");
|
|
||||||
if (downloadedVersion != latestVersion) {
|
|
||||||
Logger.i(TAG, "Downloaded version is not newest version.");
|
|
||||||
cachedVersionInvalid = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch(ex: Throwable) {
|
|
||||||
Logger.w(TAG, "Deleted version file as it was inaccessible");
|
|
||||||
versionOutputFile.delete();
|
|
||||||
cachedVersionInvalid = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_shouldBackgroundUpdate) {
|
|
||||||
Logger.i(TAG, "Update download cancelled 4.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cachedVersionInvalid) {
|
|
||||||
Logger.i(TAG, "Downloading new APK to '${apkOutputFile.path}'...");
|
|
||||||
downloadApkToFile(client, apkOutputFile) { !_shouldBackgroundUpdate };
|
|
||||||
versionOutputFile.writeText(latestVersion.toString());
|
|
||||||
|
|
||||||
Logger.i(TAG, "Downloaded APK to '${apkOutputFile.path}'.");
|
|
||||||
} else {
|
|
||||||
Logger.i(TAG, "Latest APK is already downloaded in '${apkOutputFile.path}'...");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_shouldBackgroundUpdate) {
|
|
||||||
Logger.i(TAG, "Update download cancelled 5.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return apkOutputFile;
|
|
||||||
} else {
|
|
||||||
Logger.w(TAG, "Failed to retrieve version from version URL.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to download APK.", e);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
_gettingOrDownloadingLastApk = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setShouldBackgroundUpdate(shouldBackgroundUpdate: Boolean) {
|
|
||||||
synchronized (_lockObject) {
|
|
||||||
if (_backgroundUpdateFinished) {
|
|
||||||
_shouldBackgroundUpdate = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_shouldBackgroundUpdate = shouldBackgroundUpdate;
|
|
||||||
if (shouldBackgroundUpdate && !_gettingOrDownloadingLastApk) {
|
|
||||||
Logger.i(TAG, "Auto Updating in Background");
|
|
||||||
|
|
||||||
_gettingOrDownloadingLastApk = true;
|
|
||||||
StateApp.withContext { context ->
|
|
||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val file = getOrDownloadLastApkFile(context.filesDir);
|
|
||||||
if (file == null) {
|
|
||||||
Logger.i(TAG, "Failed to get or download update.");
|
|
||||||
return@launch;
|
|
||||||
}
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
try {
|
|
||||||
context.let { c ->
|
|
||||||
_backgroundUpdateFinished = true;
|
|
||||||
UIDialogs.showInstallDownloadedUpdateDialog(c, file);
|
|
||||||
};
|
|
||||||
Logger.i(TAG, "Showing install dialog for '${file.path}'.");
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
context.let { c -> UIDialogs.toast(c, "Failed to show update dialog"); };
|
|
||||||
Logger.w(TAG, "Error occurred in update dialog.", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to get last downloaded APK file.", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun checkForUpdates(context: Context, showUpToDateToast: Boolean, hideExceptionButtons: Boolean = false) = withContext(Dispatchers.IO) {
|
suspend fun checkForUpdates(context: Context, showUpToDateToast: Boolean, hideExceptionButtons: Boolean = false) = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val client = ManagedHttpClient();
|
val client = ManagedHttpClient();
|
||||||
@@ -196,25 +56,6 @@ class StateUpdate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadApkToFile(client: ManagedHttpClient, destinationFile: File, isCancelled: (() -> Boolean)? = null) {
|
|
||||||
var apkStream: InputStream? = null;
|
|
||||||
var outputStream: OutputStream? = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
val response = client.get(APK_URL);
|
|
||||||
if (response.isOk && response.body != null) {
|
|
||||||
apkStream = response.body.byteStream();
|
|
||||||
outputStream = destinationFile.outputStream();
|
|
||||||
apkStream.copyToOutputStream(outputStream, isCancelled);
|
|
||||||
apkStream.close();
|
|
||||||
outputStream.close();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
apkStream?.close();
|
|
||||||
outputStream?.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun downloadVersionCode(client: ManagedHttpClient): Int? {
|
fun downloadVersionCode(client: ManagedHttpClient): Int? {
|
||||||
val response = client.get(VERSION_URL);
|
val response = client.get(VERSION_URL);
|
||||||
if (!response.isOk || response.body == null) {
|
if (!response.isOk || response.body == null) {
|
||||||
@@ -267,6 +108,28 @@ class StateUpdate {
|
|||||||
}
|
}
|
||||||
val CHANGELOG_BASE_URL = "https://releases.grayjay.app/changelogs";
|
val CHANGELOG_BASE_URL = "https://releases.grayjay.app/changelogs";
|
||||||
|
|
||||||
|
fun getApkFile(context: Context, version: Int): File {
|
||||||
|
val dir = File(context.filesDir, "updates");
|
||||||
|
if (!dir.exists()) {
|
||||||
|
dir.mkdirs();
|
||||||
|
}
|
||||||
|
val result = File(dir, "app-${DESIRED_ABI}-${version}.apk");
|
||||||
|
//if(result.exists())
|
||||||
|
// result.delete();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPartialApkFile(context: Context, version: Int): File {
|
||||||
|
val dir = File(context.filesDir, "updates");
|
||||||
|
if (!dir.exists()) {
|
||||||
|
dir.mkdirs();
|
||||||
|
}
|
||||||
|
val result = File(dir, "app-${DESIRED_ABI}-${version}.apk.part");
|
||||||
|
//if(result.exists())
|
||||||
|
// result.delete();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
fun finish() {
|
fun finish() {
|
||||||
_instance?.let {
|
_instance?.let {
|
||||||
_instance = null;
|
_instance = null;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class SearchHistoryStorage : FragmentedStorageFileJson() {
|
|||||||
if (!lastQueries.contains(text)) {
|
if (!lastQueries.contains(text)) {
|
||||||
lastQueries.add(0, text);
|
lastQueries.add(0, text);
|
||||||
if (lastQueries.size > 10)
|
if (lastQueries.size > 10)
|
||||||
lastQueries.removeLast();
|
lastQueries.removeAt(lastQueries.size - 1);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
lastQueries.remove(text);
|
lastQueries.remove(text);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import androidx.constraintlayout.widget.ConstraintLayout
|
|||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.casting.CastConnectionState
|
import com.futo.platformplayer.casting.CastConnectionState
|
||||||
import com.futo.platformplayer.casting.CastProtocolType
|
import com.futo.platformplayer.casting.CastProtocolType
|
||||||
@@ -91,13 +90,7 @@ class DeviceViewHolder : ViewHolder {
|
|||||||
_textType.text = "AirPlay";
|
_textType.text = "AirPlay";
|
||||||
}
|
}
|
||||||
CastProtocolType.FCAST -> {
|
CastProtocolType.FCAST -> {
|
||||||
_imageDevice.setImageResource(
|
_imageDevice.setImageResource(R.drawable.ic_fc)
|
||||||
if (Settings.instance.casting.experimentalCasting) {
|
|
||||||
R.drawable.ic_exp_fc
|
|
||||||
} else {
|
|
||||||
R.drawable.ic_fc
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_textType.text = "FCast";
|
_textType.text = "FCast";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import android.widget.TextView
|
|||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.constructs.Event0
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||||
import com.futo.platformplayer.models.Playlist
|
import com.futo.platformplayer.models.Playlist
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
|
|
||||||
class PlaylistsViewHolder : ViewHolder {
|
class PlaylistsViewHolder : ViewHolder {
|
||||||
private val _root: ConstraintLayout;
|
private val _root: ConstraintLayout;
|
||||||
@@ -44,6 +46,7 @@ class PlaylistsViewHolder : ViewHolder {
|
|||||||
if (p.videos.isNotEmpty()) {
|
if (p.videos.isNotEmpty()) {
|
||||||
Glide.with(_imageThumbnail)
|
Glide.with(_imageThumbnail)
|
||||||
.load(p.videos[0].thumbnails.getMinimumThumbnail(380))
|
.load(p.videos[0].thumbnails.getMinimumThumbnail(380))
|
||||||
|
.withMaxSizePx()
|
||||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||||
.crossfade()
|
.crossfade()
|
||||||
.into(_imageThumbnail);
|
.into(_imageThumbnail);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import androidx.constraintlayout.widget.ConstraintLayout
|
|||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
@@ -23,6 +24,7 @@ import com.futo.platformplayer.toHumanNumber
|
|||||||
import com.futo.platformplayer.toHumanTime
|
import com.futo.platformplayer.toHumanTime
|
||||||
import com.futo.platformplayer.views.others.ProgressBar
|
import com.futo.platformplayer.views.others.ProgressBar
|
||||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
|
|
||||||
class VideoListEditorViewHolder : ViewHolder {
|
class VideoListEditorViewHolder : ViewHolder {
|
||||||
private val _root: ConstraintLayout;
|
private val _root: ConstraintLayout;
|
||||||
@@ -89,6 +91,7 @@ class VideoListEditorViewHolder : ViewHolder {
|
|||||||
fun bind(v: IPlatformVideo, canEdit: Boolean) {
|
fun bind(v: IPlatformVideo, canEdit: Boolean) {
|
||||||
Glide.with(_imageThumbnail)
|
Glide.with(_imageThumbnail)
|
||||||
.load(v.thumbnails.getHQThumbnail())
|
.load(v.thumbnails.getHQThumbnail())
|
||||||
|
.withMaxSizePx()
|
||||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||||
.crossfade()
|
.crossfade()
|
||||||
.into(_imageThumbnail);
|
.into(_imageThumbnail);
|
||||||
|
|||||||
+3
@@ -7,6 +7,7 @@ import android.widget.ImageView
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
@@ -16,6 +17,7 @@ import com.futo.platformplayer.states.Artist
|
|||||||
import com.futo.platformplayer.toHumanNowDiffString
|
import com.futo.platformplayer.toHumanNowDiffString
|
||||||
import com.futo.platformplayer.toHumanTime
|
import com.futo.platformplayer.toHumanTime
|
||||||
import com.futo.platformplayer.views.adapters.AnyAdapter
|
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||||
|
import com.futo.platformplayer.withMaxSizePx
|
||||||
import com.google.android.material.imageview.ShapeableImageView
|
import com.google.android.material.imageview.ShapeableImageView
|
||||||
|
|
||||||
|
|
||||||
@@ -49,6 +51,7 @@ class LocalVideoTileViewHolder(val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHo
|
|||||||
Glide.with(it)
|
Glide.with(it)
|
||||||
.load(content.thumbnails.getHQThumbnail())
|
.load(content.thumbnails.getHQThumbnail())
|
||||||
.placeholder(R.drawable.unknown_music)
|
.placeholder(R.drawable.unknown_music)
|
||||||
|
.withMaxSizePx()
|
||||||
.into(it)
|
.into(it)
|
||||||
else
|
else
|
||||||
Glide.with(it).load(R.drawable.unknown_music).into(it);
|
Glide.with(it).load(R.drawable.unknown_music).into(it);
|
||||||
|
|||||||
@@ -6,12 +6,10 @@ import android.view.View
|
|||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
|
||||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.constructs.Event0
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.dp
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.Announcement
|
import com.futo.platformplayer.states.Announcement
|
||||||
import com.futo.platformplayer.states.AnnouncementType
|
import com.futo.platformplayer.states.AnnouncementType
|
||||||
@@ -162,6 +160,10 @@ class AnnouncementView : LinearLayout {
|
|||||||
_textClose.visibility = View.VISIBLE;
|
_textClose.visibility = View.VISIBLE;
|
||||||
_textNever.visibility = View.VISIBLE;
|
_textNever.visibility = View.VISIBLE;
|
||||||
}
|
}
|
||||||
|
AnnouncementType.ONGOING -> {
|
||||||
|
_textClose.visibility = View.GONE;
|
||||||
|
_textNever.visibility = View.GONE;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (announcement.time != null) {
|
if (announcement.time != null) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user