mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 13:02:39 +02:00
Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efe074d272 | |||
| 8a9efd3a0f | |||
| 251302b9c3 | |||
| 5cdac1405e | |||
| 565ea7cb8b | |||
| 9fa3e22d2e | |||
| 5548783337 | |||
| 0dca8798cb | |||
| d902306fe4 | |||
| baa2a4fcf3 | |||
| 8be7ad9f68 | |||
| 992cbcb3a0 | |||
| e0857aea9b | |||
| 50cd0723c9 | |||
| 4c4b322682 | |||
| 7cff8568c0 | |||
| 801c646a09 | |||
| df4ec87613 | |||
| b08a79b7cb | |||
| 396e9f9f43 | |||
| 0e5a87a911 | |||
| 64d72f6d10 | |||
| 5b40da109b | |||
| 949294952f | |||
| 40a058e369 | |||
| a070d78dd9 | |||
| 105ac538bb | |||
| ce2029774e | |||
| 50c63d7e8d | |||
| d3534080d7 | |||
| b5025193a5 | |||
| 3f85b7ed78 | |||
| 98d008ef6c | |||
| 20eb53fc38 | |||
| 1ea7b307fa | |||
| f18571e0b2 | |||
| 70872d429a | |||
| cbf3db6e30 | |||
| 0be0dcfadc | |||
| abd226c33d | |||
| 89dbdc99a0 | |||
| f89ed18a49 | |||
| 77ac2b537c | |||
| 8ab03b6b66 | |||
| dad70e57c6 | |||
| eb9c6c8330 | |||
| 68da797f4d | |||
| 25948dd296 | |||
| 10d39d6ed1 | |||
| 85e8e674dd | |||
| 0d70392bf0 | |||
| f89b074d28 | |||
| ee2af411aa | |||
| 9ffbe6dd03 | |||
| 0ae6ac2fac | |||
| fd835cc54e | |||
| 07f3140038 | |||
| 3e753d70de | |||
| d578c47975 | |||
| b7a61425ca | |||
| 727f977672 | |||
| fc9d5eeb27 | |||
| f17e147b4e | |||
| 1c569b465b | |||
| 6289c85bd5 | |||
| 098599853b | |||
| 68d11f6d58 | |||
| 74f6b9aa62 | |||
| fc2aba0120 | |||
| e4f51bb130 | |||
| 9e9d26c752 | |||
| 5c5dd3af44 | |||
| d6468ba283 | |||
| 62a2f42d68 | |||
| 7c70e58129 |
@@ -64,12 +64,6 @@
|
||||
[submodule "app/src/stable/assets/sources/bilibili"]
|
||||
path = app/src/stable/assets/sources/bilibili
|
||||
url = ../plugins/bilibili.git
|
||||
[submodule "app/src/stable/assets/sources/spotify"]
|
||||
path = app/src/stable/assets/sources/spotify
|
||||
url = ../plugins/spotify.git
|
||||
[submodule "app/src/unstable/assets/sources/spotify"]
|
||||
path = app/src/unstable/assets/sources/spotify
|
||||
url = ../plugins/spotify.git
|
||||
[submodule "app/src/stable/assets/sources/bitchute"]
|
||||
path = app/src/stable/assets/sources/bitchute
|
||||
url = ../plugins/bitchute.git
|
||||
|
||||
@@ -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
|
||||
+1
-1
@@ -181,7 +181,7 @@ dependencies {
|
||||
implementation 'com.google.code.gson:gson:2.13.2' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
|
||||
|
||||
//JS
|
||||
implementation 'com.caoccao.javet:javet-v8-android:5.0.1'
|
||||
implementation 'com.caoccao.javet:javet-v8-android:4.1.5'
|
||||
|
||||
//Exoplayer
|
||||
implementation 'androidx.media3:media3-exoplayer:1.8.0'
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.FutoVideo"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:replace="android:enableOnBackInvokedCallback"
|
||||
android:enableOnBackInvokedCallback="false"
|
||||
tools:targetApi="31"
|
||||
android:largeHeap="true">
|
||||
<provider
|
||||
@@ -61,6 +63,7 @@
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
||||
android:windowSoftInputMode="adjustPan"
|
||||
android:launchMode="singleInstance"
|
||||
android:resizeableActivity="true"
|
||||
android:supportsPictureInPicture="true">
|
||||
@@ -245,5 +248,9 @@
|
||||
android:name=".activities.PolycentricModerationActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="portrait" />
|
||||
<activity
|
||||
android:name=".activities.QRCodeFullscreenActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -1025,18 +1025,21 @@
|
||||
|
||||
let settingsToUse = __DEV_SETTINGS ?? {};
|
||||
if (true) {
|
||||
for (let setting of this.Plugin?.currentPlugin?.settings) {
|
||||
if (typeof settingsToUse[setting.variable] == "undefined") {
|
||||
switch (setting?.type?.toLowerCase()) {
|
||||
case "boolean":
|
||||
settingsToUse[setting.variable] = setting.default === 'true';
|
||||
break;
|
||||
case "dropdown":
|
||||
let dropDownIndex = parseInt(setting.default);
|
||||
if (dropDownIndex) {
|
||||
settingsToUse[setting.variable] = setting.options[dropDownIndex];
|
||||
}
|
||||
break;
|
||||
const settings = this.Plugin?.currentPlugin?.settings;
|
||||
if (settings) {
|
||||
for (let setting of settings) {
|
||||
if (typeof settingsToUse[setting.variable] == "undefined") {
|
||||
switch (setting?.type?.toLowerCase()) {
|
||||
case "boolean":
|
||||
settingsToUse[setting.variable] = setting.default === 'true';
|
||||
break;
|
||||
case "dropdown":
|
||||
let dropDownIndex = parseInt(setting.default);
|
||||
if (dropDownIndex) {
|
||||
settingsToUse[setting.variable] = setting.options[dropDownIndex];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,6 +429,9 @@ class Settings : FragmentedStorageFileJson() {
|
||||
6 -> 1.75f;
|
||||
7 -> 2.0f;
|
||||
8 -> 2.25f;
|
||||
9 -> 2.5f;
|
||||
10 -> 2.75f;
|
||||
11 -> 3.0f;
|
||||
else -> 1.0f;
|
||||
};
|
||||
|
||||
@@ -725,7 +728,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@AdvancedField
|
||||
@FormField(R.string.experimental_cast, FieldForm.TOGGLE, R.string.experimental_cast_description, 6)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var experimentalCasting: Boolean = false
|
||||
var experimentalCasting: Boolean = true
|
||||
|
||||
/*TODO: Should we have a different casting quality?
|
||||
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
||||
|
||||
@@ -101,7 +101,7 @@ fun String.isHexColor(): Boolean {
|
||||
|
||||
fun IPlatformClient.fromPool(pool: PlatformMultiClientPool) = pool.getClientPooled(this);
|
||||
|
||||
fun IPlatformVideo.withTimestamp(sec: Long) = PlatformVideoWithTime(this, sec);
|
||||
fun IPlatformVideo.withTimestamp(sec: Long) = PlatformVideoWithTime(this, sec);
|
||||
|
||||
fun DocumentFile.getInputStream(context: Context) = context.contentResolver.openInputStream(this.uri);
|
||||
fun DocumentFile.getOutputStream(context: Context) = context.contentResolver.openOutputStream(this.uri);
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.content.Intent
|
||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -33,7 +34,6 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.withStateAtLeast
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.curlbind.Libcurl
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.RootInsetsController
|
||||
@@ -67,6 +67,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.LibraryFilesFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LibraryFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LibrarySearchFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LibraryVideosFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LoginFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsFragment
|
||||
@@ -202,6 +203,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
lateinit var _fragLibraryFiles: LibraryFilesFragment;
|
||||
lateinit var _fragSettings: SettingsFragment;
|
||||
lateinit var _fragDeveloper: DeveloperFragment;
|
||||
lateinit var _fragLogin: LoginFragment;
|
||||
|
||||
lateinit var _fragBrowser: BrowserFragment;
|
||||
|
||||
@@ -210,7 +212,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
//State
|
||||
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;
|
||||
|
||||
var fragBeforeOverlay: MainFragment? = null; private set;
|
||||
@@ -251,6 +253,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
UIDialogs.toast(this, "Notification permission denied");
|
||||
};
|
||||
|
||||
|
||||
|
||||
fun requestNotificationPermissions() {
|
||||
_notificationPermissionLauncher?.launch(_notifPermission);
|
||||
}
|
||||
@@ -329,6 +333,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
|
||||
//Preload common files to memory
|
||||
FragmentedStorage.get<SubscriptionStorage>();
|
||||
FragmentedStorage.get<Settings>();
|
||||
@@ -396,6 +404,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragLibrarySearch = LibrarySearchFragment.newInstance();
|
||||
_fragSettings = SettingsFragment.newInstance();
|
||||
_fragDeveloper = DeveloperFragment.newInstance();
|
||||
_fragLogin = LoginFragment.newInstance();
|
||||
|
||||
_fragBrowser = BrowserFragment.newInstance();
|
||||
|
||||
@@ -562,7 +571,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
defaultTab.action(_fragBotBarMenu);
|
||||
StateSubscriptions.instance;
|
||||
|
||||
fragCurrent.onShown(null, false);
|
||||
fragCurrent?.onShown(null, false);
|
||||
|
||||
//Other stuff
|
||||
rootView.progress = 0f;
|
||||
@@ -1149,7 +1158,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
if (_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.onBackPressed())
|
||||
return;
|
||||
|
||||
if (!fragCurrent.onBackPressed())
|
||||
if (!(fragCurrent?.onBackPressed() ?: true))
|
||||
closeSegment();
|
||||
}
|
||||
|
||||
@@ -1200,6 +1209,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T : Fragment> navigate(parameter: Any? = null, withHistory: Boolean = true, isBack: Boolean = false) {
|
||||
val segment = getFragment<T>();
|
||||
navigate(segment as MainFragment, parameter, withHistory, isBack);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate takes a MainFragment, and makes them the current main visible view
|
||||
* A parameter can be provided which becomes available in the onShow of said fragment
|
||||
@@ -1222,27 +1236,27 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
return;
|
||||
}
|
||||
|
||||
fragCurrent.onHide();
|
||||
fragCurrent?.onHide();
|
||||
|
||||
if (segment.isMainView) {
|
||||
var transaction = supportFragmentManager.beginTransaction();
|
||||
if (segment.topBar != null) {
|
||||
if (segment.topBar != fragCurrent.topBar) {
|
||||
if (segment.topBar != fragCurrent?.topBar) {
|
||||
transaction = transaction
|
||||
.show(segment.topBar as Fragment)
|
||||
.replace(R.id.fragment_top_bar, segment.topBar as Fragment);
|
||||
fragCurrent.topBar?.onHide();
|
||||
fragCurrent?.topBar?.onHide();
|
||||
}
|
||||
} else if (fragCurrent.topBar != null)
|
||||
transaction.hide(fragCurrent.topBar as Fragment);
|
||||
} else if (fragCurrent?.topBar != null)
|
||||
transaction.hide(fragCurrent?.topBar as Fragment);
|
||||
|
||||
transaction = transaction.replace(R.id.fragment_main, segment);
|
||||
|
||||
if (segment.hasBottomBar) {
|
||||
if (!fragCurrent.hasBottomBar)
|
||||
if (!(fragCurrent?.hasBottomBar ?: false))
|
||||
transaction = transaction.show(_fragBotBarMenu);
|
||||
} else {
|
||||
if (fragCurrent.hasBottomBar)
|
||||
if (fragCurrent?.hasBottomBar ?: false)
|
||||
transaction = transaction.hide(_fragBotBarMenu);
|
||||
}
|
||||
transaction.commitNow();
|
||||
@@ -1255,10 +1269,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
if (fragCurrent.isHistory && withHistory && _queue.lastOrNull() != fragCurrent)
|
||||
_queue.add(Pair(fragCurrent, _parameterCurrent));
|
||||
if (fragCurrent?.isHistory ?: false && withHistory && _queue.lastOrNull() != fragCurrent)
|
||||
_queue.add(Pair(fragCurrent!!, _parameterCurrent));
|
||||
|
||||
if (segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory)
|
||||
if (segment.isOverlay && !(fragCurrent?.isOverlay ?: false) && withHistory)// && fragCurrent.isHistory)
|
||||
fragBeforeOverlay = fragCurrent;
|
||||
|
||||
fragCurrent = segment;
|
||||
@@ -1347,6 +1361,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
LibrarySearchFragment::class -> _fragLibrarySearch as T;
|
||||
SettingsFragment:: class -> _fragSettings as T;
|
||||
DeveloperFragment::class -> _fragDeveloper as T;
|
||||
LoginFragment::class -> _fragLogin as T;
|
||||
else -> throw IllegalArgumentException("Fragment type ${T::class.java.name} is not available in MainActivity");
|
||||
}
|
||||
}
|
||||
@@ -1354,7 +1369,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
private fun updateSegmentPaddings() {
|
||||
var paddingBottom = 0f;
|
||||
if (fragCurrent.hasBottomBar)
|
||||
if (fragCurrent?.hasBottomBar ?: false)
|
||||
paddingBottom += HEIGHT_MENU_DP;
|
||||
|
||||
_fragContainerOverlay.setPadding(
|
||||
@@ -1371,6 +1386,23 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
);
|
||||
}
|
||||
|
||||
var _callbackPermissionAudio: ((Boolean)->Unit)? = null;
|
||||
var _callbackPermissionVideo: ((Boolean)->Unit)? = null;
|
||||
val permissionReqAudio = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
|
||||
_callbackPermissionAudio?.invoke(isGranted);
|
||||
});
|
||||
val permissionReqVideo = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
|
||||
_callbackPermissionVideo?.invoke(isGranted);
|
||||
});
|
||||
fun requestPermissionAudio(cb: ((Boolean)->Unit)? = null) {
|
||||
_callbackPermissionAudio = cb;
|
||||
permissionReqAudio.launch(android.Manifest.permission.READ_MEDIA_AUDIO);
|
||||
}
|
||||
fun requestPermissionVideo(cb: ((Boolean)->Unit)? = null) {
|
||||
_callbackPermissionVideo = cb;
|
||||
permissionReqVideo.launch(android.Manifest.permission.READ_MEDIA_VIDEO);
|
||||
}
|
||||
|
||||
|
||||
val notifPermission = "android.permission.POST_NOTIFICATIONS";
|
||||
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
|
||||
|
||||
@@ -13,15 +13,18 @@ import android.view.View
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateApp.Companion.withContext
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.futo.platformplayer.activities.QRCodeFullscreenActivity
|
||||
import com.futo.polycentric.core.ContentType
|
||||
import com.futo.polycentric.core.SignedEvent
|
||||
import com.futo.polycentric.core.StorageTypeCRDTItem
|
||||
@@ -29,8 +32,10 @@ import com.futo.polycentric.core.StorageTypeCRDTSetItem
|
||||
import com.futo.polycentric.core.Store
|
||||
import com.futo.polycentric.core.toBase64Url
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.MultiFormatWriter
|
||||
import com.google.zxing.common.BitMatrix
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -41,11 +46,27 @@ import userpackage.Protocol.URLInfo
|
||||
class PolycentricBackupActivity : AppCompatActivity() {
|
||||
private lateinit var _buttonShare: BigButton;
|
||||
private lateinit var _buttonCopy: BigButton;
|
||||
private lateinit var _buttonExportFile: BigButton;
|
||||
private lateinit var _imageQR: ImageView;
|
||||
private lateinit var _exportBundle: String;
|
||||
private lateinit var _textQR: TextView;
|
||||
private lateinit var _textQRHint: TextView;
|
||||
private lateinit var _loader: View
|
||||
|
||||
private val _createDocumentLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
|
||||
uri?.let { fileUri ->
|
||||
try {
|
||||
contentResolver.openOutputStream(fileUri)?.use { outputStream ->
|
||||
outputStream.write(_exportBundle.toByteArray())
|
||||
}
|
||||
UIDialogs.toast(this, getString(R.string.profile_saved_successfully))
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to write to document", e)
|
||||
UIDialogs.toast(this, "Failed to save profile: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
@@ -57,8 +78,10 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
|
||||
_buttonShare = findViewById(R.id.button_share)
|
||||
_buttonCopy = findViewById(R.id.button_copy)
|
||||
_buttonExportFile = findViewById(R.id.button_export_file)
|
||||
_imageQR = findViewById(R.id.image_qr)
|
||||
_textQR = findViewById(R.id.text_qr)
|
||||
_textQRHint = findViewById(R.id.text_qr_hint)
|
||||
_loader = findViewById(R.id.progress_loader)
|
||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||
finish();
|
||||
@@ -66,14 +89,23 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
|
||||
_imageQR.visibility = View.INVISIBLE
|
||||
_textQR.visibility = View.INVISIBLE
|
||||
_textQRHint.visibility = View.INVISIBLE
|
||||
_loader.visibility = View.VISIBLE
|
||||
_buttonShare.visibility = View.INVISIBLE
|
||||
_buttonCopy.visibility = View.INVISIBLE
|
||||
_buttonExportFile.visibility = View.INVISIBLE
|
||||
|
||||
lifecycleScope.launch {
|
||||
val bundle = withContext(Dispatchers.IO) { createExportBundle() }
|
||||
_exportBundle = bundle
|
||||
Logger.i(TAG, "Export bundle created, length: ${bundle.length}")
|
||||
|
||||
try {
|
||||
val pair = withContext(Dispatchers.IO) {
|
||||
val bundle = createExportBundle()
|
||||
if (!isContentSuitableForQRCode(bundle)) {
|
||||
throw Exception("Data too big for QR code generation")
|
||||
}
|
||||
|
||||
val dimension = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics
|
||||
).toInt()
|
||||
@@ -81,18 +113,35 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
Pair(bundle, qr)
|
||||
}
|
||||
|
||||
_exportBundle = pair.first
|
||||
_imageQR.setImageBitmap(pair.second)
|
||||
_imageQR.visibility = View.VISIBLE
|
||||
_textQR.visibility = View.VISIBLE
|
||||
_textQRHint.visibility = View.VISIBLE
|
||||
_buttonShare.visibility = View.VISIBLE
|
||||
_buttonCopy.visibility = View.VISIBLE
|
||||
|
||||
_imageQR.setOnClickListener {
|
||||
val intent = QRCodeFullscreenActivity.createIntent(this@PolycentricBackupActivity, _exportBundle)
|
||||
startActivity(intent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e)
|
||||
val byteSize = bundle.toByteArray(Charsets.UTF_8).size
|
||||
Logger.e(TAG, "QR code generation failed. Bundle length: ${bundle.length} chars, ${byteSize} bytes, Error: ${e.message}", e)
|
||||
|
||||
if (e.message?.contains("Data too big") == true) {
|
||||
_textQR.text = getString(R.string.qr_code_too_large_use_file_export)
|
||||
_buttonExportFile.visibility = View.VISIBLE
|
||||
} else {
|
||||
_textQR.text = getString(R.string.failed_to_generate_qr_code)
|
||||
}
|
||||
|
||||
_textQR.visibility = View.VISIBLE
|
||||
_textQRHint.visibility = View.INVISIBLE
|
||||
_buttonShare.visibility = View.VISIBLE
|
||||
_buttonCopy.visibility = View.VISIBLE
|
||||
|
||||
// Hide QR image since generation failed
|
||||
_imageQR.visibility = View.INVISIBLE
|
||||
_textQR.visibility = View.INVISIBLE
|
||||
_buttonShare.visibility = View.INVISIBLE
|
||||
_buttonCopy.visibility = View.INVISIBLE
|
||||
} finally {
|
||||
_loader.visibility = View.GONE
|
||||
}
|
||||
@@ -108,11 +157,29 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
val clip = ClipData.newPlainText(getString(R.string.copied_text), _exportBundle);
|
||||
clipboard.setPrimaryClip(clip);
|
||||
};
|
||||
|
||||
_buttonExportFile.onClick.subscribe {
|
||||
val fileName = "polycentric_profile_${System.currentTimeMillis()}.txt"
|
||||
_createDocumentLauncher.launch(fileName)
|
||||
};
|
||||
}
|
||||
|
||||
private fun isContentSuitableForQRCode(content: String): Boolean {
|
||||
val bytes = content.toByteArray(Charsets.UTF_8)
|
||||
return bytes.size <= 2300 // QR Code Version 40 with Error Correction Level M can hold ~2331 bytes
|
||||
}
|
||||
|
||||
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
|
||||
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height);
|
||||
return bitMatrixToBitmap(bitMatrix);
|
||||
if (!isContentSuitableForQRCode(content)) {
|
||||
throw Exception("Data too big for QR code generation")
|
||||
}
|
||||
|
||||
val hints = java.util.EnumMap<EncodeHintType, Any>(EncodeHintType::class.java)
|
||||
hints[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.M
|
||||
hints[EncodeHintType.MARGIN] = 1
|
||||
|
||||
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints)
|
||||
return bitMatrixToBitmap(bitMatrix)
|
||||
}
|
||||
|
||||
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
|
||||
@@ -203,7 +270,8 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
.setBody(exportBundle.toByteString())
|
||||
.build();
|
||||
|
||||
return "polycentric://" + urlInfo.toByteArray().toBase64Url()
|
||||
val data = urlInfo.toByteArray()
|
||||
return "polycentric://" + data.toBase64Url()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
+133
-61
@@ -32,100 +32,166 @@ import userpackage.Protocol
|
||||
import userpackage.Protocol.ExportBundle
|
||||
|
||||
class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||
private lateinit var _buttonHelp: ImageButton;
|
||||
private lateinit var _buttonScanProfile: LinearLayout;
|
||||
private lateinit var _buttonImportProfile: LinearLayout;
|
||||
private lateinit var _editProfile: EditText;
|
||||
private lateinit var _loaderOverlay: LoaderOverlay;
|
||||
private lateinit var _buttonHelp: ImageButton
|
||||
private lateinit var _buttonScanProfile: LinearLayout
|
||||
private lateinit var _buttonImportFile: LinearLayout
|
||||
private lateinit var _buttonImportProfile: LinearLayout
|
||||
private lateinit var _editProfile: EditText
|
||||
private lateinit var _loaderOverlay: LoaderOverlay
|
||||
|
||||
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
||||
scanResult?.let {
|
||||
if (it.contents != null) {
|
||||
val scannedUrl = it.contents
|
||||
import(scannedUrl)
|
||||
private val _qrCodeResultLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val scanResult =
|
||||
IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
||||
scanResult?.let {
|
||||
if (it.contents != null) {
|
||||
val scannedUrl = it.contents
|
||||
import(scannedUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val _filePickerLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
uri?.let { fileUri ->
|
||||
try {
|
||||
// Check file size before reading
|
||||
val fileSize =
|
||||
contentResolver.openFileDescriptor(fileUri, "r")?.statSize ?: 0
|
||||
val maxFileSize = 10 * 1024 * 1024 // 10MB limit
|
||||
|
||||
if (fileSize > maxFileSize) {
|
||||
UIDialogs.toast(this, "File too large. Maximum size is 10MB.")
|
||||
return@let
|
||||
}
|
||||
|
||||
if (fileSize == 0L) {
|
||||
UIDialogs.toast(this, "Selected file is empty.")
|
||||
return@let
|
||||
}
|
||||
|
||||
val content =
|
||||
contentResolver
|
||||
.openInputStream(fileUri)
|
||||
?.bufferedReader()
|
||||
?.readText()
|
||||
content?.let { fileContent ->
|
||||
val trimmedContent = fileContent.trim()
|
||||
|
||||
// Check if content is empty after trimming
|
||||
if (trimmedContent.isEmpty()) {
|
||||
UIDialogs.toast(this, "Selected file contains no data.")
|
||||
return@let
|
||||
}
|
||||
|
||||
// Check if content looks like a valid polycentric URL
|
||||
if (!trimmedContent.startsWith("polycentric://")) {
|
||||
UIDialogs.toast(
|
||||
this,
|
||||
"Selected file does not contain a valid polycentric profile URL."
|
||||
)
|
||||
return@let
|
||||
}
|
||||
|
||||
import(trimmedContent)
|
||||
}
|
||||
?: run { UIDialogs.toast(this, "Could not read file content.") }
|
||||
} catch (e: SecurityException) {
|
||||
Logger.e(TAG, "Security exception reading file", e)
|
||||
UIDialogs.toast(this, "Permission denied to read file.")
|
||||
} catch (e: OutOfMemoryError) {
|
||||
Logger.e(TAG, "Out of memory reading file", e)
|
||||
UIDialogs.toast(this, "File too large to process.")
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to read file", e)
|
||||
UIDialogs.toast(this, "Failed to read file: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_import_profile);
|
||||
setNavigationBarColorAndIcons();
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_polycentric_import_profile)
|
||||
setNavigationBarColorAndIcons()
|
||||
|
||||
_buttonHelp = findViewById(R.id.button_help);
|
||||
_buttonScanProfile = findViewById(R.id.button_scan_profile);
|
||||
_buttonImportProfile = findViewById(R.id.button_import_profile);
|
||||
_loaderOverlay = findViewById(R.id.loader_overlay);
|
||||
_editProfile = findViewById(R.id.edit_profile);
|
||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||
finish();
|
||||
};
|
||||
_buttonHelp = findViewById(R.id.button_help)
|
||||
_buttonScanProfile = findViewById(R.id.button_scan_profile)
|
||||
_buttonImportFile = findViewById(R.id.button_import_file)
|
||||
_buttonImportProfile = findViewById(R.id.button_import_profile)
|
||||
_loaderOverlay = findViewById(R.id.loader_overlay)
|
||||
_editProfile = findViewById(R.id.edit_profile)
|
||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener { finish() }
|
||||
|
||||
_buttonHelp.setOnClickListener {
|
||||
startActivity(Intent(this, PolycentricWhyActivity::class.java));
|
||||
};
|
||||
startActivity(Intent(this, PolycentricWhyActivity::class.java))
|
||||
}
|
||||
|
||||
_buttonScanProfile.setOnClickListener {
|
||||
val integrator = IntentIntegrator(this)
|
||||
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
|
||||
integrator.setPrompt(getString(R.string.scan_a_qr_code))
|
||||
integrator.setOrientationLocked(true);
|
||||
integrator.setOrientationLocked(true)
|
||||
integrator.setCameraId(0)
|
||||
integrator.setBeepEnabled(false)
|
||||
integrator.setBarcodeImageEnabled(true)
|
||||
integrator.setCaptureActivity(QRCaptureActivity::class.java);
|
||||
integrator.setCaptureActivity(QRCaptureActivity::class.java)
|
||||
_qrCodeResultLauncher.launch(integrator.createScanIntent())
|
||||
};
|
||||
}
|
||||
|
||||
_buttonImportFile.setOnClickListener { _filePickerLauncher.launch("text/plain") }
|
||||
|
||||
_buttonImportProfile.setOnClickListener {
|
||||
if (_editProfile.text.isEmpty()) {
|
||||
UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data));
|
||||
return@setOnClickListener;
|
||||
UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data))
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
import(_editProfile.text.toString());
|
||||
};
|
||||
import(_editProfile.text.toString())
|
||||
}
|
||||
|
||||
val url = intent.getStringExtra("url");
|
||||
val url = intent.getStringExtra("url")
|
||||
if (url != null) {
|
||||
import(url);
|
||||
import(url)
|
||||
}
|
||||
}
|
||||
|
||||
private fun import(url: String) {
|
||||
if (!url.startsWith("polycentric://")) {
|
||||
UIDialogs.toast(this, getString(R.string.not_a_valid_url));
|
||||
return;
|
||||
UIDialogs.toast(this, getString(R.string.not_a_valid_url))
|
||||
return
|
||||
}
|
||||
|
||||
_loaderOverlay.show()
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val data = url.substring("polycentric://".length).base64UrlToByteArray();
|
||||
val urlInfo = Protocol.URLInfo.parseFrom(data);
|
||||
val data = url.substring("polycentric://".length).base64UrlToByteArray()
|
||||
val urlInfo = Protocol.URLInfo.parseFrom(data)
|
||||
|
||||
if (urlInfo.urlType != 3L) {
|
||||
throw Exception("Expected urlInfo struct of type ExportBundle")
|
||||
}
|
||||
|
||||
val exportBundle = ExportBundle.parseFrom(urlInfo.body);
|
||||
val keyPair = KeyPair.fromProto(exportBundle.keyPair);
|
||||
val exportBundle = ExportBundle.parseFrom(urlInfo.body)
|
||||
val keyPair = KeyPair.fromProto(exportBundle.keyPair)
|
||||
|
||||
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey);
|
||||
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey)
|
||||
if (existingProcessSecret != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.this_profile_is_already_imported));
|
||||
UIDialogs.toast(
|
||||
this@PolycentricImportProfileActivity,
|
||||
getString(R.string.this_profile_is_already_imported)
|
||||
)
|
||||
}
|
||||
return@launch;
|
||||
return@launch
|
||||
}
|
||||
|
||||
val processSecret = ProcessSecret(keyPair, Process.random());
|
||||
Store.instance.addProcessSecret(processSecret);
|
||||
val processSecret = ProcessSecret(keyPair, Process.random())
|
||||
Store.instance.addProcessSecret(processSecret)
|
||||
|
||||
try {
|
||||
PolycentricStorage.instance.addProcessSecret(processSecret)
|
||||
@@ -133,37 +199,43 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
|
||||
}
|
||||
|
||||
val processHandle = processSecret.toProcessHandle();
|
||||
val processHandle = processSecret.toProcessHandle()
|
||||
|
||||
for (e in exportBundle.events.eventsList) {
|
||||
try {
|
||||
val se = SignedEvent.fromProto(e);
|
||||
Store.instance.putSignedEvent(se);
|
||||
val se = SignedEvent.fromProto(e)
|
||||
Store.instance.putSignedEvent(se)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Ignored invalid event", e);
|
||||
Logger.w(TAG, "Ignored invalid event", e)
|
||||
}
|
||||
}
|
||||
|
||||
StatePolycentric.instance.setProcessHandle(processHandle);
|
||||
processHandle.fullyBackfillClient(ApiMethods.SERVER);
|
||||
StatePolycentric.instance.setProcessHandle(processHandle)
|
||||
processHandle.fullyBackfillClient(ApiMethods.SERVER)
|
||||
withContext(Dispatchers.Main) {
|
||||
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
|
||||
finish();
|
||||
startActivity(
|
||||
Intent(
|
||||
this@PolycentricImportProfileActivity,
|
||||
PolycentricProfileActivity::class.java
|
||||
)
|
||||
)
|
||||
finish()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to import profile", e);
|
||||
Logger.w(TAG, "Failed to import profile", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.failed_to_import_profile) + " '${e.message}'");
|
||||
UIDialogs.toast(
|
||||
this@PolycentricImportProfileActivity,
|
||||
getString(R.string.failed_to_import_profile) + " '${e.message}'"
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
withContext(Dispatchers.Main) {
|
||||
_loaderOverlay.hide();
|
||||
}
|
||||
withContext(Dispatchers.Main) { _loaderOverlay.hide() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PolycentricImportProfileActivity";
|
||||
private const val TAG = "PolycentricImportProfileActivity"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.MultiFormatWriter
|
||||
import com.google.zxing.common.BitMatrix
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||
|
||||
class QRCodeFullscreenActivity : AppCompatActivity() {
|
||||
companion object {
|
||||
private const val EXTRA_QR_TEXT = "qr_text"
|
||||
|
||||
fun createIntent(context: Context, qrText: String): android.content.Intent {
|
||||
return android.content.Intent(context, QRCodeFullscreenActivity::class.java).apply {
|
||||
putExtra(EXTRA_QR_TEXT, qrText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_qr_code_fullscreen)
|
||||
setNavigationBarColorAndIcons()
|
||||
|
||||
val qrText = intent.getStringExtra(EXTRA_QR_TEXT)
|
||||
|
||||
val imageQR = findViewById<ImageView>(R.id.image_qr_fullscreen)
|
||||
val buttonBack = findViewById<ImageButton>(R.id.button_back_fullscreen)
|
||||
val buttonClose = findViewById<ImageButton>(R.id.button_close_fullscreen)
|
||||
|
||||
// Generate QR code bitmap from text
|
||||
qrText?.let { text ->
|
||||
try {
|
||||
if (!isContentSuitableForQRCode(text)) {
|
||||
throw Exception("Data too big for QR code generation")
|
||||
}
|
||||
|
||||
val dimension = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP, 300f, resources.displayMetrics
|
||||
).toInt()
|
||||
val qrBitmap = generateQRCode(text, dimension, dimension)
|
||||
imageQR.setImageBitmap(qrBitmap)
|
||||
} catch (e: Exception) {
|
||||
// If QR generation fails, show error or fallback
|
||||
imageQR.setImageResource(R.drawable.ic_qr)
|
||||
}
|
||||
}
|
||||
|
||||
buttonBack.setOnClickListener {
|
||||
finish()
|
||||
}
|
||||
|
||||
buttonClose.setOnClickListener {
|
||||
finish()
|
||||
}
|
||||
|
||||
imageQR.setOnClickListener {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isContentSuitableForQRCode(content: String): Boolean {
|
||||
val bytes = content.toByteArray(Charsets.UTF_8)
|
||||
return bytes.size <= 2300 // QR Code Version 40 with Error Correction Level M can hold ~2331 bytes
|
||||
}
|
||||
|
||||
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
|
||||
if (!isContentSuitableForQRCode(content)) {
|
||||
throw Exception("Data too big for QR code generation")
|
||||
}
|
||||
|
||||
val hints = java.util.EnumMap<EncodeHintType, Any>(EncodeHintType::class.java)
|
||||
hints[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.M
|
||||
hints[EncodeHintType.MARGIN] = 1
|
||||
|
||||
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints)
|
||||
return bitMatrixToBitmap(bitMatrix)
|
||||
}
|
||||
|
||||
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
|
||||
val width = matrix.width
|
||||
val height = matrix.height
|
||||
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
|
||||
|
||||
for (x in 0 until width) {
|
||||
for (y in 0 until height) {
|
||||
bmp.setPixel(x, y, if (matrix[x, y]) Color.BLACK else Color.WHITE)
|
||||
}
|
||||
}
|
||||
return bmp
|
||||
}
|
||||
}
|
||||
|
||||
+39
-9
@@ -5,6 +5,7 @@ import android.util.Log
|
||||
import com.futo.platformplayer.api.http.server.HttpContext
|
||||
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.models.modifier.IRequest
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.parsers.HttpResponseParser
|
||||
import com.futo.platformplayer.readLine
|
||||
@@ -27,6 +28,7 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
||||
private var _injectReferer = false;
|
||||
|
||||
private val _client = ManagedHttpClient();
|
||||
private var _requestModifier: ((String, Map<String, String>) -> IRequest)? = null;
|
||||
|
||||
override fun handle(context: HttpContext) {
|
||||
if (useTcp) {
|
||||
@@ -43,21 +45,33 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
||||
for (injectHeader in _injectRequestHeader)
|
||||
proxyHeaders[injectHeader.first] = injectHeader.second;
|
||||
|
||||
val parsed = Uri.parse(targetUrl);
|
||||
val req = _requestModifier?.invoke(targetUrl, proxyHeaders)
|
||||
var url = targetUrl
|
||||
if (req != null) {
|
||||
req.url?.let {
|
||||
url = it
|
||||
}
|
||||
req.headers.let {
|
||||
proxyHeaders.clear()
|
||||
proxyHeaders.putAll(it)
|
||||
}
|
||||
}
|
||||
|
||||
val parsed = Uri.parse(url);
|
||||
if(_injectHost)
|
||||
proxyHeaders.put("Host", parsed.host!!);
|
||||
if(_injectReferer)
|
||||
proxyHeaders.put("Referer", targetUrl);
|
||||
proxyHeaders.put("Referer", url);
|
||||
|
||||
val useMethod = if (method == "inherit") context.method else method;
|
||||
Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${targetUrl}");
|
||||
Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${url}");
|
||||
Logger.i(TAG, "handleWithOkHttp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
|
||||
|
||||
val resp = when (useMethod) {
|
||||
"GET" -> _client.get(targetUrl, proxyHeaders);
|
||||
"POST" -> _client.post(targetUrl, content ?: "", proxyHeaders);
|
||||
"HEAD" -> _client.head(targetUrl, proxyHeaders)
|
||||
else -> _client.requestMethod(useMethod, targetUrl, proxyHeaders);
|
||||
"GET" -> _client.get(url, proxyHeaders);
|
||||
"POST" -> _client.post(url, content ?: "", proxyHeaders);
|
||||
"HEAD" -> _client.head(url, proxyHeaders)
|
||||
else -> _client.requestMethod(useMethod, url, proxyHeaders);
|
||||
};
|
||||
|
||||
Logger.i(TAG, "Proxied Response [${resp.code}]");
|
||||
@@ -91,11 +105,23 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
||||
for (injectHeader in _injectRequestHeader)
|
||||
proxyHeaders[injectHeader.first] = injectHeader.second;
|
||||
|
||||
val parsed = Uri.parse(targetUrl);
|
||||
val req = _requestModifier?.invoke(targetUrl, proxyHeaders)
|
||||
var url = targetUrl
|
||||
if (req != null) {
|
||||
req.url?.let {
|
||||
url = it
|
||||
}
|
||||
req.headers.let {
|
||||
proxyHeaders.clear()
|
||||
proxyHeaders.putAll(it)
|
||||
}
|
||||
}
|
||||
|
||||
val parsed = Uri.parse(url);
|
||||
if(_injectHost)
|
||||
proxyHeaders.put("Host", parsed.host!!);
|
||||
if(_injectReferer)
|
||||
proxyHeaders.put("Referer", targetUrl);
|
||||
proxyHeaders.put("Referer", url);
|
||||
|
||||
val useMethod = if (method == "inherit") context.method else method;
|
||||
Logger.i(TAG, "handleWithTcp Proxied Request ${useMethod}: ${parsed}");
|
||||
@@ -242,6 +268,10 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
||||
_ignoreRequestHeaders.add("referer");
|
||||
return this;
|
||||
}
|
||||
fun withRequestModifier(modifier: (String, Map<String, String>) -> IRequest) : HttpProxyHandler {
|
||||
_requestModifier = modifier;
|
||||
return this;
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "HttpProxyHandler"
|
||||
|
||||
@@ -153,8 +153,8 @@ open class JSClient : IPlatformClient {
|
||||
_captcha = descriptor.getCaptchaData();
|
||||
flags = descriptor.flags.toTypedArray();
|
||||
|
||||
_httpClient = JSHttpClient(this, null, _captcha);
|
||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha);
|
||||
_httpClient = JSHttpClient(this, null, _captcha, config);
|
||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
|
||||
_plugin = V8Plugin(context, descriptor.config, null, _httpClient, _httpClientAuth);
|
||||
_plugin.withDependency(context, "scripts/polyfil.js");
|
||||
_plugin.withDependency(context, "scripts/source.js");
|
||||
@@ -186,8 +186,8 @@ open class JSClient : IPlatformClient {
|
||||
_captcha = descriptor.getCaptchaData();
|
||||
flags = descriptor.flags.toTypedArray();
|
||||
|
||||
_httpClient = JSHttpClient(this, null, _captcha);
|
||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha);
|
||||
_httpClient = JSHttpClient(this, null, _captcha, config);
|
||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
|
||||
_plugin = V8Plugin(context, descriptor.config, script, _httpClient, _httpClientAuth);
|
||||
_plugin.withDependency(context, "scripts/polyfil.js");
|
||||
_plugin.withDependency(context, "scripts/source.js");
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.platforms.js
|
||||
|
||||
import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import java.util.Dictionary
|
||||
|
||||
@Serializable
|
||||
@@ -27,7 +28,7 @@ class SourcePluginAuthConfig(
|
||||
val details: String? = null,
|
||||
val once: Boolean? = true
|
||||
) {
|
||||
@Contextual
|
||||
@Transient
|
||||
private var _regex: Regex? = null;
|
||||
|
||||
fun getRegex(): Regex {
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ class SourcePluginConfig(
|
||||
//Script
|
||||
val repositoryUrl: String? = null,
|
||||
val scriptUrl: String = "",
|
||||
val version: Int = -1,
|
||||
var version: Int = -1,
|
||||
|
||||
val iconUrl: String? = null,
|
||||
var id: String = UUID.randomUUID().toString(),
|
||||
|
||||
+71
@@ -23,6 +23,7 @@ import java.util.UUID
|
||||
class JSHttpClient : ManagedHttpClient {
|
||||
private val _jsClient: JSClient?;
|
||||
private val _jsConfig: SourcePluginConfig?;
|
||||
val config get() = _jsConfig
|
||||
private val _auth: SourceAuth?;
|
||||
private val _captcha: SourceCaptchaData?;
|
||||
|
||||
@@ -254,6 +255,76 @@ class JSHttpClient : ManagedHttpClient {
|
||||
|
||||
return resp;
|
||||
}
|
||||
fun processRequest(method: String, responseCode: Int, url: Uri, headers: Map<String, List<String>>) {
|
||||
if(doUpdateCookies) {
|
||||
val domain = url.host?.lowercase() ?: return;
|
||||
val domainParts = domain.split(".");
|
||||
val defaultCookieDomain =
|
||||
"." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
||||
for (header in headers) {
|
||||
if(header.key.lowercase() == "set-cookie") {
|
||||
var domainToUse = domain;
|
||||
val cookie = cookieStringToPair(header.value.first());
|
||||
var cookieValue = cookie.second;
|
||||
|
||||
if (cookie.first.isNotEmpty() && cookie.second.isNotEmpty()) {
|
||||
val cookieParts = cookie.second.split(";");
|
||||
if (cookieParts.size == 0)
|
||||
continue;
|
||||
cookieValue = cookieParts[0].trim();
|
||||
|
||||
val cookieVariables = cookieParts.drop(1).map {
|
||||
val splitIndex = it.indexOf("=");
|
||||
if (splitIndex < 0)
|
||||
return@map Pair(it.trim().lowercase(), "");
|
||||
return@map Pair<String, String>(
|
||||
it.substring(0, splitIndex).lowercase().trim(),
|
||||
it.substring(splitIndex + 1).trim()
|
||||
);
|
||||
}.toMap();
|
||||
domainToUse = if (cookieVariables.containsKey("domain"))
|
||||
cookieVariables["domain"]!!.lowercase();
|
||||
else defaultCookieDomain;
|
||||
//TODO: Make sure this has no negative effect besides apply cookies to root domain
|
||||
if(!domainToUse.startsWith("."))
|
||||
domainToUse = ".${domainToUse}";
|
||||
}
|
||||
|
||||
if ((_auth != null || _currentCookieMap.isNotEmpty())) {
|
||||
val cookieMap = if (_currentCookieMap.containsKey(domainToUse))
|
||||
_currentCookieMap[domainToUse]!!;
|
||||
else {
|
||||
val newMap = hashMapOf<String, String>();
|
||||
_currentCookieMap[domainToUse] = newMap
|
||||
newMap;
|
||||
}
|
||||
if (cookieMap.containsKey(cookie.first) || doAllowNewCookies)
|
||||
cookieMap[cookie.first] = cookieValue;
|
||||
}
|
||||
else {
|
||||
val cookieMap = if (_otherCookieMap.containsKey(domainToUse))
|
||||
_otherCookieMap[domainToUse]!!;
|
||||
else {
|
||||
val newMap = hashMapOf<String, String>();
|
||||
_otherCookieMap[domainToUse] = newMap
|
||||
newMap;
|
||||
}
|
||||
if (cookieMap.containsKey(cookie.first) || doAllowNewCookies)
|
||||
cookieMap[cookie.first] = cookieValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(_jsClient is DevJSClient) {
|
||||
//val peekBody = resp.peekBody(1000 * 1000).string();
|
||||
StateDeveloper.instance.addDevHttpExchange(
|
||||
StateDeveloper.DevHttpExchange(
|
||||
StateDeveloper.DevHttpRequest(method, url.toString(), mapOf(*headers.map { Pair(it.key, it.value.joinToString(";")) }.toTypedArray()), ""),
|
||||
StateDeveloper.DevHttpRequest("RESP", url.toString(), mapOf(*headers.map { Pair(it.key, it.value.joinToString(";")) }.toTypedArray()), "", responseCode)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private fun cookieStringToPair(cookie: String): Pair<String, String> {
|
||||
val cookieKey = cookie.substring(0, cookie.indexOf("="));
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import com.caoccao.javet.values.primitive.V8ValueString
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getSourcePlugin
|
||||
import com.futo.platformplayer.invokeV8
|
||||
@@ -30,7 +31,7 @@ class JSSubtitleSource : ISubtitleSource {
|
||||
|
||||
val context = "JSSubtitles";
|
||||
name = v8Value.getOrThrow(config, "name", context, false);
|
||||
language = v8Value.getOrThrow(config, "language", context, false);
|
||||
language = v8Value.getOrDefault(config, "language", context, null);
|
||||
url = v8Value.getOrThrow(config, "url", context, true);
|
||||
format = v8Value.getOrThrow(config, "format", context, true);
|
||||
hasFetch = v8Value.has("getSubtitles");
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler
|
||||
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.HttpProxyHandler
|
||||
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
|
||||
@@ -295,20 +296,63 @@ abstract class StateCasting {
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
|
||||
if (videoSource is IVideoUrlSource) {
|
||||
val videoPath = "/video-${id}"
|
||||
val videoUrl = if(proxyStreams) url + videoPath else videoSource.getVideoUrl();
|
||||
Logger.i(TAG, "Casting as singular video");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
|
||||
val videoPath = "/video-$id"
|
||||
val upstreamUrl = videoSource.getVideoUrl()
|
||||
val videoUrl = if (proxyStreams) url + videoPath else upstreamUrl
|
||||
val jsReqMod = (videoSource as? JSSource)?.getRequestModifier()
|
||||
|
||||
if (proxyStreams) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpProxyHandler("GET", videoPath, upstreamUrl, true)
|
||||
.withIRequestModifier(jsReqMod)
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"),
|
||||
true
|
||||
).withTag("castSingular")
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Casting as singular video (proxy=$proxyStreams, url=$videoUrl)")
|
||||
ad.loadVideo(
|
||||
if (video.isLive) "LIVE" else "BUFFERED",
|
||||
videoSource.container,
|
||||
videoUrl,
|
||||
resumePosition,
|
||||
video.duration.toDouble(),
|
||||
speed,
|
||||
metadataFromVideo(video)
|
||||
)
|
||||
} else if (audioSource is IAudioUrlSource) {
|
||||
val audioPath = "/audio-${id}"
|
||||
val audioUrl = if(proxyStreams) url + audioPath else audioSource.getAudioUrl();
|
||||
Logger.i(TAG, "Casting as singular audio");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
|
||||
val audioPath = "/audio-$id"
|
||||
val upstreamUrl = audioSource.getAudioUrl()
|
||||
val audioUrl = if (proxyStreams) url + audioPath else upstreamUrl
|
||||
val jsReqMod = (audioSource as? JSSource)?.getRequestModifier()
|
||||
|
||||
if (proxyStreams) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpProxyHandler("GET", audioPath, upstreamUrl, true)
|
||||
.withIRequestModifier(jsReqMod)
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"),
|
||||
true
|
||||
).withTag("castSingular")
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Casting as singular audio (proxy=$proxyStreams, url=$audioUrl)")
|
||||
ad.loadVideo(
|
||||
if (video.isLive) "LIVE" else "BUFFERED",
|
||||
audioSource.container,
|
||||
audioUrl,
|
||||
resumePosition,
|
||||
video.duration.toDouble(),
|
||||
speed,
|
||||
metadataFromVideo(video)
|
||||
)
|
||||
} else if (videoSource is IHLSManifestSource) {
|
||||
if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) {
|
||||
Logger.i(TAG, "Casting as proxied HLS");
|
||||
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed);
|
||||
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed, (videoSource as JSSource?)?.getRequestModifier());
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as non-proxied HLS");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
|
||||
@@ -316,7 +360,7 @@ abstract class StateCasting {
|
||||
} else if (audioSource is IHLSManifestAudioSource) {
|
||||
if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) {
|
||||
Logger.i(TAG, "Casting as proxied audio HLS");
|
||||
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed);
|
||||
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed, (audioSource as JSSource?)?.getRequestModifier());
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as non-proxied audio HLS");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
|
||||
@@ -347,6 +391,11 @@ abstract class StateCasting {
|
||||
}
|
||||
}
|
||||
|
||||
private fun HttpProxyHandler.withIRequestModifier(requestModifier: IRequestModifier?): HttpProxyHandler {
|
||||
if (requestModifier == null) return this
|
||||
return withRequestModifier { url, headers -> requestModifier.modifyRequest(url, headers) }
|
||||
}
|
||||
|
||||
fun resumeVideo(): Boolean {
|
||||
val ad = activeDevice ?: return false;
|
||||
try {
|
||||
@@ -665,7 +714,8 @@ abstract class StateCasting {
|
||||
sourceUrl: String,
|
||||
codec: String?,
|
||||
resumePosition: Double,
|
||||
speed: Double?
|
||||
speed: Double?,
|
||||
requestModifier: IRequestModifier?
|
||||
): List<String> {
|
||||
_castServer.removeAllHandlers("castProxiedHlsMaster")
|
||||
|
||||
@@ -686,7 +736,9 @@ abstract class StateCasting {
|
||||
val headers = masterContext.headers.clone()
|
||||
headers["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||
|
||||
val masterPlaylistResponse = _client.get(sourceUrl)
|
||||
val req = requestModifier?.modifyRequest(sourceUrl, mapOf())
|
||||
val masterPlaylistResponse = _client.get(req?.url ?: sourceUrl, (req?.headers ?: mapOf()).toMutableMap())
|
||||
|
||||
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
|
||||
|
||||
val masterPlaylistContent = masterPlaylistResponse.body?.string()
|
||||
@@ -706,7 +758,7 @@ abstract class StateCasting {
|
||||
val variantPlaylist =
|
||||
HLS.parseVariantPlaylist(masterPlaylistContent, sourceUrl)
|
||||
val proxiedVariantPlaylist =
|
||||
proxyVariantPlaylist(url, id, variantPlaylist, video.isLive)
|
||||
proxyVariantPlaylist(url, id, variantPlaylist, video.isLive, requestModifier)
|
||||
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||
masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||
return@HttpFunctionHandler
|
||||
@@ -747,7 +799,7 @@ abstract class StateCasting {
|
||||
val variantPlaylist =
|
||||
HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url)
|
||||
val proxiedVariantPlaylist =
|
||||
proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive)
|
||||
proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive, requestModifier)
|
||||
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||
}.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
@@ -784,7 +836,7 @@ abstract class StateCasting {
|
||||
val variantPlaylist =
|
||||
HLS.parseVariantPlaylist(vpContent, mediaRendition.uri)
|
||||
val proxiedVariantPlaylist = proxyVariantPlaylist(
|
||||
url, playlistId, variantPlaylist, video.isLive
|
||||
url, playlistId, variantPlaylist, video.isLive, requestModifier
|
||||
)
|
||||
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||
@@ -826,13 +878,13 @@ abstract class StateCasting {
|
||||
return listOf(hlsUrl);
|
||||
}
|
||||
|
||||
private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, isLive: Boolean, proxySegments: Boolean = true): HLS.VariantPlaylist {
|
||||
private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, isLive: Boolean, requestModifier: IRequestModifier?, proxySegments: Boolean = true): HLS.VariantPlaylist {
|
||||
val newSegments = arrayListOf<HLS.Segment>()
|
||||
|
||||
if (proxySegments) {
|
||||
variantPlaylist.segments.forEachIndexed { index, segment ->
|
||||
val sequenceNumber = (variantPlaylist.mediaSequence ?: 0) + index.toLong()
|
||||
newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber))
|
||||
newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber, requestModifier))
|
||||
}
|
||||
} else {
|
||||
newSegments.addAll(variantPlaylist.segments)
|
||||
@@ -850,7 +902,7 @@ abstract class StateCasting {
|
||||
)
|
||||
}
|
||||
|
||||
private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long): HLS.Segment {
|
||||
private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long, requestModifier: IRequestModifier?): HLS.Segment {
|
||||
if (segment is HLS.MediaSegment) {
|
||||
val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}"
|
||||
val newSegmentUrl = url + newSegmentPath;
|
||||
@@ -858,6 +910,7 @@ abstract class StateCasting {
|
||||
if (_castServer.getHandler("GET", newSegmentPath) == null) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpProxyHandler("GET", newSegmentPath, segment.uri, true)
|
||||
.withIRequestModifier(requestModifier)
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castProxiedHlsVariant")
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.futo.platformplayer.engine.dev.V8RemoteObject
|
||||
import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.gsonStandard
|
||||
import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.serialize
|
||||
import com.futo.platformplayer.engine.packages.PackageHttp
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LoginFragment
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateAssets
|
||||
@@ -268,11 +269,15 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
context.respondCode(403, "This plugin doesn't support auth");
|
||||
return;
|
||||
}
|
||||
LoginFragment.showLogin(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) {
|
||||
_testPluginVariables.clear();
|
||||
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
|
||||
|
||||
};
|
||||
}; */
|
||||
context.respondCode(200, "Login started");
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
|
||||
@@ -48,6 +48,7 @@ class PluginUpdateDialog : AlertDialog {
|
||||
|
||||
private lateinit var _buttonCancel1: Button;
|
||||
private lateinit var _buttonCancel2: Button;
|
||||
private lateinit var _buttonAlways: LinearLayout;
|
||||
private lateinit var _buttonUpdate: LinearLayout;
|
||||
|
||||
private lateinit var _buttonOk: LinearLayout;
|
||||
@@ -58,6 +59,7 @@ class PluginUpdateDialog : AlertDialog {
|
||||
private lateinit var _textProgres: TextView;
|
||||
private lateinit var _textError: TextView;
|
||||
private lateinit var _textResult: TextView;
|
||||
private lateinit var _textChangelogResult: TextView;
|
||||
|
||||
private lateinit var _uiChoiceTop: FrameLayout;
|
||||
private lateinit var _uiProgressTop: FrameLayout;
|
||||
@@ -89,6 +91,7 @@ class PluginUpdateDialog : AlertDialog {
|
||||
|
||||
_buttonCancel1 = findViewById(R.id.button_cancel_1);
|
||||
_buttonCancel2 = findViewById(R.id.button_cancel_2);
|
||||
_buttonAlways = findViewById(R.id.button_always);
|
||||
_buttonUpdate = findViewById(R.id.button_update);
|
||||
|
||||
_buttonOk = findViewById(R.id.button_ok);
|
||||
@@ -99,6 +102,7 @@ class PluginUpdateDialog : AlertDialog {
|
||||
_textProgres = findViewById(R.id.text_progress);
|
||||
_textError = findViewById(R.id.text_error);
|
||||
_textResult = findViewById(R.id.text_result);
|
||||
_textChangelogResult = findViewById(R.id.text_changelog_result);
|
||||
|
||||
_uiChoiceTop = findViewById(R.id.dialog_ui_choice_top);
|
||||
_uiProgressTop = findViewById(R.id.dialog_ui_progress_top);
|
||||
@@ -119,17 +123,24 @@ class PluginUpdateDialog : AlertDialog {
|
||||
val changelog = _newConfig.changelog!![changelogVersion]!!;
|
||||
if(changelog.size > 1) {
|
||||
_textChangelog.text = "Changelog (${_newConfig.version})\n" + changelog.map { " - " + it.trim() }.joinToString("\n");
|
||||
_textChangelogResult.text = _textChangelog.text;
|
||||
}
|
||||
else if(changelog.size == 1) {
|
||||
_textChangelog.text = "Changelog (${_newConfig.version})\n" + changelog[0].trim();
|
||||
_textChangelogResult.text = _textChangelog.text;
|
||||
}
|
||||
else
|
||||
else {
|
||||
_textChangelog.visibility = View.GONE;
|
||||
} else
|
||||
_textChangelog.visibility = View.GONE;
|
||||
_textChangelogResult.visibility = View.GONE;
|
||||
}
|
||||
} else {
|
||||
_textChangelog.visibility = View.GONE;
|
||||
_textChangelogResult.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
_textChangelog.visibility = View.GONE;
|
||||
_textChangelogResult.visibility = View.GONE;
|
||||
Logger.e(TAG, "Invalid changelog? ", ex);
|
||||
}
|
||||
|
||||
@@ -145,6 +156,18 @@ class PluginUpdateDialog : AlertDialog {
|
||||
_isUpdating = true;
|
||||
update();
|
||||
};
|
||||
_buttonAlways.setOnClickListener {
|
||||
if (_isUpdating)
|
||||
return@setOnClickListener;
|
||||
val plugin = StatePlugins.instance.getPlugin(_oldConfig.id);
|
||||
if(plugin != null) {
|
||||
plugin.appSettings.automaticUpdate = true;
|
||||
StatePlugins.instance.savePlugin(_oldConfig.id);
|
||||
UIDialogs.appToast("Automatic update enabled, can be disabled in plugin settings");
|
||||
}
|
||||
_isUpdating = true;
|
||||
update();
|
||||
};
|
||||
|
||||
Glide.with(_iconPlugin)
|
||||
.load(_oldConfig.absoluteIconUrl)
|
||||
@@ -158,7 +181,8 @@ class PluginUpdateDialog : AlertDialog {
|
||||
if (_isUpdating)
|
||||
return;
|
||||
_isUpdating = true;
|
||||
update();
|
||||
|
||||
update(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,7 +191,7 @@ class PluginUpdateDialog : AlertDialog {
|
||||
super.dismiss();
|
||||
}
|
||||
|
||||
private fun update() {
|
||||
private fun update(automatic: Boolean = false) {
|
||||
_uiChoiceTop.visibility = View.GONE;
|
||||
_uiRiskTop.visibility = View.GONE;
|
||||
_uiChoiceBot.visibility = View.GONE;
|
||||
@@ -187,9 +211,16 @@ class PluginUpdateDialog : AlertDialog {
|
||||
val scope = StateApp.instance.scopeOrNull;
|
||||
scope?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
withContext(Dispatchers.Main) {
|
||||
_textProgres.setText("Loading current script file...");
|
||||
}
|
||||
val client = ManagedHttpClient();
|
||||
client.setTimeout(10000);
|
||||
val script = StatePlugins.instance.getScript(_oldConfig.id) ?: "";
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
_textProgres.setText("Requesting new script file...");
|
||||
}
|
||||
val newScript = client.get(_newConfig.absoluteScriptUrl)?.body?.string();
|
||||
if(newScript.isNullOrEmpty())
|
||||
throw IllegalStateException("No script found");
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.futo.platformplayer.downloads
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaMuxer
|
||||
import android.util.Log
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
@@ -8,6 +11,7 @@ import com.arthenica.ffmpegkit.StatisticsCallback
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
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.sources.AudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
@@ -136,6 +140,8 @@ class VideoDownload {
|
||||
|
||||
var hasVideoRequestExecutor: Boolean = false;
|
||||
var hasAudioRequestExecutor: Boolean = false;
|
||||
var hasVideoRequestModifier: Boolean = false;
|
||||
var hasAudioRequestModifier: Boolean = false;
|
||||
|
||||
var progress: Double = 0.0;
|
||||
var isCancelled = false;
|
||||
@@ -203,8 +209,10 @@ class VideoDownload {
|
||||
this.prepareTime = OffsetDateTime.now();
|
||||
this.hasVideoRequestExecutor = videoSource is JSSource && videoSource.hasRequestExecutor;
|
||||
this.hasAudioRequestExecutor = audioSource is JSSource && audioSource.hasRequestExecutor;
|
||||
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
|
||||
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
|
||||
this.hasVideoRequestModifier = videoSource is JSSource && videoSource.hasRequestModifier;
|
||||
this.hasAudioRequestModifier = audioSource is JSSource && audioSource.hasRequestModifier;
|
||||
this.requiresLiveVideoSource = this.hasVideoRequestModifier || this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
|
||||
this.requiresLiveAudioSource = this.hasAudioRequestModifier || this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
|
||||
this.targetVideoName = videoSource?.name;
|
||||
this.targetAudioName = audioSource?.name;
|
||||
this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null;
|
||||
@@ -478,8 +486,8 @@ class VideoDownload {
|
||||
|
||||
if(actualVideoSource is IVideoUrlSource)
|
||||
videoFileSize = when (videoSource!!.container) {
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
else -> downloadFileSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "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) {
|
||||
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback);
|
||||
@@ -518,8 +526,8 @@ class VideoDownload {
|
||||
|
||||
if(actualAudioSource is IAudioUrlSource)
|
||||
audioFileSize = when (audioSource!!.container) {
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
else -> downloadFileSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "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) {
|
||||
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback);
|
||||
@@ -580,83 +588,12 @@ class VideoDownload {
|
||||
return cipher.doFinal(encryptedSegment)
|
||||
}
|
||||
|
||||
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
|
||||
var downloadedTotalLength = 0L
|
||||
|
||||
val segmentFiles = arrayListOf<File>()
|
||||
try {
|
||||
val response = client.get(hlsUrl)
|
||||
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
|
||||
|
||||
val vpContent = response.body?.string()
|
||||
?: throw Exception("Variant playlist content is empty")
|
||||
|
||||
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
|
||||
val decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) {
|
||||
val keyResponse = client.get(variantPlaylist.decryptionInfo.keyUrl)
|
||||
check(keyResponse.isOk) { "HLS request failed for decryption key: ${keyResponse.code}" }
|
||||
DecryptionInfo(keyResponse.body!!.bytes(), variantPlaylist.decryptionInfo.iv?.hexStringToByteArray())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
variantPlaylist.segments.forEachIndexed { index, segment ->
|
||||
if (segment !is HLS.MediaSegment) {
|
||||
return@forEachIndexed
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Download '$name' segment $index Sequential");
|
||||
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
|
||||
val outputStream = segmentFile.outputStream()
|
||||
try {
|
||||
segmentFiles.add(segmentFile)
|
||||
|
||||
val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri, if (index == 0) null else decryptionInfo, index) { segmentLength, totalRead, lastSpeed ->
|
||||
val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index
|
||||
val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
|
||||
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
|
||||
}
|
||||
|
||||
downloadedTotalLength += segmentLength
|
||||
} finally {
|
||||
outputStream.close()
|
||||
}
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Combining segments into $targetFile");
|
||||
combineSegments(context, segmentFiles, targetFile)
|
||||
|
||||
Logger.i(TAG, "${name} downloadSource Finished");
|
||||
}
|
||||
catch(ioex: IOException) {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
if(ioex.message?.contains("ENOSPC") ?: false)
|
||||
throw Exception("Not enough space on device", ioex);
|
||||
else
|
||||
throw ioex;
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
throw ex;
|
||||
}
|
||||
finally {
|
||||
for (segmentFile in segmentFiles) {
|
||||
segmentFile.delete()
|
||||
}
|
||||
}
|
||||
return downloadedTotalLength;
|
||||
}
|
||||
|
||||
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val cmd =
|
||||
"-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\""
|
||||
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
|
||||
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
|
||||
|
||||
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
|
||||
val statisticsCallback = StatisticsCallback { _ ->
|
||||
//TODO: Show progress?
|
||||
}
|
||||
@@ -665,6 +602,7 @@ class VideoDownload {
|
||||
val session = FFmpegKit.executeAsync(cmd,
|
||||
{ session ->
|
||||
if (ReturnCode.isSuccess(session.returnCode)) {
|
||||
fileList.delete()
|
||||
continuation.resumeWith(Result.success(Unit))
|
||||
} else {
|
||||
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
|
||||
@@ -672,6 +610,7 @@ class VideoDownload {
|
||||
} else {
|
||||
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
|
||||
}
|
||||
fileList.delete()
|
||||
continuation.resumeWithException(RuntimeException(errorMessage))
|
||||
}
|
||||
},
|
||||
@@ -686,6 +625,237 @@ class VideoDownload {
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
targetFile.delete()
|
||||
|
||||
var downloadedTotalLength = 0L
|
||||
val modifier = if (source is JSSource && source.hasRequestModifier)
|
||||
source.getRequestModifier()
|
||||
else
|
||||
null
|
||||
|
||||
fun downloadBytes(url: String, rangeStart: Long? = null, rangeLength: Long? = null): ByteArray {
|
||||
val headers = mutableMapOf<String, String>()
|
||||
|
||||
if (rangeStart != null) {
|
||||
if (rangeLength != null && rangeLength > 0) {
|
||||
val end = rangeStart + rangeLength - 1
|
||||
headers["Range"] = "bytes=$rangeStart-$end"
|
||||
} else {
|
||||
headers["Range"] = "bytes=$rangeStart-"
|
||||
}
|
||||
}
|
||||
|
||||
val modified = modifier?.modifyRequest(url, headers)
|
||||
val finalUrl = modified?.url ?: url
|
||||
val finalHeaders = modified?.headers?.toMutableMap() ?: headers
|
||||
|
||||
val resp = client.get(finalUrl, finalHeaders)
|
||||
if (!resp.isOk) {
|
||||
resp.body?.close()
|
||||
throw IllegalStateException("Failed to download HLS resource ($finalUrl): HTTP ${resp.code}")
|
||||
}
|
||||
|
||||
val body = resp.body ?: throw IllegalStateException("Failed to download HLS resource ($finalUrl): Empty body")
|
||||
val bytes = body.bytes()
|
||||
body.close()
|
||||
return bytes
|
||||
}
|
||||
|
||||
fun buildSequenceIv(sequenceNumber: Long): ByteArray {
|
||||
return ByteBuffer.allocate(16)
|
||||
.putLong(0L)
|
||||
.putLong(sequenceNumber)
|
||||
.array()
|
||||
}
|
||||
|
||||
val segmentFiles = arrayListOf<File>()
|
||||
try {
|
||||
val playlistHeaders = mutableMapOf<String, String>()
|
||||
val modifiedPlaylistReq = modifier?.modifyRequest(hlsUrl, playlistHeaders)
|
||||
val playlistResp = client.get(
|
||||
modifiedPlaylistReq?.url ?: hlsUrl,
|
||||
modifiedPlaylistReq?.headers?.toMutableMap() ?: playlistHeaders
|
||||
)
|
||||
|
||||
check(playlistResp.isOk) { "Failed to get variant playlist: ${playlistResp.code}" }
|
||||
|
||||
val vpContent = playlistResp.body?.string()
|
||||
?: throw IllegalStateException("Variant playlist content is empty")
|
||||
|
||||
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
|
||||
val hlsDec = variantPlaylist.decryptionInfo
|
||||
val useDecryption = hlsDec != null && !hlsDec.method.equals("NONE", ignoreCase = true)
|
||||
var keyBytes: ByteArray? = null
|
||||
var staticIvBytes: ByteArray? = null
|
||||
|
||||
if (useDecryption) {
|
||||
if (!hlsDec.method.equals("AES-128", ignoreCase = true)) {
|
||||
throw UnsupportedOperationException("HLS decryption method '${hlsDec.method}' is not supported.")
|
||||
}
|
||||
|
||||
val keyUrl = hlsDec.keyUrl ?: throw IllegalStateException("Encrypted HLS playlist without key URI is not supported.")
|
||||
|
||||
keyBytes = downloadBytes(keyUrl)
|
||||
if (!hlsDec.iv.isNullOrEmpty()) {
|
||||
staticIvBytes = hlsDec.iv.hexStringToByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
val mediaSequence = variantPlaylist.mediaSequence ?: 0L
|
||||
val rangeOffsets = mutableMapOf<String, Long>()
|
||||
|
||||
if (!variantPlaylist.mapUrl.isNullOrEmpty()) {
|
||||
if (isCancelled) throw CancellationException("Cancelled")
|
||||
|
||||
Logger.i(TAG, "Downloading HLS initialization map")
|
||||
|
||||
var mapRangeStart: Long? = null
|
||||
var mapRangeLength: Long? = null
|
||||
|
||||
if (variantPlaylist.mapBytesLength > 0) {
|
||||
mapRangeLength = variantPlaylist.mapBytesLength
|
||||
|
||||
val mapUrl = variantPlaylist.mapUrl
|
||||
if (variantPlaylist.mapBytesStart >= 0) {
|
||||
mapRangeStart = variantPlaylist.mapBytesStart
|
||||
rangeOffsets[mapUrl] =
|
||||
variantPlaylist.mapBytesStart + variantPlaylist.mapBytesLength
|
||||
} else {
|
||||
val offset = rangeOffsets[mapUrl] ?: 0L
|
||||
mapRangeStart = offset
|
||||
rangeOffsets[mapUrl] = offset + variantPlaylist.mapBytesLength
|
||||
}
|
||||
}
|
||||
|
||||
var mapBytes = downloadBytes(variantPlaylist.mapUrl!!, mapRangeStart, mapRangeLength)
|
||||
|
||||
if (useDecryption) {
|
||||
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
|
||||
val iv = staticIvBytes
|
||||
?: throw UnsupportedOperationException("Encrypted EXT-X-MAP without explicit IV is not supported.")
|
||||
mapBytes = decryptSegment(mapBytes, kb, iv)
|
||||
}
|
||||
|
||||
if (mapBytes.size.toLong() > Int.MAX_VALUE) {
|
||||
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.flush()
|
||||
} finally {
|
||||
outStr.close()
|
||||
}
|
||||
downloadedTotalLength += mapBytes.size
|
||||
}
|
||||
|
||||
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")
|
||||
} catch (ioex: IOException) {
|
||||
if (targetFile.exists())
|
||||
targetFile.delete()
|
||||
if (ioex.message?.contains("ENOSPC") == true)
|
||||
throw Exception("Not enough space on device", ioex)
|
||||
else
|
||||
throw ioex
|
||||
} catch (ex: Throwable) {
|
||||
if (targetFile.exists())
|
||||
targetFile.delete()
|
||||
throw ex
|
||||
}
|
||||
finally {
|
||||
for (segmentFile in segmentFiles) {
|
||||
segmentFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
return downloadedTotalLength
|
||||
}
|
||||
|
||||
private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
@@ -715,6 +885,11 @@ class VideoDownload {
|
||||
source.getRequestExecutor();
|
||||
else
|
||||
null;
|
||||
|
||||
val modifier = if (source is JSSource && source.hasRequestModifier)
|
||||
source.getRequestModifier();
|
||||
else
|
||||
null;
|
||||
val speedTracker = SpeedTracker(1000);
|
||||
|
||||
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
|
||||
@@ -726,12 +901,14 @@ class VideoDownload {
|
||||
val t = cue.groupValues[1];
|
||||
val d = cue.groupValues[2];
|
||||
|
||||
|
||||
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
|
||||
val modified = modifier?.modifyRequest(url, mapOf());
|
||||
|
||||
val data = if(executor != null)
|
||||
executor.executeRequest("GET", url, null, mapOf());
|
||||
executor.executeRequest("GET", modified?.url ?: url, null, modified?.headers ?: mapOf());
|
||||
else {
|
||||
val resp = client.get(url, mutableMapOf());
|
||||
val resp = client.get(modified?.url ?: url, modified?.headers?.toMutableMap() ?: mutableMapOf());
|
||||
if(!resp.isOk)
|
||||
throw IllegalStateException("Dash request failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
|
||||
resp.body!!.bytes()
|
||||
@@ -766,7 +943,7 @@ class VideoDownload {
|
||||
}
|
||||
return sourceLength!!;
|
||||
}
|
||||
private fun downloadFileSource(name: String, client: ManagedHttpClient, 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())
|
||||
targetFile.delete();
|
||||
|
||||
@@ -775,7 +952,12 @@ class VideoDownload {
|
||||
val sourceLength: Long?;
|
||||
val fileStream = FileOutputStream(targetFile);
|
||||
|
||||
try{
|
||||
val modifier = if (source is JSSource && source.hasRequestModifier)
|
||||
source.getRequestModifier();
|
||||
else
|
||||
null;
|
||||
|
||||
try {
|
||||
val head = client.tryHead(videoUrl);
|
||||
val relatedPlugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null };
|
||||
if(Settings.instance.downloads.byteRangeDownload && head?.containsKey("accept-ranges") == true && head.containsKey("content-length"))
|
||||
@@ -786,12 +968,12 @@ class VideoDownload {
|
||||
Logger.i(TAG, "Download $name ByteRange Parallel (${concurrency}): " + videoUrl);
|
||||
sourceLength = head["content-length"]!!.toLong();
|
||||
onProgress(sourceLength, 0, 0);
|
||||
downloadSource_Ranges(name, client, fileStream, videoUrl, sourceLength, 1024*512, concurrency, onProgress);
|
||||
downloadSource_Ranges(name, client, modifier, fileStream, videoUrl, sourceLength, 1024*512, concurrency, onProgress);
|
||||
}
|
||||
else {
|
||||
Logger.i(TAG, "Download $name Sequential");
|
||||
try {
|
||||
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, 0, onProgress);
|
||||
sourceLength = downloadSource_Sequential(client, modifier, fileStream, videoUrl, null, 0, onProgress);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)")
|
||||
throw e
|
||||
@@ -842,7 +1024,7 @@ class VideoDownload {
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, index: Int, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
private fun downloadSource_Sequential(client: ManagedHttpClient, modifier: IRequestModifier? = null, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, index: Int, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
val progressRate: Int = 4096 * 5;
|
||||
var lastProgressCount: Int = 0;
|
||||
val speedRate: Int = 4096 * 5;
|
||||
@@ -851,7 +1033,12 @@ class VideoDownload {
|
||||
|
||||
var lastSpeed: Long = 0;
|
||||
|
||||
val result = client.get(url);
|
||||
val result = if (modifier != null) {
|
||||
val modified = modifier.modifyRequest(url, mapOf())
|
||||
client.get(modified.url!!, modified.headers.toMutableMap())
|
||||
} else {
|
||||
client.get(url)
|
||||
}
|
||||
if (!result.isOk) {
|
||||
result.body?.close()
|
||||
throw IllegalStateException("Failed to download source. Web[${result.code}] Error");
|
||||
@@ -988,7 +1175,7 @@ class VideoDownload {
|
||||
onProgress(sourceLength, totalRead, 0)
|
||||
return sourceLength
|
||||
}*/
|
||||
private fun downloadSource_Ranges(name: String, client: ManagedHttpClient, fileStream: FileOutputStream, url: String, sourceLength: Long, rangeSize: Int, concurrency: Int = 1, onProgress: (Long, Long, Long) -> Unit) {
|
||||
private fun downloadSource_Ranges(name: String, client: ManagedHttpClient, modifier: IRequestModifier?, fileStream: FileOutputStream, url: String, sourceLength: Long, rangeSize: Int, concurrency: Int = 1, onProgress: (Long, Long, Long) -> Unit) {
|
||||
val progressRate: Int = 4096 * 5;
|
||||
var lastProgressCount: Int = 0;
|
||||
val speedRate: Int = 4096 * 5;
|
||||
@@ -1007,7 +1194,7 @@ class VideoDownload {
|
||||
|
||||
Logger.i(TAG, "Download ${name} Batch #${reqCount} [${concurrency}] (${lastSpeed.toHumanBytesSpeed()})");
|
||||
|
||||
val byteRangeResults = requestByteRangeParallel(client, pool, url, sourceLength, concurrency, totalRead,
|
||||
val byteRangeResults = requestByteRangeParallel(client, pool, modifier, url, sourceLength, concurrency, totalRead,
|
||||
rangeSize, 1024 * 64);
|
||||
|
||||
for(byteRange in byteRangeResults) {
|
||||
@@ -1038,7 +1225,7 @@ class VideoDownload {
|
||||
onProgress(sourceLength, totalRead, 0);
|
||||
}
|
||||
|
||||
private fun requestByteRangeParallel(client: ManagedHttpClient, pool: ForkJoinPool, url: String, totalLength: Long, concurrency: Int, rangePosition: Long, rangeSize: Int, rangeVariance: Int = -1): List<Triple<ByteArray, Long, Long>> {
|
||||
private fun requestByteRangeParallel(client: ManagedHttpClient, pool: ForkJoinPool, modifier: IRequestModifier?, url: String, totalLength: Long, concurrency: Int, rangePosition: Long, rangeSize: Int, rangeVariance: Int = -1): List<Triple<ByteArray, Long, Long>> {
|
||||
val tasks = mutableListOf<ForkJoinTask<Triple<ByteArray, Long, Long>>>();
|
||||
var readPosition = rangePosition;
|
||||
for(i in 0 until concurrency) {
|
||||
@@ -1052,21 +1239,25 @@ class VideoDownload {
|
||||
else readPosition + toRead;
|
||||
|
||||
tasks.add(pool.submit<Triple<ByteArray, Long, Long>> {
|
||||
return@submit requestByteRange(client, url, rangeStart, rangeEnd);
|
||||
return@submit requestByteRange(client, modifier, url, rangeStart, rangeEnd);
|
||||
});
|
||||
readPosition = rangeEnd + 1;
|
||||
}
|
||||
|
||||
return tasks.map { it.get() };
|
||||
}
|
||||
private fun requestByteRange(client: ManagedHttpClient, url: String, rangeStart: Long, rangeEnd: Long): Triple<ByteArray, Long, Long> {
|
||||
private fun requestByteRange(client: ManagedHttpClient, modifier: IRequestModifier?, url: String, rangeStart: Long, rangeEnd: Long): Triple<ByteArray, Long, Long> {
|
||||
var retryCount = 0
|
||||
var lastException: Throwable? = null
|
||||
var lastException: Throwable? = null;
|
||||
|
||||
val headers = mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}"));
|
||||
val modified = modifier?.modifyRequest(url, headers);
|
||||
|
||||
while (retryCount <= 3) {
|
||||
try {
|
||||
val toRead = rangeEnd - rangeStart;
|
||||
val req = client.get(url, mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}")));
|
||||
|
||||
val req = client.get(modified?.url ?: url, modified?.headers?.toMutableMap() ?: headers);
|
||||
if (!req.isOk) {
|
||||
val bodyString = req.body?.string()
|
||||
req.body?.close()
|
||||
|
||||
@@ -23,10 +23,7 @@ object Libcurl {
|
||||
var body: ByteArray? = null,
|
||||
var impersonateTarget: String = "chrome136",
|
||||
var useBuiltInHeaders: Boolean = true,
|
||||
var timeoutMs: Int = 30_000,
|
||||
var cookieJarPath: String? = null,
|
||||
var sendCookies: Boolean = true,
|
||||
var persistCookies: Boolean = true,
|
||||
var timeoutMs: Int = 30_000
|
||||
)
|
||||
|
||||
@Keep
|
||||
@@ -121,12 +118,6 @@ object Libcurl {
|
||||
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
|
||||
if (!method.equals("GET", ignoreCase = true)) {
|
||||
checkOK(ce_setopt_str(easy, CURLOPT.CUSTOMREQUEST, method))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -6,7 +6,7 @@ import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
|
||||
|
||||
open class MainActivityFragment : Fragment() {
|
||||
protected val currentMain : MainFragment
|
||||
protected val currentMain : MainFragment?
|
||||
get() {
|
||||
isValidMainActivity();
|
||||
return (activity as MainActivity).fragCurrent;
|
||||
|
||||
+198
-19
@@ -8,18 +8,25 @@ import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.animation.doOnEnd
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.fragment.mainactivity.MainActivityFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.*
|
||||
@@ -27,6 +34,10 @@ import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePayment
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.views.AnyAdapterView
|
||||
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
|
||||
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||
import com.futo.platformplayer.views.pills.RoundButton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.floor
|
||||
@@ -69,9 +80,15 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
private val _inflater: LayoutInflater;
|
||||
private val _subscribedActivity: MainActivity?;
|
||||
|
||||
private val _containerMoreHeader: ConstraintLayout;
|
||||
private val _toggleAirplaneMode: LinearLayout;
|
||||
private val _togglePrivacy: LinearLayout;
|
||||
|
||||
private var _overlayMore: FrameLayout;
|
||||
private var _overlayMoreBackground: FrameLayout;
|
||||
private var _layoutMoreButtons: LinearLayout;
|
||||
private var _layoutMoreButtons: RecyclerView;
|
||||
private val _layoutMoreButtonItems = arrayListOf<MenuButtonItem>();
|
||||
private var _layoutMoreButtonsAdapter: AnyAdapterView<MenuButtonItem, MenuButtonItemViewHolder>;
|
||||
private var _layoutBottomBarButtons: LinearLayout;
|
||||
|
||||
private var _moreVisible = false;
|
||||
@@ -85,15 +102,79 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
|
||||
private var currentButtonDefinitions: List<ButtonDefinition>? = null;
|
||||
|
||||
private var moreColumns = 3;
|
||||
|
||||
constructor(fragment: MenuBottomBarFragment, inflater: LayoutInflater) : super(inflater.context) {
|
||||
_fragment = fragment;
|
||||
_inflater = inflater;
|
||||
inflater.inflate(R.layout.fragment_overview_bottom_bar, this);
|
||||
|
||||
_containerMoreHeader = findViewById(R.id.container_more_options);
|
||||
_toggleAirplaneMode = findViewById(R.id.container_toggle_airplane);
|
||||
_togglePrivacy = findViewById(R.id.container_toggle_privacy);
|
||||
|
||||
_toggleAirplaneMode.isVisible = false //TODO: Remove when airplane mode implemented
|
||||
|
||||
StateApp.instance.airplaneModeChanged.subscribe {
|
||||
if(!StateApp.instance.airplaneMode)
|
||||
_toggleAirplaneMode.setBackgroundResource(R.drawable.background_menu_toggle)
|
||||
else
|
||||
_toggleAirplaneMode.setBackgroundResource(R.drawable.background_menu_toggle_active)
|
||||
}
|
||||
if(!StateApp.instance.airplaneMode)
|
||||
_toggleAirplaneMode.setBackgroundResource(R.drawable.background_menu_toggle)
|
||||
else
|
||||
_toggleAirplaneMode.setBackgroundResource(R.drawable.background_menu_toggle_active)
|
||||
_toggleAirplaneMode.setOnClickListener {
|
||||
if(StateApp.instance.airplaneMode) {
|
||||
StateApp.instance.setAirMode(false);
|
||||
UIDialogs.appToast("Airplane mode disabled");
|
||||
}
|
||||
else {
|
||||
StateApp.instance.setAirMode(true);
|
||||
UIDialogs.appToast("Airplane mode enabled");
|
||||
}
|
||||
}
|
||||
|
||||
StateApp.instance.privateModeChanged.subscribe {
|
||||
if(!StateApp.instance.privateMode)
|
||||
_togglePrivacy.setBackgroundResource(R.drawable.background_menu_toggle)
|
||||
else
|
||||
_togglePrivacy.setBackgroundResource(R.drawable.background_menu_toggle_active)
|
||||
}
|
||||
if(!StateApp.instance.privateMode)
|
||||
_togglePrivacy.setBackgroundResource(R.drawable.background_menu_toggle)
|
||||
else
|
||||
_togglePrivacy.setBackgroundResource(R.drawable.background_menu_toggle_active)
|
||||
_togglePrivacy.setOnClickListener {
|
||||
if(StateApp.instance.privateMode) {
|
||||
StateApp.instance.setPrivacyMode(false);
|
||||
UIDialogs.appToast("Privacy mode disabled");
|
||||
}
|
||||
else {
|
||||
StateApp.instance.setPrivacyMode(true);
|
||||
UIDialogs.appToast("Privacy mode enabled");
|
||||
}
|
||||
}
|
||||
|
||||
_overlayMore = findViewById(R.id.more_overlay);
|
||||
_overlayMoreBackground = findViewById(R.id.more_overlay_background);
|
||||
_layoutMoreButtons = findViewById(R.id.more_menu_buttons);
|
||||
_layoutBottomBarButtons = findViewById(R.id.bottom_bar_buttons)
|
||||
_layoutBottomBarButtons = findViewById(R.id.bottom_bar_buttons);
|
||||
|
||||
val totalWidthDp = resources.displayMetrics.widthPixels / resources.displayMetrics.density;
|
||||
val columns = MenuButtonItemViewHolder.getAutoSizeColumns(totalWidthDp);
|
||||
_layoutMoreButtonsAdapter = _layoutMoreButtons.asAny<MenuButtonItem, MenuButtonItemViewHolder>(_layoutMoreButtonItems,
|
||||
RecyclerView.VERTICAL, false, { button ->
|
||||
button.setAutoSize(totalWidthDp);
|
||||
button.parentFragment = this@MenuBottomBarView._fragment;
|
||||
button.onClick.subscribe {
|
||||
setMoreVisible(false);
|
||||
}
|
||||
})
|
||||
moreColumns = columns;
|
||||
val layoutManager = GridLayoutManager(context, columns, GridLayoutManager.VERTICAL, true);
|
||||
_layoutMoreButtons.layoutManager = layoutManager;
|
||||
|
||||
_overlayMoreBackground.setOnClickListener { setMoreVisible(false); };
|
||||
|
||||
@@ -120,6 +201,8 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
}
|
||||
|
||||
private fun setMoreVisible(visible: Boolean) {
|
||||
|
||||
//TODO: issues with these bools
|
||||
if (_moreVisibleAnimating) {
|
||||
return
|
||||
}
|
||||
@@ -128,9 +211,12 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
val height = _moreButtons.firstOrNull()?.let {
|
||||
it.height.toFloat() + (it.layoutParams as MarginLayoutParams).bottomMargin
|
||||
} ?: return
|
||||
*/
|
||||
|
||||
_moreVisibleAnimating = true
|
||||
val moreOverlayBackground = _overlayMoreBackground
|
||||
@@ -142,14 +228,17 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
moreOverlay.visibility = VISIBLE
|
||||
val animations = arrayListOf<Animator>()
|
||||
animations.add(ObjectAnimator.ofFloat(moreOverlayBackground, "alpha", 0.0f, 1.0f).setDuration(duration))
|
||||
animations.add(ObjectAnimator.ofFloat(_layoutMoreButtons, "alpha", 0.0f, 1.0f).setDuration(duration))
|
||||
animations.add(ObjectAnimator.ofFloat(_containerMoreHeader, "alpha", 0.0f, 1.0f).setDuration(duration))
|
||||
_bottomButtons.find { it.definition.id == 99 }?.let {
|
||||
animations.add(ObjectAnimator.ofFloat(it, "alpha", 0.4f, 1.0f)
|
||||
animations.add(ObjectAnimator.ofFloat(it, "alpha", 0.5f, 1.0f)
|
||||
.setDuration(duration));
|
||||
}
|
||||
|
||||
animations.add(ObjectAnimator.ofFloat(_layoutMoreButtons, "translationY", resources.displayMetrics.heightPixels.toFloat(), 0.0f).setDuration(duration))
|
||||
for ((index, button) in _moreButtons.withIndex()) {
|
||||
val i = _moreButtons.size - index
|
||||
animations.add(ObjectAnimator.ofFloat(button, "translationY", height * staggerFactor * (i + 1), 0.0f).setDuration(duration))
|
||||
//animations.add(ObjectAnimator.ofFloat(button, "translationY", height * staggerFactor * (i + 1), 0.0f).setDuration(duration))
|
||||
}
|
||||
|
||||
val animatorSet = AnimatorSet()
|
||||
@@ -164,14 +253,21 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
animations
|
||||
.add(ObjectAnimator.ofFloat(moreOverlayBackground, "alpha", 1.0f, 0.0f)
|
||||
.setDuration(duration))
|
||||
animations
|
||||
.add(ObjectAnimator.ofFloat(_layoutMoreButtons, "alpha", 1.0f, 0.0f)
|
||||
.setDuration(duration))
|
||||
animations
|
||||
.add(ObjectAnimator.ofFloat(_containerMoreHeader, "alpha", 1.0f, 0.0f)
|
||||
.setDuration(duration))
|
||||
_bottomButtons.find { it.definition.id == 99 }?.let {
|
||||
animations.add(ObjectAnimator.ofFloat(it, "alpha", 1.0f, 0.4f)
|
||||
animations.add(ObjectAnimator.ofFloat(it, "alpha", 1.0f, 0.5f)
|
||||
.setDuration(duration));
|
||||
}
|
||||
|
||||
animations.add(ObjectAnimator.ofFloat(_layoutMoreButtons, "translationY", 0.0f, resources.displayMetrics.heightPixels.toFloat()).setDuration(duration))
|
||||
for ((index, button) in _moreButtons.withIndex()) {
|
||||
val i = _moreButtons.size - index
|
||||
animations.add(ObjectAnimator.ofFloat(button, "translationY", 0.0f, height * staggerFactor * (i + 1)).setDuration(duration))
|
||||
//animations.add(ObjectAnimator.ofFloat(button, "translationY", 0.0f, height * staggerFactor * (i + 1)).setDuration(duration))
|
||||
}
|
||||
|
||||
val animatorSet = AnimatorSet()
|
||||
@@ -183,11 +279,12 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
animatorSet.playTogether(animations)
|
||||
animatorSet.start()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun updateBottomMenuButtons(buttons: MutableList<ButtonDefinition>, hasMore: Boolean) {
|
||||
if (hasMore) {
|
||||
buttons.add(ButtonDefinition(99, R.drawable.ic_more, R.drawable.ic_more, R.string.more, canToggle = false, { false }, { setMoreVisible(true) }))
|
||||
buttons.add(ButtonDefinition(99, R.drawable.ic_more, R.drawable.ic_more, R.string.more, canToggle = false, { false }, { setMoreVisible(!_moreVisible) }))
|
||||
}
|
||||
|
||||
_bottomButtons.clear();
|
||||
@@ -227,32 +324,42 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
_layoutMoreButtons.removeAllViews();
|
||||
|
||||
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
|
||||
val buyIndex = buttons.indexOfFirst { b -> b.id == 98 };
|
||||
if (buyIndex != -1) {
|
||||
val button = buttons[buyIndex]
|
||||
buttons.removeAt(buyIndex)
|
||||
buttons.add(0, button)
|
||||
insertedButtons++;
|
||||
buttons.add(button)
|
||||
//insertedButtons++;
|
||||
}
|
||||
//Force faq to be second
|
||||
val faqIndex = buttons.indexOfFirst { b -> b.id == 97 };
|
||||
if (faqIndex != -1) {
|
||||
val button = buttons[faqIndex]
|
||||
buttons.removeAt(faqIndex)
|
||||
buttons.add(if (insertedButtons == 1) 1 else 0, button)
|
||||
insertedButtons++;
|
||||
buttons.add(button)
|
||||
//insertedButtons++;
|
||||
}
|
||||
//Force privacy to be third
|
||||
val privacyIndex = buttons.indexOfFirst { b -> b.id == 96 };
|
||||
if (privacyIndex != -1) {
|
||||
val button = buttons[privacyIndex]
|
||||
buttons.removeAt(privacyIndex)
|
||||
buttons.add(if (insertedButtons == 2) 2 else (if(insertedButtons == 1) 1 else 0), button)
|
||||
insertedButtons++;
|
||||
buttons.add(button)
|
||||
//insertedButtons++;
|
||||
}
|
||||
|
||||
val newButtons = mutableListOf<MenuButtonItem>();
|
||||
for (data in buttons) {
|
||||
/*
|
||||
val button = MenuButton(context, data, _fragment, true);
|
||||
button.setOnClickListener {
|
||||
updateMenuIcons()
|
||||
@@ -262,7 +369,12 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
|
||||
_moreButtons.add(button);
|
||||
_layoutMoreButtons.addView(button);
|
||||
*/
|
||||
val buttonItem = MenuButtonItem(data);
|
||||
newButtons.add(buttonItem);
|
||||
}
|
||||
_layoutMoreButtonsAdapter.setData(newButtons);
|
||||
_layoutMoreButtonsAdapter.notifyContentChanged();
|
||||
}
|
||||
|
||||
private fun updateMenuIcons() {
|
||||
@@ -350,6 +462,71 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
}
|
||||
|
||||
|
||||
class MenuButtonItem(val def: ButtonDefinition);
|
||||
class MenuButtonItemViewHolder(private val _viewGroup: ViewGroup): AnyAdapter.AnyViewHolder<MenuButtonItem>(
|
||||
LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_menu_tile,
|
||||
_viewGroup, false)) {
|
||||
|
||||
val onClick = Event1<MenuButtonItem>();
|
||||
|
||||
val root: ConstraintLayout;
|
||||
val imageIcon: ImageView;
|
||||
val textName: TextView;
|
||||
|
||||
|
||||
var button: MenuButtonItem? = null;
|
||||
|
||||
var parentFragment: MenuBottomBarFragment? = null;
|
||||
|
||||
init {
|
||||
root = _view.findViewById(R.id.root);
|
||||
imageIcon = _view.findViewById(R.id.image_icon);
|
||||
textName = _view.findViewById(R.id.text_name);
|
||||
|
||||
root.setOnClickListener {
|
||||
button?.let {
|
||||
it.def.action(parentFragment ?: return@let);
|
||||
onClick.emit(it);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun bind(value: MenuButtonItem) {
|
||||
button = value;
|
||||
textName.text = _view.context.getString(value.def.string);
|
||||
imageIcon.setImageResource(value.def.iconActive);
|
||||
}
|
||||
|
||||
|
||||
fun setWidth(dp: Int) {
|
||||
root.updateLayoutParams {
|
||||
this.width = (dp - 6).dp(_viewGroup.context.resources);
|
||||
this.height = (dp - 6).dp(_viewGroup.context.resources);
|
||||
}
|
||||
imageIcon.updateLayoutParams {
|
||||
this.width = (dp - 54).dp(_viewGroup.context.resources);
|
||||
this.height = (dp - 54).dp(_viewGroup.context.resources);
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutoSize(totalWidth: Float) {
|
||||
val dpWidth = totalWidth;
|
||||
val columns = Math.max(((dpWidth) / viewWidthDp).toInt(), 1);
|
||||
val remainder = dpWidth - columns * viewWidthDp;
|
||||
val targetSize = viewWidthDp + (remainder / columns).toInt();
|
||||
setWidth(targetSize);
|
||||
}
|
||||
|
||||
companion object {
|
||||
val viewWidthDp = 90;
|
||||
fun getAutoSizeColumns(totalWidth: Float): Int {
|
||||
val dpWidth = totalWidth;
|
||||
val columns = Math.max(((dpWidth) / viewWidthDp).toInt(), 1);
|
||||
return columns;
|
||||
}
|
||||
}
|
||||
}
|
||||
class MenuButton: LinearLayout {
|
||||
val definition: ButtonDefinition;
|
||||
|
||||
@@ -369,7 +546,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
this.alpha = 1f;
|
||||
}
|
||||
else {
|
||||
this.alpha = 0.4f;
|
||||
this.alpha = 0.5f;
|
||||
}
|
||||
|
||||
_textButton = findViewById(R.id.text_button);
|
||||
@@ -389,7 +566,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
this.alpha = 1f;
|
||||
}
|
||||
else {
|
||||
this.alpha = 0.4f;
|
||||
this.alpha = 0.5f;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -413,7 +590,9 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
}
|
||||
}),
|
||||
ButtonDefinition(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate<SubscriptionsFeedFragment>(withHistory = false) }),
|
||||
ButtonDefinition(12, R.drawable.ic_library, R.drawable.ic_library, R.string.library, canToggle = false, { it.currentMain is LibraryFragment }, { it.navigate<LibraryFragment>(withHistory = false) }),
|
||||
//if(Build.VERSION.SDK_INT > Build.VERSION_CODES.P)
|
||||
ButtonDefinition(12, R.drawable.ic_library, R.drawable.ic_library, R.string.library, canToggle = false, { it.currentMain is LibraryFragment }, { it.navigate<LibraryFragment>(withHistory = false) })
|
||||
,//else null,
|
||||
ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate<CreatorsFragment>(withHistory = false) }),
|
||||
ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>(withHistory = false) }),
|
||||
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>(withHistory = false) }),
|
||||
@@ -434,7 +613,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
if (c is Activity) {
|
||||
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 }, {
|
||||
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,
|
||||
@@ -444,14 +623,14 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
UIDialogs.Action("Enable", {
|
||||
StateApp.instance.setPrivacyMode(true);
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}),
|
||||
}),*/
|
||||
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);
|
||||
})
|
||||
//96 is reserved for privacy button
|
||||
//98 is reserved for buy button
|
||||
//99 is reserved for more button
|
||||
);
|
||||
).filterNotNull();
|
||||
}
|
||||
|
||||
data class ButtonDefinition(
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.states.Album
|
||||
import com.futo.platformplayer.states.Artist
|
||||
import com.futo.platformplayer.states.ArtistOrdering
|
||||
import com.futo.platformplayer.states.FileEntry
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateLibrary
|
||||
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
|
||||
import com.futo.platformplayer.views.AnyInsertedAdapterView
|
||||
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop
|
||||
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithViews
|
||||
import com.futo.platformplayer.views.LibrarySection
|
||||
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapter
|
||||
import com.futo.platformplayer.views.adapters.viewholders.AlbumTileViewHolder
|
||||
import com.futo.platformplayer.views.adapters.viewholders.ArtistTileViewHolder
|
||||
import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder
|
||||
import com.futo.platformplayer.views.adapters.viewholders.LocalVideoTileViewHolder
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
|
||||
|
||||
class BaseFragment : MainFragment() {
|
||||
override val isMainView : Boolean = true;
|
||||
override val isTab: Boolean = true;
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
private var view: FragView? = null;
|
||||
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val newView = FragView(this);
|
||||
view = newView;
|
||||
return newView;
|
||||
}
|
||||
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack);
|
||||
view?.onShown();
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
view = null;
|
||||
super.onDestroyMainView();
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance() = BaseFragment().apply {}
|
||||
}
|
||||
|
||||
|
||||
class FragView: ConstraintLayout {
|
||||
val fragment: BaseFragment;
|
||||
|
||||
constructor(fragment: BaseFragment) : super(fragment.requireContext()) {
|
||||
inflate(context, R.layout.fragview_library, this);
|
||||
this.fragment = fragment;
|
||||
}
|
||||
|
||||
|
||||
fun onShown() {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
@@ -39,7 +40,7 @@ import java.time.OffsetDateTime
|
||||
import kotlin.math.max
|
||||
|
||||
abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : LinearLayout where TPager : IPager<TResult>, TViewHolder : RecyclerView.ViewHolder, TFragment : MainFragment {
|
||||
protected val _feedRoot: FrameLayout;
|
||||
protected val _feedRoot: ConstraintLayout;
|
||||
protected val _recyclerResults: RecyclerView;
|
||||
protected val _overlayContainer: FrameLayout;
|
||||
protected val _swipeRefresh: SwipeRefreshLayout;
|
||||
@@ -52,6 +53,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
private val _emptyPagerContainer: FrameLayout;
|
||||
|
||||
protected val _toolbarContentView: LinearLayout;
|
||||
protected val _bottomContentView: LinearLayout;
|
||||
|
||||
private var _loading: Boolean = true;
|
||||
|
||||
@@ -136,6 +138,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
setActiveTags(null);
|
||||
|
||||
_toolbarContentView = findViewById(R.id.container_toolbar_content);
|
||||
_bottomContentView = findViewById(R.id.container_bottom);
|
||||
|
||||
_nextPageHandler = TaskHandler<TPager, Pair<TPager, List<TResult>>>({fragment.lifecycleScope}, {
|
||||
if (it is IAsyncPager<*>)
|
||||
@@ -481,7 +484,8 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
recyclerData.resultsUnfiltered.addAll(toAdd);
|
||||
recyclerData.adapter.notifyDataSetChanged();
|
||||
recyclerData.loadedFeedStyle = feedStyle;
|
||||
ensureEnoughContentVisible(filteredResults)
|
||||
if(pager.hasMorePages())
|
||||
ensureEnoughContentVisible(filteredResults)
|
||||
}
|
||||
|
||||
private fun detachPagerEvents() {
|
||||
|
||||
+15
-3
@@ -26,6 +26,7 @@ import com.futo.platformplayer.models.HistoryVideo
|
||||
import com.futo.platformplayer.states.StateHistory
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.views.ToggleBar
|
||||
import com.futo.platformplayer.views.adapters.HistoryListViewHolder
|
||||
@@ -243,12 +244,23 @@ class HistoryFragment : MainFragment() {
|
||||
return;
|
||||
}
|
||||
|
||||
val inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
|
||||
val diff = v.video.duration - v.position;
|
||||
val vid: Any = if (diff > 5) { v.video.withTimestamp(v.position) } else { v.video };
|
||||
StatePlayer.instance.clearQueue();
|
||||
_fragment.navigate<VideoDetailFragment>(vid).maximizeVideoDetail();
|
||||
|
||||
val playlistId = v.playlistId
|
||||
val playlist = playlistId?.let { StatePlaylists.instance.getPlaylist(it) }
|
||||
val playlistIndex = playlist?.videos?.indexOfFirst { it.url == v.video.url }
|
||||
if (playlist != null && playlistIndex != null && playlistIndex >= 0) {
|
||||
_fragment.navigate<VideoDetailFragment>(vid).maximizeVideoDetail();
|
||||
StatePlayer.instance.setPlaylist(playlist, playlistIndex)
|
||||
|
||||
} else {
|
||||
StatePlayer.instance.clearQueue();
|
||||
_fragment.navigate<VideoDetailFragment>(vid).maximizeVideoDetail();
|
||||
}
|
||||
|
||||
_editSearch.clearFocus();
|
||||
val inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
|
||||
inputMethodManager.hideSoftInputFromWindow(_editSearch.windowToken, 0);
|
||||
|
||||
_fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
|
||||
+3
-1
@@ -365,8 +365,10 @@ class HomeFragment : MainFragment() {
|
||||
finishRefreshLayoutLoader();
|
||||
setLoading(false);
|
||||
setPager(pager);
|
||||
if(pager.getResults().isEmpty() && !pager.hasMorePages())
|
||||
if(pager.getResults().isEmpty() && !pager.hasMorePages()) {
|
||||
setLoading(false);
|
||||
setEmptyPager(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-6
@@ -14,6 +14,7 @@ import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@@ -22,6 +23,7 @@ import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
@@ -319,8 +321,7 @@ class LibraryArtistFragment : MainFragment() {
|
||||
|
||||
_fragment.topBar?.onShown(channel)
|
||||
|
||||
val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) {
|
||||
})
|
||||
val buttons = arrayListOf<Pair<Int, ()->Unit>>();
|
||||
|
||||
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.contentUrl ?: return@launch)
|
||||
@@ -337,8 +338,7 @@ class LibraryArtistFragment : MainFragment() {
|
||||
}
|
||||
|
||||
_buttonSubscribe.visibility = GONE;
|
||||
_buttonSubscriptionSettings.visibility =
|
||||
if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE
|
||||
_buttonSubscriptionSettings.visibility = View.GONE
|
||||
_textChannel.text = channel.name
|
||||
_textChannelSub.text = "${channel.countTracks} songs, ${channel.countAlbums} albums";
|
||||
|
||||
@@ -361,7 +361,21 @@ class LibraryArtistFragment : MainFragment() {
|
||||
(_viewPager.adapter as ArtistViewPagerAdapter).artist = channel
|
||||
|
||||
|
||||
_viewPager.adapter!!.notifyDataSetChanged()
|
||||
_viewPager.adapter!!.notifyDataSetChanged();
|
||||
|
||||
val artistThumbnail = channel.getThumbnailOrAlbum();
|
||||
if(artistThumbnail != null) {
|
||||
_creatorThumbnail.isVisible = true;
|
||||
_creatorThumbnail.setThumbnail(channel.getThumbnailOrAlbum(), true, true);
|
||||
Glide.with(_imageBanner)
|
||||
.load(artistThumbnail)
|
||||
.into(_imageBanner);
|
||||
}
|
||||
else {
|
||||
_creatorThumbnail.isVisible = false;
|
||||
Glide.with(_imageBanner).clear(_imageBanner);
|
||||
}
|
||||
|
||||
|
||||
this.channel = channel
|
||||
}
|
||||
@@ -506,7 +520,10 @@ class LibraryArtistFragment : MainFragment() {
|
||||
|
||||
val playlist = _artist?.toPlaylist();
|
||||
if (playlist != null) {
|
||||
val index = playlist.videos.indexOf(c);
|
||||
val sameVideo = playlist.videos.find { it.name == c.name };
|
||||
val index = sameVideo?.let {
|
||||
playlist.videos.indexOf(sameVideo)
|
||||
} ?: -1;
|
||||
if (index == -1)
|
||||
return@subscribe;
|
||||
|
||||
|
||||
+35
-5
@@ -8,25 +8,32 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.api.media.models.video.LocalVideoDetails
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.structures.AdhocPager
|
||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.FilesTopBarFragment
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
import com.futo.platformplayer.states.FileEntry
|
||||
import com.futo.platformplayer.states.StateLibrary
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.NoResultsView
|
||||
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||
import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.futo.platformplayer.views.buttons.ButtonsContainer
|
||||
|
||||
class LibraryFilesFragment : MainFragment() {
|
||||
override val isMainView : Boolean = true;
|
||||
@@ -70,6 +77,7 @@ class LibraryFilesFragment : MainFragment() {
|
||||
private var root: FileEntry? = null;
|
||||
|
||||
constructor(fragment: LibraryFilesFragment, inflater: LayoutInflater) : super(fragment, inflater) {
|
||||
disableRefreshLayout();
|
||||
}
|
||||
|
||||
fun onShown(parameter: Any? = null) {
|
||||
@@ -78,6 +86,7 @@ class LibraryFilesFragment : MainFragment() {
|
||||
}
|
||||
fun loadTop() {
|
||||
var initialDirectories = listOf<FileEntry>();
|
||||
var path = "";
|
||||
if(root == null) {
|
||||
initialDirectories = StateLibrary.instance.getFileDirectories();
|
||||
if (initialDirectories.size == 0) {
|
||||
@@ -101,9 +110,10 @@ class LibraryFilesFragment : MainFragment() {
|
||||
it.isVisible = false;
|
||||
}
|
||||
initialDirectories = root?.getSubFiles() ?: listOf();
|
||||
path = root?.path ?: "";
|
||||
}
|
||||
navStack.clear();
|
||||
val entry = FileStack("", initialDirectories);
|
||||
val entry = FileStack(path, initialDirectories);
|
||||
navStack.add(entry);
|
||||
openDirectory(navStack.last());
|
||||
fragment.topBar?.let {
|
||||
@@ -114,11 +124,10 @@ class LibraryFilesFragment : MainFragment() {
|
||||
}
|
||||
}
|
||||
fun leaveDirectory() {
|
||||
if(navStack.size > 1) {
|
||||
navStack.removeLast();
|
||||
openDirectory(navStack.last());
|
||||
if (navStack.size > 1) {
|
||||
navStack.removeAt(navStack.size - 1)
|
||||
openDirectory(navStack.last())
|
||||
}
|
||||
else {}
|
||||
}
|
||||
fun openDirectory(stack: FileStack, addToStack: Boolean = false) {
|
||||
if(addToStack)
|
||||
@@ -139,6 +148,27 @@ class LibraryFilesFragment : MainFragment() {
|
||||
setPager(AdhocPager<FileEntry>({ listOf(); }, stack.files));
|
||||
setLoading(false);
|
||||
|
||||
val allSongs = stack.files.filter { !it.isDirectory };
|
||||
if(allSongs.any()) {
|
||||
_bottomContentView.addView(ButtonsContainer(context,
|
||||
listOf(
|
||||
ButtonsContainer.Button("Play All", R.drawable.background_button_primary) {
|
||||
StatePlayer.instance.setPlaylist(Playlist(stack.path.toUri().lastPathSegment ?: "", allSongs.map {
|
||||
SerializedPlatformVideo.fromVideo(LocalVideoDetails.fromContent(it.path))
|
||||
}), focus = true, shuffle = false)
|
||||
},
|
||||
ButtonsContainer.Button("Shuffle", R.drawable.background_button_accent) {
|
||||
StatePlayer.instance.setPlaylist(Playlist(stack.path.toUri().lastPathSegment ?: "", allSongs.map {
|
||||
SerializedPlatformVideo.fromVideo(LocalVideoDetails.fromContent(it.path))
|
||||
}), focus = true, shuffle = true)
|
||||
}
|
||||
)).apply {
|
||||
this.layoutParams = LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
||||
})
|
||||
}
|
||||
else
|
||||
_bottomContentView.removeAllViews();
|
||||
|
||||
fragment.topBar?.let {
|
||||
if(it is FilesTopBarFragment) {
|
||||
if(navStack.size > 1)
|
||||
|
||||
+141
-45
@@ -2,6 +2,7 @@ package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.util.AttributeSet
|
||||
@@ -11,11 +12,13 @@ import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.collection.emptyLongSet
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
@@ -34,6 +37,7 @@ import com.futo.platformplayer.views.AnyInsertedAdapterView
|
||||
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop
|
||||
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithViews
|
||||
import com.futo.platformplayer.views.LibrarySection
|
||||
import com.futo.platformplayer.views.NoResultsView
|
||||
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapter
|
||||
import com.futo.platformplayer.views.adapters.viewholders.AlbumTileViewHolder
|
||||
@@ -41,6 +45,9 @@ import com.futo.platformplayer.views.adapters.viewholders.ArtistTileViewHolder
|
||||
import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder
|
||||
import com.futo.platformplayer.views.adapters.viewholders.LocalVideoTileViewHolder
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Dispatcher
|
||||
|
||||
|
||||
class LibraryFragment : MainFragment() {
|
||||
@@ -93,14 +100,18 @@ class LibraryFragment : MainFragment() {
|
||||
UIDialogs.showDialog(requireContext(), R.drawable.ic_library,
|
||||
"Music permissions", "We require permissions to see your on-device music, denying this will hide the option to see local music.", null, 1,
|
||||
UIDialogs.Action("Ok", {
|
||||
permissionReqAudio.launch(android.Manifest.permission.READ_MEDIA_AUDIO);
|
||||
StateApp?.instance?.activity?.requestPermissionAudio {
|
||||
setPermissionResultAudio(it);
|
||||
}
|
||||
}, UIDialogs.ActionStyle.PRIMARY),
|
||||
UIDialogs.Action("Cancel", {
|
||||
|
||||
}, UIDialogs.ActionStyle.NONE));
|
||||
}
|
||||
else -> {
|
||||
permissionReqAudio.launch(android.Manifest.permission.READ_MEDIA_AUDIO);
|
||||
StateApp?.instance?.activity?.requestPermissionAudio {
|
||||
setPermissionResultAudio(it);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,24 +124,22 @@ class LibraryFragment : MainFragment() {
|
||||
UIDialogs.showDialog(requireContext(), R.drawable.ic_library, false,
|
||||
"Videos permissions", "We require permissions to see your on-device videos, denying this will hide the option to see local videos.", null, 1,
|
||||
UIDialogs.Action("Ok", {
|
||||
permissionReqVideo.launch(android.Manifest.permission.READ_MEDIA_VIDEO);
|
||||
StateApp?.instance?.activity?.requestPermissionVideo {
|
||||
setPermissionResultVideo(it);
|
||||
}
|
||||
}, UIDialogs.ActionStyle.PRIMARY),
|
||||
UIDialogs.Action("Cancel", {
|
||||
|
||||
}, UIDialogs.ActionStyle.NONE));
|
||||
}
|
||||
else -> {
|
||||
permissionReqVideo.launch(android.Manifest.permission.READ_MEDIA_VIDEO);
|
||||
StateApp?.instance?.activity?.requestPermissionVideo {
|
||||
setPermissionResultVideo(it);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val permissionReqAudio = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
|
||||
setPermissionResultAudio(isGranted);
|
||||
});
|
||||
val permissionReqVideo = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
|
||||
setPermissionResultVideo(isGranted);
|
||||
});
|
||||
|
||||
companion object {
|
||||
fun newInstance() = LibraryFragment().apply {}
|
||||
@@ -144,11 +153,12 @@ class LibraryFragment : MainFragment() {
|
||||
var sectionAlbums: LibrarySection;
|
||||
var sectionVideos: LibrarySection;
|
||||
var sectionFiles: LibrarySection;
|
||||
var noContent: NoResultsView;
|
||||
//var buttonFiles: BigButton;
|
||||
|
||||
val recycler: RecyclerView;
|
||||
|
||||
val adapterFiles: AnyInsertedAdapterView<FileEntry, FileViewHolder>;
|
||||
var adapterFiles: AnyInsertedAdapterView<FileEntry, FileViewHolder>? = null;
|
||||
|
||||
//var metaInfo: TextView;
|
||||
|
||||
@@ -184,6 +194,9 @@ class LibraryFragment : MainFragment() {
|
||||
//buttonFiles = findViewById<BigButton>(R.id.button_files);
|
||||
//metaInfo = findViewById(R.id.meta_info);
|
||||
|
||||
noContent = NoResultsView(context, "No directories", "No directories have been added.\nAdd them using the (+) icon.", -1, listOf());
|
||||
noContent.isVisible = false;
|
||||
|
||||
this.allowMusic = allowMusic ?: false;
|
||||
this.allowVideo = allowVideo ?: false;
|
||||
|
||||
@@ -193,14 +206,6 @@ class LibraryFragment : MainFragment() {
|
||||
else
|
||||
fragment.requestPermissionMusic();
|
||||
});
|
||||
val adapterArtists = sectionArtists.getAnyAdapter<Artist, ArtistTileViewHolder>({
|
||||
it.onClick.subscribe {
|
||||
if(it != null)
|
||||
fragment.navigate<LibraryArtistFragment>(it);
|
||||
}
|
||||
});
|
||||
val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount);
|
||||
adapterArtists.setData(artists);
|
||||
|
||||
sectionAlbums.setSection("Albums", {
|
||||
if(this.allowMusic)
|
||||
@@ -208,14 +213,6 @@ class LibraryFragment : MainFragment() {
|
||||
else
|
||||
fragment.requestPermissionMusic();
|
||||
});
|
||||
val adapterAlbums = sectionAlbums.getAnyAdapter<Album, AlbumTileViewHolder>({
|
||||
it.onClick.subscribe {
|
||||
if(it != null)
|
||||
fragment.navigate<LibraryAlbumFragment>(it);
|
||||
}
|
||||
});
|
||||
val albums = StateLibrary.instance.getAlbums();
|
||||
adapterAlbums.setData(albums);
|
||||
|
||||
|
||||
sectionVideos.setSection("Videos", {
|
||||
@@ -224,21 +221,118 @@ class LibraryFragment : MainFragment() {
|
||||
else
|
||||
fragment.requestPermissionVideo();
|
||||
});
|
||||
|
||||
reloadLibraryUI();
|
||||
|
||||
|
||||
/*
|
||||
buttonFiles.onClick.subscribe {
|
||||
fragment.navigate<LibraryFilesFragment>()
|
||||
} */
|
||||
//buttonFiles.setButtonEnabled(false);
|
||||
setMusicPermissions(allowMusic ?: false);
|
||||
setVideoPermissions(allowVideo ?: false);
|
||||
}
|
||||
|
||||
fun reloadFiles() {
|
||||
val files = StateLibrary.instance.getFileDirectories();
|
||||
adapterFiles?.setData(files);
|
||||
if(files.size == 0) {
|
||||
noContent.isVisible = true;
|
||||
}
|
||||
else
|
||||
noContent.isVisible = false;
|
||||
}
|
||||
|
||||
fun reloadLibraryUI() {
|
||||
|
||||
val adapterAlbums = sectionAlbums.getAnyAdapter<Album, AlbumTileViewHolder>({
|
||||
it.onClick.subscribe {
|
||||
if(it != null)
|
||||
fragment.navigate<LibraryAlbumFragment>(it);
|
||||
}
|
||||
});
|
||||
val adapterArtists = sectionArtists.getAnyAdapter<Artist, ArtistTileViewHolder>({
|
||||
it.onClick.subscribe {
|
||||
if(it != null)
|
||||
fragment.navigate<LibraryArtistFragment>(it);
|
||||
}
|
||||
});
|
||||
val adapterVideos = sectionVideos.getAnyAdapter<IPlatformVideo, LocalVideoTileViewHolder>({
|
||||
it.onClick.subscribe {
|
||||
if(it != null)
|
||||
fragment.navigate<VideoDetailFragment>(it);
|
||||
}
|
||||
});
|
||||
val videos = StateLibrary.instance.getRecentVideos(null, 20);
|
||||
adapterVideos.setData(videos);
|
||||
|
||||
if(this.allowMusic) {
|
||||
val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount);
|
||||
adapterArtists.setData(artists);
|
||||
if (artists.size == 0)
|
||||
sectionArtists.setEmpty(
|
||||
"No artists",
|
||||
"No artists were found on your device",
|
||||
-1
|
||||
);
|
||||
else
|
||||
sectionArtists.clearEmpty();
|
||||
}
|
||||
else if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
sectionAlbums.isVisible = false;
|
||||
}
|
||||
else {
|
||||
sectionArtists.setEmpty(
|
||||
"No Music Permissions",
|
||||
"You have not granted music access permissions to Grayjay",
|
||||
-1
|
||||
);
|
||||
}
|
||||
|
||||
if(this.allowMusic) {
|
||||
val albums = StateLibrary.instance.getAlbums();
|
||||
adapterAlbums.setData(albums);
|
||||
if (albums.size == 0)
|
||||
sectionAlbums.setEmpty("No albums", "No albums were found on your device", -1);
|
||||
else
|
||||
sectionAlbums.clearEmpty();
|
||||
}
|
||||
else if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
sectionArtists.isVisible = false;
|
||||
}
|
||||
else {
|
||||
sectionAlbums.setEmpty(
|
||||
"No Music Permissions",
|
||||
"You have not granted music access permissions to Grayjay",
|
||||
-1
|
||||
);
|
||||
}
|
||||
|
||||
if(this.allowVideo) {
|
||||
val videos = StateLibrary.instance.getRecentVideos(null, 20);
|
||||
adapterVideos.setData(videos);
|
||||
if (videos.size == 0)
|
||||
sectionVideos.setEmpty("No videos", "No videos were found on your device", -1);
|
||||
else
|
||||
sectionVideos.clearEmpty();
|
||||
}
|
||||
else if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
sectionVideos.isVisible = false;
|
||||
}
|
||||
else {
|
||||
sectionVideos.setEmpty(
|
||||
"No Video Permissions",
|
||||
"You have not granted video access permissions to Grayjay",
|
||||
-1
|
||||
);
|
||||
}
|
||||
|
||||
adapterFiles = recycler.asAnyWithViews<FileEntry, FileViewHolder>(
|
||||
arrayListOf(
|
||||
sectionArtists,
|
||||
sectionAlbums,
|
||||
sectionVideos,
|
||||
sectionFiles
|
||||
sectionFiles,
|
||||
noContent
|
||||
),
|
||||
arrayListOf(View(context).apply { this.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 20.dp(resources)) }),
|
||||
RecyclerView.VERTICAL, false, {
|
||||
@@ -255,23 +349,8 @@ class LibraryFragment : MainFragment() {
|
||||
}
|
||||
);
|
||||
reloadFiles();
|
||||
|
||||
|
||||
/*
|
||||
buttonFiles.onClick.subscribe {
|
||||
fragment.navigate<LibraryFilesFragment>()
|
||||
} */
|
||||
//buttonFiles.setButtonEnabled(false);
|
||||
setMusicPermissions(allowMusic ?: false);
|
||||
setVideoPermissions(allowVideo ?: false);
|
||||
}
|
||||
|
||||
fun reloadFiles() {
|
||||
val files = StateLibrary.instance.getFileDirectories();
|
||||
adapterFiles.setData(files);
|
||||
}
|
||||
|
||||
|
||||
fun setMusicPermissions(access: Boolean) {
|
||||
allowMusic = access;
|
||||
sectionAlbums.setContentEmptyMessage(R.drawable.ic_library, "No mediastore permissions");
|
||||
@@ -281,6 +360,10 @@ class LibraryFragment : MainFragment() {
|
||||
// if(!allowMusic) "You did not give access to local music, so these options are disabled" else null,
|
||||
// if(!allowVideo) "You did not give access to local videos, so these options are disabled" else null
|
||||
//).filterNotNull().joinToString("\n");
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
reloadLibraryUI();
|
||||
}
|
||||
}
|
||||
fun setVideoPermissions(access: Boolean) {
|
||||
allowVideo = access;
|
||||
@@ -289,9 +372,22 @@ class LibraryFragment : MainFragment() {
|
||||
// if(!allowMusic) "You did not give access to local music, so these options are disabled" else null,
|
||||
// if(!allowVideo) "You did not give access to local videos, so these options are disabled" else null
|
||||
//).filterNotNull().joinToString("\n");
|
||||
// }
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
reloadLibraryUI();
|
||||
}
|
||||
}
|
||||
|
||||
fun onShown() {
|
||||
if(didShowAlpha)
|
||||
return;
|
||||
didShowAlpha = true;
|
||||
UIDialogs.appToast("Library is in alpha\nImprovements are coming to local media playback.")
|
||||
}
|
||||
companion object {
|
||||
var didShowAlpha: Boolean = false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebView
|
||||
import android.widget.ImageButton
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourceAuth
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.others.LoginWebViewClient
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.text.matches
|
||||
|
||||
|
||||
class LoginFragment : MainFragment() {
|
||||
override val isMainView : Boolean = true;
|
||||
override val isTab: Boolean = true;
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
private var view: FragView? = null;
|
||||
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val newView = FragView(this);
|
||||
view = newView;
|
||||
return newView;
|
||||
}
|
||||
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack);
|
||||
view?.onShown(parameter ?: throw IllegalArgumentException("No parameter for login"));
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
view = null;
|
||||
super.onDestroyMainView();
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance() = LoginFragment().apply {}
|
||||
|
||||
private var _callback: ((SourceAuth?) -> Unit)? = null;
|
||||
fun showLogin(config: SourcePluginConfig, callback: ((SourceAuth?) -> Unit)? = null) {
|
||||
if(_callback != null) _callback?.invoke(null);
|
||||
_callback = callback;
|
||||
StateApp.instance.activity?.navigate<LoginFragment>(config, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class FragView: ConstraintLayout {
|
||||
val fragment: LoginFragment;
|
||||
|
||||
private val _webView: WebView;
|
||||
private val _textUrl: TextView;
|
||||
private val _buttonClose: ImageButton;
|
||||
|
||||
constructor(fragment: LoginFragment) : super(fragment.requireContext()) {
|
||||
inflate(context, R.layout.activity_login, this);
|
||||
this.fragment = fragment;
|
||||
|
||||
_textUrl = findViewById(R.id.text_url);
|
||||
_buttonClose = findViewById(R.id.button_close);
|
||||
_buttonClose.setOnClickListener {
|
||||
UIDialogs.toast("Login cancelled", false);
|
||||
fragment.close(true);
|
||||
}
|
||||
|
||||
|
||||
_webView = findViewById(R.id.web_view);
|
||||
_webView.settings.javaScriptEnabled = true;
|
||||
CookieManager.getInstance().setAcceptCookie(true);
|
||||
}
|
||||
|
||||
fun onShown(parameter: Any) {
|
||||
|
||||
|
||||
val config = parameter as? SourcePluginConfig;
|
||||
|
||||
val authConfig = if(config != null)
|
||||
config.authentication ?: throw IllegalStateException("Plugin has no authentication support");
|
||||
else if(parameter is SourcePluginAuthConfig)
|
||||
parameter
|
||||
else throw IllegalStateException("No valid configuration?");
|
||||
//TODO: Backwards compat removal?
|
||||
|
||||
_webView.settings.userAgentString = authConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
|
||||
_webView.settings.useWideViewPort = true;
|
||||
_webView.settings.loadWithOverviewMode = true;
|
||||
|
||||
val webViewClient = if(config != null) LoginWebViewClient(config) else LoginWebViewClient(authConfig);
|
||||
|
||||
webViewClient.onLogin.subscribe { auth ->
|
||||
_callback?.let {
|
||||
_callback = null;
|
||||
it.invoke(auth);
|
||||
}
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
fragment.close(true);
|
||||
}catch (ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to close login", ex);
|
||||
}
|
||||
}
|
||||
};
|
||||
var isFirstLoad = true;
|
||||
val loginWarnings = authConfig.loginWarnings?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.Warning>();
|
||||
val uiMods = authConfig.uiMods?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.UIMod>();
|
||||
var currentScale = 100;
|
||||
var currentDesktop = false;
|
||||
webViewClient.onPageLoaded.subscribe { view, url ->
|
||||
_textUrl.setText(url ?: "");
|
||||
|
||||
if(loginWarnings.size > 0 && url != null) {
|
||||
synchronized(loginWarnings) {
|
||||
val warning = loginWarnings.find { url.matches(it.getRegex()) };
|
||||
if(warning != null) {
|
||||
if(warning.once == true)
|
||||
loginWarnings.remove(warning);
|
||||
UIDialogs.showDialog(context, R.drawable.ic_warning_yellow, warning.text ?: "", warning.details ?: "", null, 0,
|
||||
UIDialogs.Action("Understood", {
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(!isFirstLoad)
|
||||
return@subscribe;
|
||||
isFirstLoad = false;
|
||||
|
||||
if(!authConfig.loginButton.isNullOrEmpty() && authConfig.loginButton.matches(REGEX_LOGIN_BUTTON)) {
|
||||
Logger.i(TAG, "Clicking login button [${authConfig.loginButton}]");
|
||||
//TODO: Find most reliable way to wait for page js to finish
|
||||
view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {});
|
||||
}
|
||||
}
|
||||
_webView.settings.domStorageEnabled = true;
|
||||
|
||||
_webView.webViewClient = webViewClient;
|
||||
_webView.loadUrl(authConfig.loginUrl);
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "LoginFragment";
|
||||
private val REGEX_LOGIN_BUTTON = Regex("[a-zA-Z\\-\\.#:_ ]*");
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
-4
@@ -453,7 +453,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
}.apply {
|
||||
this.alpha = 0.5f;
|
||||
},*/
|
||||
if(isEmbedded) BigButton(c, "Reinstall", "Modify the source of this plugin", R.drawable.ic_refresh) {
|
||||
if(isEmbedded) BigButton(c, "Reinstall", "Reinstall the original version that was embedded with this version of Grayjay", R.drawable.ic_refresh) {
|
||||
val embeddedConfig = StatePlugins.instance.getEmbeddedPluginConfigFromID(context, config.id);
|
||||
|
||||
UIDialogs.showDialog(context, R.drawable.ic_warning_yellow, "Are you sure you want to downgrade (${config.version}=>${embeddedConfig?.version})?",
|
||||
@@ -467,7 +467,29 @@ class SourceDetailFragment : MainFragment() {
|
||||
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
|
||||
};
|
||||
} else null
|
||||
} else
|
||||
BigButton(c, "Reinstall", "Reinstall the current version from the remote repository", R.drawable.ic_refresh) {
|
||||
var newConfig: SourcePluginConfig? = null;
|
||||
try {
|
||||
newConfig = StatePlugins.instance.requestConfig(config?.sourceUrl ?: throw IllegalArgumentException("No config"));
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to fetch new plugin config", ex);
|
||||
}
|
||||
UIDialogs.showDialog(context, R.drawable.ic_warning_yellow, "Are you sure you want to downgrade (${config.version}=>)?",
|
||||
"This will revert the plugin back to the originally embedded version.\nVersion change: ${config.version}=>${newConfig?.version}", null,
|
||||
0, UIDialogs.Action("Cancel", {}), UIDialogs.Action("Reinstall", {
|
||||
val url = config.sourceUrl ?: return@Action;
|
||||
StatePlugins.instance.installPlugin(context, fragment.lifecycleScope, url) {
|
||||
reloadSource(config.id);
|
||||
UIDialogs.toast(context, "Plugin reinstalled, may require refresh");
|
||||
}
|
||||
}, UIDialogs.ActionStyle.DANGEROUS));
|
||||
}.apply {
|
||||
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
|
||||
};
|
||||
}
|
||||
)
|
||||
|
||||
_sourceAdvancedButtons.removeAllViews();
|
||||
@@ -486,7 +508,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
config.authentication.loginWarning, null, 0,
|
||||
UIDialogs.Action("Cancel", {}, UIDialogs.ActionStyle.NONE),
|
||||
UIDialogs.Action("Login", {
|
||||
LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||
LoginFragment.showLogin(config) {//LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||
try {
|
||||
StatePlugins.instance.setPluginAuth(config.id, it);
|
||||
reloadSource(config.id);
|
||||
@@ -500,7 +522,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
}, UIDialogs.ActionStyle.PRIMARY))
|
||||
}
|
||||
else
|
||||
LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||
LoginFragment.showLogin(config) {//LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||
try {
|
||||
StatePlugins.instance.setPluginAuth(config.id, it);
|
||||
reloadSource(config.id);
|
||||
|
||||
+75
-17
@@ -55,6 +55,7 @@ import com.futo.platformplayer.api.media.LiveChatManager
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException
|
||||
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink
|
||||
import com.futo.platformplayer.api.media.models.chapters.ChapterType
|
||||
import com.futo.platformplayer.api.media.models.chapters.IChapter
|
||||
@@ -77,6 +78,7 @@ import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
|
||||
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.models.video.LocalVideoDetails
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
@@ -175,6 +177,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import userpackage.Protocol
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.Locale
|
||||
@@ -563,6 +566,18 @@ class VideoDetailView : ConstraintLayout {
|
||||
if (video is TutorialFragment.TutorialVideo) {
|
||||
return@setOnClickListener
|
||||
}
|
||||
if(video is LocalVideoDetails) {
|
||||
video?.author?.let {
|
||||
if(it.url.startsWith("content://media/external/audio/artists")) {
|
||||
fragment.navigate<LibraryArtistFragment>(it.url);
|
||||
fragment.lifecycleScope.launch {
|
||||
delay(100);
|
||||
fragment.minimizeVideoDetail();
|
||||
};
|
||||
}
|
||||
}
|
||||
return@setOnClickListener;
|
||||
}
|
||||
|
||||
(video?.author ?: _searchVideo?.author)?.let {
|
||||
fragment.navigate<ChannelFragment>(it);
|
||||
@@ -625,6 +640,11 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
_player.onSourceChanged.subscribe(::onSourceChanged);
|
||||
_player.onSourceEnded.subscribe {
|
||||
if (_isCasting) {
|
||||
Logger.i(TAG, "Ignoring onSourceEnded because casting is active")
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
if (!fragment.isInPictureInPicture) {
|
||||
_player.gestureControl.showControls(false);
|
||||
}
|
||||
@@ -704,6 +724,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
val v = video;
|
||||
if (!it && v != null && v.duration - activeDevice.time.toLong() < 2L) {
|
||||
Log.i(TAG, "Next video (loop?)")
|
||||
nextVideo();
|
||||
}
|
||||
}
|
||||
@@ -1035,7 +1056,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_slideUpOverlay?.hide();
|
||||
}
|
||||
else null,
|
||||
if(!isLimitedVersion && !(video?.isLive ?: false))
|
||||
if(!isLimitedVersion && !(video?.isLive ?: false) && !(video is LocalVideoDetails))
|
||||
RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) {
|
||||
video?.let {
|
||||
_slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver);
|
||||
@@ -1058,15 +1079,16 @@ class VideoDetailView : ConstraintLayout {
|
||||
_slideUpOverlay?.hide();
|
||||
}
|
||||
else null,
|
||||
RoundButton(context, R.drawable.ic_export, context.getString(R.string.page), TAG_OPEN) {
|
||||
if(!(video is LocalVideoDetails))
|
||||
RoundButton(context, R.drawable.ic_export, context.getString(R.string.page), TAG_OPEN) {
|
||||
video?.let {
|
||||
val url = video?.shareUrl ?: _searchVideo?.shareUrl ?: _url;
|
||||
fragment.navigate<BrowserFragment>(url);
|
||||
fragment.minimizeVideoDetail();
|
||||
};
|
||||
_slideUpOverlay?.hide();
|
||||
},
|
||||
if (StateSync.instance.hasAuthorizedDevice()) {
|
||||
} else null,
|
||||
if (StateSync.instance.hasAuthorizedDevice() && !(video is LocalVideoDetails)) {
|
||||
RoundButton(context, R.drawable.ic_device, context.getString(R.string.send_to_device), TAG_SEND_TO_DEVICE) {
|
||||
val devices = StateSync.instance.getAuthorizedSessions();
|
||||
val videoToSend = video ?: return@RoundButton;
|
||||
@@ -1089,10 +1111,11 @@ class VideoDetailView : ConstraintLayout {
|
||||
})
|
||||
}
|
||||
}} else null,
|
||||
RoundButton(context, R.drawable.ic_refresh, context.getString(R.string.reload), "Reload") {
|
||||
if(!(video is LocalVideoDetails))
|
||||
RoundButton(context, R.drawable.ic_refresh, context.getString(R.string.reload), "Reload") {
|
||||
reloadVideo();
|
||||
_slideUpOverlay?.hide();
|
||||
}).filterNotNull();
|
||||
} else null).filterNotNull();
|
||||
if(!_buttonPinStore.getAllValues().any())
|
||||
_buttonPins.setButtons(*(buttons + listOf(_buttonMore)).toTypedArray());
|
||||
else {
|
||||
@@ -1327,7 +1350,22 @@ class VideoDetailView : ConstraintLayout {
|
||||
return;
|
||||
//Loop workaround
|
||||
if(bypassSameVideoCheck && this.video?.url == video.url && StatePlayer.instance.loopVideo) {
|
||||
_player.seekTo(0);
|
||||
Log.i(TAG, "Loop")
|
||||
if (_isCasting) {
|
||||
Log.i(TAG, "Loop casting")
|
||||
StateCasting.instance.activeDevice?.seekTo(0.0)
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
delay(300)
|
||||
StateCasting.instance.activeDevice?.resumePlayback()
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Failed to resume", e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "Loop player")
|
||||
_player.seekTo(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1354,6 +1392,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
_minimize_title.text = video.name;
|
||||
_minimize_meta.text = video.author.name;
|
||||
StatePlayer.instance.setCurrentlyPlaying(video);
|
||||
Log.i(TAG, "setCurrentlyPlaying (setVideoOverview) ${video.url} (${video.name})")
|
||||
|
||||
val subTitleSegments : ArrayList<String> = ArrayList();
|
||||
if(video.viewCount > 0)
|
||||
@@ -1623,7 +1663,9 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
_buttonSubscribe.setSubscribeChannel(video.author.url);
|
||||
setDescription(video.description.fixHtmlLinks());
|
||||
_creatorThumbnail.setThumbnail(video.author.thumbnail, false);
|
||||
_creatorThumbnail.setThumbnail(video.author.thumbnail, false,
|
||||
video is LocalVideoDetails
|
||||
);
|
||||
setPolycentricProfile(null, animate = false);
|
||||
_taskLoadPolycentricProfile.run(video.author.id);
|
||||
|
||||
@@ -1651,7 +1693,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
_rating.visibility = View.GONE;
|
||||
|
||||
if (StatePolycentric.instance.enabled) {
|
||||
if (StatePolycentric.instance.enabled && !(video is LocalVideoDetails)) {
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val queryReferencesResponse = ApiMethods.getQueryReferences(
|
||||
@@ -1711,7 +1753,9 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
|
||||
_rating.visibility = View.GONE;
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
_rating.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1776,7 +1820,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
false,
|
||||
(toResume.toFloat() / 1000.0f).toLong(),
|
||||
null,
|
||||
true
|
||||
true,
|
||||
StatePlayer.instance.playlistId
|
||||
);
|
||||
Logger.i(
|
||||
TAG,
|
||||
@@ -1788,6 +1833,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
|
||||
StatePlayer.instance.startOrUpdateMediaSession(context, video);
|
||||
Log.i(TAG, "setCurrentlyPlaying (nextVideo) ${video.url} (${video.name})")
|
||||
StatePlayer.instance.setCurrentlyPlaying(video);
|
||||
|
||||
_liveChat?.stop();
|
||||
@@ -1809,17 +1855,19 @@ class VideoDetailView : ConstraintLayout {
|
||||
_player.updateNextPrevious();
|
||||
updateMoreButtons();
|
||||
|
||||
if (videoDetail is TutorialFragment.TutorialVideo) {
|
||||
if (videoDetail is TutorialFragment.TutorialVideo || videoDetail is LocalVideoDetails) {
|
||||
_buttonSubscribe.visibility = View.GONE
|
||||
_buttonMore.visibility = View.GONE
|
||||
_buttonPins.visibility = View.GONE
|
||||
_buttonMore.visibility = if(videoDetail is LocalVideoDetails) View.VISIBLE else View.GONE;
|
||||
_buttonPins.visibility = if(videoDetail is LocalVideoDetails) View.VISIBLE else View.GONE;
|
||||
_layoutRating.visibility = View.GONE
|
||||
_rating.visibility = View.GONE;
|
||||
_layoutChangeBottomSection.visibility = View.GONE
|
||||
} else {
|
||||
_buttonSubscribe.visibility = View.VISIBLE
|
||||
_buttonMore.visibility = View.VISIBLE
|
||||
_buttonPins.visibility = View.VISIBLE
|
||||
_layoutRating.visibility = View.VISIBLE
|
||||
_rating.visibility = View.VISIBLE;
|
||||
_layoutChangeBottomSection.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
@@ -2286,6 +2334,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
checkAndRemoveWatchLater();
|
||||
|
||||
var next = StatePlayer.instance.nextQueueItem(withoutRemoval || _player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9, bypassVideoLoop);
|
||||
Log.i(TAG, "next queue item ${next?.url} (${next?.name})")
|
||||
|
||||
val autoplayVideo = _autoplayVideo
|
||||
if (next == null && autoplayVideo != null && StatePlayer.instance.autoplay) {
|
||||
Logger.i(TAG, "Found autoplay video!")
|
||||
@@ -2298,11 +2348,14 @@ class VideoDetailView : ConstraintLayout {
|
||||
if(next == null && forceLoop)
|
||||
next = StatePlayer.instance.restartQueue();
|
||||
if(next != null) {
|
||||
Logger.i(TAG, "Set video overview (next = ${next.url} (${next.name}))")
|
||||
setVideoOverview(next, true, 0, true);
|
||||
return true;
|
||||
}
|
||||
else
|
||||
else {
|
||||
Log.i(TAG, "setCurrentlyPlaying (nextVideo) null")
|
||||
StatePlayer.instance.setCurrentlyPlaying(null);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2683,7 +2736,11 @@ class VideoDetailView : ConstraintLayout {
|
||||
private fun fetchComments() {
|
||||
Logger.i(TAG, "fetchComments")
|
||||
video?.let {
|
||||
_commentsList.load(true) { StatePlatform.instance.getComments(it); };
|
||||
if(video is LocalVideoDetails) {
|
||||
_commentsList.clearComments();
|
||||
}
|
||||
else
|
||||
_commentsList.load(true) { StatePlatform.instance.getComments(it); };
|
||||
}
|
||||
}
|
||||
private fun fetchPolycentricComments() {
|
||||
@@ -2970,6 +3027,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
|
||||
onChannelClicked.subscribe {
|
||||
Logger.i(TAG, "Opening channel url: ${it.url}");
|
||||
if(it.url.isNotBlank()) {
|
||||
fragment.minimizeVideoDetail()
|
||||
fragment.navigate<ChannelFragment>(it)
|
||||
@@ -3094,7 +3152,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
if (v !is TutorialFragment.TutorialVideo) {
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
val history = getHistoryIndex(v) ?: return@launch;
|
||||
StateHistory.instance.updateHistoryPosition(v, history, true, (positionMilliseconds.toFloat() / 1000.0f).toLong(), null, true);
|
||||
StateHistory.instance.updateHistoryPosition(v, history, true, (positionMilliseconds.toFloat() / 1000.0f).toLong(), null, true, StatePlayer.instance.playlistId);
|
||||
}
|
||||
}
|
||||
_lastPositionSaveTime = currentTime;
|
||||
|
||||
@@ -14,15 +14,17 @@ import java.time.ZoneOffset
|
||||
class HistoryVideo {
|
||||
var video: SerializedPlatformVideo;
|
||||
var position: Long;
|
||||
var playlistId: String? = null
|
||||
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
|
||||
var date: OffsetDateTime;
|
||||
|
||||
|
||||
constructor(video: SerializedPlatformVideo, position: Long, date: OffsetDateTime) {
|
||||
constructor(video: SerializedPlatformVideo, position: Long, date: OffsetDateTime, playlistId: String?) {
|
||||
this.video = video;
|
||||
this.position = position;
|
||||
this.date = date;
|
||||
this.playlistId = playlistId
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +61,7 @@ class HistoryVideo {
|
||||
viewCount = -1
|
||||
);
|
||||
|
||||
return HistoryVideo(video, position, OffsetDateTime.of(LocalDateTime.ofEpochSecond(dateSec, 0, ZoneOffset.UTC), ZoneOffset.UTC));
|
||||
return HistoryVideo(video, position, OffsetDateTime.of(LocalDateTime.ofEpochSecond(dateSec, 0, ZoneOffset.UTC), ZoneOffset.UTC), null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,5 +12,6 @@ data class Telemetry(
|
||||
val brand: String,
|
||||
val manufacturer: String,
|
||||
val model: String,
|
||||
val sdkVersion: Int
|
||||
val sdkVersion: Int,
|
||||
val plugins: List<String>? = null
|
||||
) { }
|
||||
@@ -29,14 +29,25 @@ class HLS {
|
||||
val mediaRenditions = mutableListOf<MediaRendition>()
|
||||
val sessionDataList = mutableListOf<SessionData>()
|
||||
var independentSegments = false
|
||||
var version: Int? = null
|
||||
var mediaSequence: Long? = null
|
||||
val unhandled = mutableListOf<String>()
|
||||
|
||||
masterPlaylistContent.lines().forEachIndexed { index, line ->
|
||||
val lines = masterPlaylistContent.lines()
|
||||
lines.forEachIndexed { index, line ->
|
||||
when {
|
||||
line.startsWith("#EXT-X-VERSION:") -> {
|
||||
version = line.substringAfter(":").toIntOrNull()
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-MEDIA-SEQUENCE:") -> {
|
||||
mediaSequence = line.substringAfter(":").toLongOrNull()
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-STREAM-INF") -> {
|
||||
val nextLine = masterPlaylistContent.lines().getOrNull(index + 1)
|
||||
val nextLine = lines.getOrNull(index + 1)
|
||||
?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none")
|
||||
val url = resolveUrl(baseUrl, nextLine)
|
||||
|
||||
variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line)))
|
||||
}
|
||||
|
||||
@@ -52,10 +63,14 @@ class HLS {
|
||||
val sessionData = parseSessionData(line)
|
||||
sessionDataList.add(sessionData)
|
||||
}
|
||||
|
||||
else -> {
|
||||
unhandled.add(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments)
|
||||
return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments, version = version, mediaSequence = mediaSequence, unhandled = unhandled)
|
||||
}
|
||||
|
||||
fun mediaRenditionToVariant(rendition: MediaRendition): HLSVariantAudioUrlSource? {
|
||||
@@ -83,62 +98,189 @@ class HLS {
|
||||
return HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", reference.streamInfo.codecs ?: "", reference.streamInfo.bandwidth, 0, false, reference.url)
|
||||
}
|
||||
|
||||
private fun parseByteRange(value: String): Pair<Long, Long> {
|
||||
val trimmed = value.trim()
|
||||
require(trimmed.isNotEmpty()) { "Empty BYTERANGE value" }
|
||||
|
||||
val parts = trimmed.split('@')
|
||||
val length = parts[0].toLong()
|
||||
require(length >= 0) { "Invalid BYTERANGE length '$value'" }
|
||||
|
||||
val start = if (parts.size > 1) {
|
||||
val s = parts[1].toLong()
|
||||
require(s >= 0) { "Invalid BYTERANGE offset '$value'" }
|
||||
s
|
||||
} else {
|
||||
-1L
|
||||
}
|
||||
|
||||
return length to start
|
||||
}
|
||||
|
||||
|
||||
private fun parseAttributes(content: String): Map<String, String> {
|
||||
val index = content.indexOf(':')
|
||||
if (index < 0 || index == content.length - 1) return emptyMap()
|
||||
|
||||
val attributes = mutableMapOf<String, String>()
|
||||
val maybeAttributePairs = content.substring(index + 1).splitToSequence(',')
|
||||
|
||||
var currentPair = StringBuilder()
|
||||
for (pair in maybeAttributePairs) {
|
||||
currentPair.append(pair)
|
||||
if (currentPair.count { it == '\"' } % 2 == 0) {
|
||||
val full = currentPair.toString()
|
||||
val key = full.substringBefore("=")
|
||||
val value = full.substringAfter("=")
|
||||
attributes[key.trim()] = value.trim().removeSurrounding("\"")
|
||||
currentPair = StringBuilder()
|
||||
} else {
|
||||
currentPair.append(',')
|
||||
}
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
fun parseVariantPlaylist(content: String, sourceUrl: String): VariantPlaylist {
|
||||
val baseUrl = URI(sourceUrl).resolve("./").toString()
|
||||
val lines = content.lines()
|
||||
val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull()
|
||||
val targetDuration = lines.find { it.startsWith("#EXT-X-TARGETDURATION:") }?.substringAfter(":")?.toIntOrNull()
|
||||
val mediaSequence = lines.find { it.startsWith("#EXT-X-MEDIA-SEQUENCE:") }?.substringAfter(":")?.toLongOrNull()
|
||||
val discontinuitySequence = lines.find { it.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE:") }?.substringAfter(":")?.toIntOrNull()
|
||||
val programDateTime = lines.find { it.startsWith("#EXT-X-PROGRAM-DATE-TIME:") }?.substringAfter(":")?.let {
|
||||
ZonedDateTime.parse(it, DateTimeFormatter.ISO_DATE_TIME)
|
||||
}
|
||||
val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":")
|
||||
val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) }
|
||||
|
||||
val keyInfo =
|
||||
lines.find { it.startsWith("#EXT-X-KEY:") }?.substringAfter(":")?.split(",")
|
||||
|
||||
val key = keyInfo?.find { it.startsWith("URI=") }?.substringAfter("=")?.trim('"')
|
||||
val iv =
|
||||
keyInfo?.find { it.startsWith("IV=") }?.substringAfter("=")?.substringAfter("x")
|
||||
|
||||
val decryptionInfo: DecryptionInfo? = key?.let { k ->
|
||||
DecryptionInfo(k, iv)
|
||||
}
|
||||
|
||||
val initSegment =
|
||||
lines.find { it.startsWith("#EXT-X-MAP:") }?.substringAfter(":")?.split(",")?.get(0)
|
||||
?.substringAfter("=")?.trim('"')
|
||||
var version: Int? = null
|
||||
var targetDuration: Int? = null
|
||||
var mediaSequence: Long? = null
|
||||
var discontinuitySequence: Int? = null
|
||||
var programDateTime: ZonedDateTime? = null
|
||||
var playlistType: String? = null
|
||||
var streamInfo: StreamInfo? = null
|
||||
var decryptionInfo: DecryptionInfo? = null
|
||||
var mapUrl: String? = null
|
||||
var mapBytesStart: Long = -1
|
||||
var mapBytesLength: Long = -1
|
||||
val segments = mutableListOf<Segment>()
|
||||
if (initSegment != null) {
|
||||
segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment)))
|
||||
}
|
||||
val unhandled = mutableListOf<String>()
|
||||
|
||||
var currentSegment: MediaSegment? = null
|
||||
lines.forEach { line ->
|
||||
|
||||
for (rawLine in lines) {
|
||||
val line = rawLine.trim()
|
||||
if (line.isEmpty()) continue
|
||||
|
||||
when {
|
||||
line.startsWith("#EXT-X-VERSION:") -> {
|
||||
version = line.substringAfter(":").toIntOrNull()
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-TARGETDURATION:") -> {
|
||||
targetDuration = line.substringAfter(":").toIntOrNull()
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-MEDIA-SEQUENCE:") -> {
|
||||
mediaSequence = line.substringAfter(":").toLongOrNull()
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE:") -> {
|
||||
discontinuitySequence = line.substringAfter(":").toIntOrNull()
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-PROGRAM-DATE-TIME:") -> {
|
||||
programDateTime = ZonedDateTime.parse(
|
||||
line.substringAfter(":"),
|
||||
DateTimeFormatter.ISO_DATE_TIME
|
||||
)
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-PLAYLIST-TYPE:") -> {
|
||||
playlistType = line.substringAfter(":")
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-STREAM-INF:") -> {
|
||||
streamInfo = parseStreamInfo(line)
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-KEY:") -> {
|
||||
val attrs = parseAttributes(line)
|
||||
val method = attrs["METHOD"]?.ifEmpty { "AES-128" } ?: "AES-128"
|
||||
val keyUri = attrs["URI"]?.removeSurrounding("\"")
|
||||
val keyUrl = keyUri?.let { resolveUrl(baseUrl, it) }
|
||||
val ivRaw = attrs["IV"]
|
||||
val iv = ivRaw
|
||||
?.removePrefix("0x")
|
||||
?.removePrefix("0X")
|
||||
val keyFormat = attrs["KEYFORMAT"]
|
||||
val keyFormatVersions = attrs["KEYFORMATVERSIONS"]
|
||||
decryptionInfo = DecryptionInfo(method, keyUrl, iv, keyFormat, keyFormatVersions)
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-MAP:") -> {
|
||||
val attrs = parseAttributes(line)
|
||||
attrs["URI"]?.let { uri ->
|
||||
mapUrl = resolveUrl(baseUrl, uri)
|
||||
}
|
||||
attrs["BYTERANGE"]?.let { br ->
|
||||
val (len, start) = parseByteRange(br)
|
||||
mapBytesLength = len
|
||||
mapBytesStart = start
|
||||
}
|
||||
}
|
||||
|
||||
line.startsWith("#EXTINF:") -> {
|
||||
val duration = line.substringAfter(":").substringBefore(",").toDoubleOrNull()
|
||||
?: throw Exception("Invalid segment duration format")
|
||||
val durationText = line.substringAfter(":").substringBefore(",")
|
||||
val duration = durationText.toDoubleOrNull()
|
||||
?: throw IllegalArgumentException("Invalid segment duration: '$line'")
|
||||
currentSegment = MediaSegment(duration = duration)
|
||||
}
|
||||
|
||||
line == "#EXT-X-DISCONTINUITY" -> {
|
||||
segments.add(DiscontinuitySegment())
|
||||
}
|
||||
line =="#EXT-X-ENDLIST" -> {
|
||||
|
||||
line == "#EXT-X-ENDLIST" -> {
|
||||
segments.add(EndListSegment())
|
||||
}
|
||||
else -> {
|
||||
|
||||
currentSegment != null && line.startsWith("#EXT-X-BYTERANGE:") -> {
|
||||
val br = line.substringAfter(":").trim()
|
||||
val (len, start) = parseByteRange(br)
|
||||
currentSegment!!.bytesLength = len
|
||||
currentSegment!!.bytesStart = start
|
||||
}
|
||||
|
||||
currentSegment != null && line.startsWith("#") -> {
|
||||
currentSegment!!.unhandled.add(line)
|
||||
}
|
||||
|
||||
!line.startsWith("#") -> {
|
||||
currentSegment?.let {
|
||||
it.uri = resolveUrl(sourceUrl, line)
|
||||
it.uri = resolveUrl(baseUrl, line)
|
||||
segments.add(it)
|
||||
currentSegment = null
|
||||
} ?: run {
|
||||
unhandled.add(line)
|
||||
}
|
||||
currentSegment = null
|
||||
}
|
||||
|
||||
else -> {
|
||||
unhandled.add(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments, decryptionInfo)
|
||||
return VariantPlaylist(
|
||||
version = version,
|
||||
targetDuration = targetDuration,
|
||||
mediaSequence = mediaSequence,
|
||||
discontinuitySequence = discontinuitySequence,
|
||||
programDateTime = programDateTime,
|
||||
playlistType = playlistType,
|
||||
streamInfo = streamInfo,
|
||||
segments = segments,
|
||||
decryptionInfo = decryptionInfo,
|
||||
mapUrl = mapUrl,
|
||||
mapBytesStart = mapBytesStart,
|
||||
mapBytesLength = mapBytesLength,
|
||||
unhandled = unhandled
|
||||
)
|
||||
}
|
||||
|
||||
fun parseAndGetVideoSources(source: Any, content: String, url: String): List<HLSVariantVideoUrlSource> {
|
||||
@@ -232,26 +374,6 @@ class HLS {
|
||||
return SessionData(dataId, value)
|
||||
}
|
||||
|
||||
private fun parseAttributes(content: String): Map<String, String> {
|
||||
val attributes = mutableMapOf<String, String>()
|
||||
val maybeAttributePairs = content.substringAfter(":").splitToSequence(',')
|
||||
|
||||
var currentPair = StringBuilder()
|
||||
for (pair in maybeAttributePairs) {
|
||||
currentPair.append(pair)
|
||||
if (currentPair.count { it == '\"' } % 2 == 0) { // Check if the number of quotes is even
|
||||
val key = currentPair.toString().substringBefore("=")
|
||||
val value = currentPair.toString().substringAfter("=")
|
||||
attributes[key.trim()] = value.trim().removeSurrounding("\"")
|
||||
currentPair = StringBuilder() // Reset for the next attribute
|
||||
} else {
|
||||
currentPair.append(',') // Continue building the current attribute pair
|
||||
}
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
private val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO", "VIDEO")
|
||||
private fun shouldQuote(key: String, value: String?): Boolean {
|
||||
if (value == null)
|
||||
@@ -345,11 +467,22 @@ class HLS {
|
||||
val variantPlaylistsRefs: List<VariantPlaylistReference>,
|
||||
val mediaRenditions: List<MediaRendition>,
|
||||
val sessionDataList: List<SessionData>,
|
||||
val independentSegments: Boolean
|
||||
val independentSegments: Boolean,
|
||||
val version: Int? = null,
|
||||
val mediaSequence: Long? = null,
|
||||
val unhandled: List<String> = emptyList()
|
||||
) {
|
||||
fun buildM3U8(): String {
|
||||
val builder = StringBuilder()
|
||||
builder.append("#EXTM3U\n")
|
||||
|
||||
version?.let {
|
||||
builder.append("#EXT-X-VERSION:$it\n")
|
||||
}
|
||||
mediaSequence?.let {
|
||||
builder.append("#EXT-X-MEDIA-SEQUENCE:$it\n")
|
||||
}
|
||||
|
||||
if (independentSegments) {
|
||||
builder.append("#EXT-X-INDEPENDENT-SEGMENTS\n")
|
||||
}
|
||||
@@ -404,9 +537,15 @@ class HLS {
|
||||
}
|
||||
|
||||
data class DecryptionInfo(
|
||||
val keyUrl: String,
|
||||
val iv: String?
|
||||
)
|
||||
val method: String,
|
||||
val keyUrl: String?,
|
||||
val iv: String?,
|
||||
val keyFormat: String?,
|
||||
val keyFormatVersions: String?
|
||||
) {
|
||||
val isEncrypted: Boolean
|
||||
get() = !method.equals("NONE", ignoreCase = true)
|
||||
}
|
||||
|
||||
data class VariantPlaylist(
|
||||
val version: Int?,
|
||||
@@ -417,7 +556,11 @@ class HLS {
|
||||
val playlistType: String?,
|
||||
val streamInfo: StreamInfo?,
|
||||
val segments: List<Segment>,
|
||||
val decryptionInfo: DecryptionInfo? = null
|
||||
val decryptionInfo: DecryptionInfo? = null,
|
||||
val mapUrl: String? = null,
|
||||
val mapBytesStart: Long = -1,
|
||||
val mapBytesLength: Long = -1,
|
||||
val unhandled: List<String> = emptyList()
|
||||
) {
|
||||
fun buildM3U8(): String = buildString {
|
||||
append("#EXTM3U\n")
|
||||
@@ -426,9 +569,50 @@ class HLS {
|
||||
mediaSequence?.let { append("#EXT-X-MEDIA-SEQUENCE:$it\n") }
|
||||
discontinuitySequence?.let { append("#EXT-X-DISCONTINUITY-SEQUENCE:$it\n") }
|
||||
playlistType?.let { append("#EXT-X-PLAYLIST-TYPE:$it\n") }
|
||||
programDateTime?.let { append("#EXT-X-PROGRAM-DATE-TIME:${it.format(DateTimeFormatter.ISO_DATE_TIME)}\n") }
|
||||
programDateTime?.let {
|
||||
append(
|
||||
"#EXT-X-PROGRAM-DATE-TIME:${
|
||||
it.withZoneSameInstant(java.time.ZoneOffset.UTC)
|
||||
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'"))
|
||||
}\n"
|
||||
)
|
||||
}
|
||||
streamInfo?.let { append(it.toM3U8Line()) }
|
||||
|
||||
decryptionInfo?.let { dec ->
|
||||
val sb = StringBuilder()
|
||||
sb.append("#EXT-X-KEY:METHOD=").append(dec.method)
|
||||
if (!dec.method.equals("NONE", ignoreCase = true)) {
|
||||
dec.keyUrl?.let { url ->
|
||||
sb.append(",URI=\"").append(url).append("\"")
|
||||
}
|
||||
dec.iv?.let { iv ->
|
||||
sb.append(",IV=0x").append(iv)
|
||||
}
|
||||
dec.keyFormat?.let { kf ->
|
||||
sb.append(",KEYFORMAT=\"").append(kf).append("\"")
|
||||
}
|
||||
dec.keyFormatVersions?.let { kfv ->
|
||||
sb.append(",KEYFORMATVERSIONS=\"").append(kfv).append("\"")
|
||||
}
|
||||
}
|
||||
append(sb.append("\n").toString())
|
||||
}
|
||||
|
||||
if (!mapUrl.isNullOrEmpty()) {
|
||||
val sb = StringBuilder()
|
||||
sb.append("#EXT-X-MAP:URI=\"").append(mapUrl).append("\"")
|
||||
if (mapBytesLength > 0) {
|
||||
if (mapBytesStart >= 0) {
|
||||
sb.append(",BYTERANGE=\"").append(mapBytesLength)
|
||||
.append("@").append(mapBytesStart).append("\"")
|
||||
} else {
|
||||
sb.append(",BYTERANGE=\"").append(mapBytesLength).append("\"")
|
||||
}
|
||||
}
|
||||
append(sb.append("\n").toString())
|
||||
}
|
||||
|
||||
segments.forEach { segment ->
|
||||
append(segment.toM3U8Line())
|
||||
}
|
||||
@@ -439,13 +623,25 @@ class HLS {
|
||||
abstract fun toM3U8Line(): String
|
||||
}
|
||||
|
||||
data class MediaSegment (
|
||||
data class MediaSegment(
|
||||
val duration: Double,
|
||||
var uri: String = ""
|
||||
var uri: String = "",
|
||||
var bytesStart: Long = -1,
|
||||
var bytesLength: Long = -1,
|
||||
val unhandled: MutableList<String> = mutableListOf()
|
||||
) : Segment() {
|
||||
override fun toM3U8Line(): String = buildString {
|
||||
append("#EXTINF:${duration},\n")
|
||||
append(uri + "\n")
|
||||
|
||||
if (bytesLength > 0) {
|
||||
if (bytesStart >= 0) {
|
||||
append("#EXT-X-BYTERANGE:${bytesLength}@${bytesStart}\n")
|
||||
} else {
|
||||
append("#EXT-X-BYTERANGE:${bytesLength}\n")
|
||||
}
|
||||
}
|
||||
|
||||
append(uri).append("\n")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,20 @@ class StateApp {
|
||||
|
||||
val sessionId = UUID.randomUUID().toString();
|
||||
|
||||
|
||||
var airplaneMode: Boolean = false
|
||||
get(){
|
||||
return field;
|
||||
}
|
||||
private set(value) {
|
||||
field = value;
|
||||
}
|
||||
val airplaneModeChanged = Event1<Boolean>();
|
||||
fun setAirMode(value: Boolean) {
|
||||
airplaneMode = value;
|
||||
airplaneModeChanged.emit(airplaneMode);
|
||||
}
|
||||
|
||||
var privateMode: Boolean = false
|
||||
get(){
|
||||
return field;
|
||||
@@ -422,9 +436,9 @@ class StateApp {
|
||||
try {
|
||||
val caFile = AppCaUpdater.ensureCaBundle(context)
|
||||
Libcurl.setDefaultCAPath(caFile.absolutePath)
|
||||
Logger.i(TAG, "Libcurl initialized")
|
||||
} catch (t: Throwable) {
|
||||
val fallback = File(context.noBackupFilesDir, "curl-ca-bundle.pem")
|
||||
if (fallback.exists()) Libcurl.setDefaultCAPath(fallback.absolutePath)
|
||||
Logger.e(TAG, "Failed to initialize Libcurl", t);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ class StateAssets {
|
||||
if(part == "." || part == "..") {
|
||||
if(parentAllowance <= 0)
|
||||
throw IllegalStateException("Path [${path}] attempted to escape path..");
|
||||
parts1.removeLast();
|
||||
parts1.removeAt(parts1.size - 1);
|
||||
toSkip++;
|
||||
}
|
||||
else
|
||||
|
||||
@@ -365,7 +365,7 @@ class StateBackup {
|
||||
}
|
||||
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);
|
||||
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);
|
||||
|
||||
@@ -543,7 +543,9 @@ class StateDownloads {
|
||||
val file = export.export(context, { progress ->
|
||||
val now = System.currentTimeMillis();
|
||||
if (lastNotifyTime == -1L || now - lastNotifyTime > 100) {
|
||||
it.setProgress(progress);
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
it.setProgress(progress);
|
||||
}
|
||||
lastNotifyTime = now;
|
||||
}
|
||||
}, null);
|
||||
|
||||
@@ -65,7 +65,7 @@ class StateHistory {
|
||||
}
|
||||
|
||||
private var _lastHistoryBroadcast = "";
|
||||
fun updateHistoryPosition(liveObj: IPlatformVideo, index: DBHistory.Index, updateExisting: Boolean, position: Long = -1L, date: OffsetDateTime? = null, isUserAction: Boolean = false): Long {
|
||||
fun updateHistoryPosition(liveObj: IPlatformVideo, index: DBHistory.Index, updateExisting: Boolean, position: Long = -1L, date: OffsetDateTime? = null, isUserAction: Boolean = false, playlistId: String? = null): Long {
|
||||
val pos = if(position < 0) 0 else position;
|
||||
val historyVideo = index.obj;
|
||||
|
||||
@@ -86,6 +86,7 @@ class StateHistory {
|
||||
|
||||
historyVideo.position = pos;
|
||||
historyVideo.date = date ?: OffsetDateTime.now();
|
||||
historyVideo.playlistId = playlistId
|
||||
_historyDBStore.update(index.id!!, historyVideo);
|
||||
onHistoricVideoChanged.emit(liveObj, pos);
|
||||
|
||||
@@ -157,7 +158,7 @@ class StateHistory {
|
||||
UIDialogs.toast("History item null?\nNo history tracking..");
|
||||
}
|
||||
else if(create) {
|
||||
val newHistItem = HistoryVideo(SerializedPlatformVideo.fromVideo(video), 0, watchDate ?: OffsetDateTime.now());
|
||||
val newHistItem = HistoryVideo(SerializedPlatformVideo.fromVideo(video), 0, watchDate ?: OffsetDateTime.now(), StatePlayer.instance.playlistId);
|
||||
val id = _historyDBStore.insert(newHistItem);
|
||||
result = _historyDBStore.getOrNull(id);
|
||||
if(result == null)
|
||||
|
||||
@@ -4,9 +4,12 @@ import android.content.ContentUris
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.provider.MediaStore.Audio.Artists
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.collection.emptyLongSet
|
||||
import androidx.core.database.getStringOrNull
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
@@ -34,6 +37,8 @@ import java.io.File
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ConcurrentMap
|
||||
|
||||
|
||||
class StateLibrary {
|
||||
@@ -101,13 +106,15 @@ class StateLibrary {
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA,
|
||||
"LOWER(" + MediaStore.Audio.Media.DISPLAY_NAME + ") LIKE ? ", arrayOf("%" + str.trim().lowercase() + "%"),
|
||||
null) ?: return listOf();
|
||||
cursor.moveToFirst();
|
||||
val list = mutableListOf<IPlatformVideo>()
|
||||
while(!cursor.isAfterLast) {
|
||||
list.add(StateLibrary.audioFromCursor(cursor));
|
||||
cursor.moveToNext();
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
val list = mutableListOf<IPlatformVideo>()
|
||||
while(!cursor.isAfterLast) {
|
||||
list.add(StateLibrary.audioFromCursor(cursor));
|
||||
cursor.moveToNext();
|
||||
}
|
||||
return@use list;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
fun getAlbums(): List<Album> {
|
||||
@@ -154,21 +161,26 @@ class StateLibrary {
|
||||
query,
|
||||
null,
|
||||
MediaStore.Video.Media.DATE_ADDED + " DESC") ?: return EmptyPager();
|
||||
cursor.moveToFirst();
|
||||
val list = mutableListOf<IPlatformVideo>()
|
||||
while(!cursor.isAfterLast && list.size < 10) {
|
||||
list.add(videoFromCursor(cursor));
|
||||
cursor.moveToNext();
|
||||
}
|
||||
|
||||
return AdhocPager<IPlatformContent>({
|
||||
val list = mutableListOf<IPlatformContent>()
|
||||
//Ongoing usage of cursor..todo disposal
|
||||
//return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
val list = mutableListOf<IPlatformVideo>()
|
||||
while(!cursor.isAfterLast && list.size < 10) {
|
||||
list.add(videoFromCursor(cursor));
|
||||
cursor.moveToNext();
|
||||
}
|
||||
return@AdhocPager list;
|
||||
}, list);
|
||||
|
||||
return AdhocPager<IPlatformContent>({
|
||||
val list = mutableListOf<IPlatformContent>()
|
||||
while(!cursor.isAfterLast && list.size < 10) {
|
||||
list.add(videoFromCursor(cursor));
|
||||
cursor.moveToNext();
|
||||
}
|
||||
Logger.i(TAG, "Videos nextPage: ${list.size}")
|
||||
return@AdhocPager list;
|
||||
}, list);
|
||||
//}
|
||||
}
|
||||
fun getRecentVideos(buckets: List<String>? = null, count: Int = 20): List<IPlatformVideo> {
|
||||
val videoPager = getVideos(buckets);
|
||||
@@ -183,31 +195,45 @@ class StateLibrary {
|
||||
|
||||
private var _cacheBucketNames: List<Bucket>? = null;
|
||||
fun getVideoBucketNames(): List<Bucket> {
|
||||
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
|
||||
return listOf();
|
||||
if(_cacheBucketNames != null)
|
||||
return _cacheBucketNames ?: listOf();
|
||||
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();
|
||||
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();
|
||||
|
||||
val buckets = mutableListOf<Bucket>();
|
||||
val list = HashSet<Long>();
|
||||
if (cur.moveToFirst()) {
|
||||
var id: Long;
|
||||
var bucket: String
|
||||
do {
|
||||
id = cur.getLong(0);
|
||||
bucket = cur.getString(1)
|
||||
if(!list.contains(id)) {
|
||||
list.add(id);
|
||||
buckets.add(Bucket(id, bucket));
|
||||
return cur.use {
|
||||
val buckets = mutableListOf<Bucket>();
|
||||
val list = HashSet<Long>();
|
||||
if (cur.moveToFirst()) {
|
||||
var id: Long;
|
||||
var bucket: String
|
||||
do {
|
||||
try {
|
||||
id = cur.getLong(0);
|
||||
bucket = cur.getStringOrNull(1) ?: continue;
|
||||
if (!list.contains(id)) {
|
||||
list.add(id);
|
||||
buckets.add(Bucket(id, bucket));
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to parse bucket due to ${ex.message}", ex);
|
||||
}
|
||||
} while (cur.moveToNext())
|
||||
}
|
||||
} while (cur.moveToNext())
|
||||
_cacheBucketNames = buckets.toList()
|
||||
return@use _cacheBucketNames ?: listOf();
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Buckets loading failed, returning empty");
|
||||
return listOf();
|
||||
}
|
||||
_cacheBucketNames = buckets.toList()
|
||||
return _cacheBucketNames ?: listOf();
|
||||
}
|
||||
|
||||
|
||||
@@ -215,7 +241,6 @@ class StateLibrary {
|
||||
val PROJECTION_VIDEO = arrayOf(
|
||||
MediaStore.Video.Media._ID,
|
||||
MediaStore.Video.Media.DISPLAY_NAME,
|
||||
MediaStore.Video.Media.AUTHOR,
|
||||
MediaStore.Video.Media.DATE_ADDED,
|
||||
MediaStore.Video.Media.MIME_TYPE,
|
||||
MediaStore.Video.Media.BUCKET_DISPLAY_NAME
|
||||
@@ -224,11 +249,12 @@ class StateLibrary {
|
||||
MediaStore.Audio.Media._ID, //0
|
||||
MediaStore.Audio.Media.DISPLAY_NAME, //1
|
||||
MediaStore.Audio.Media.ARTIST, //2
|
||||
MediaStore.Audio.Media.ALBUM_ID, //3
|
||||
MediaStore.Audio.Media.DURATION, //4
|
||||
MediaStore.Audio.Media.DATE_ADDED, //5
|
||||
MediaStore.Audio.Media.MIME_TYPE, //6
|
||||
MediaStore.Audio.Media.BUCKET_DISPLAY_NAME //7
|
||||
MediaStore.Audio.Media.ARTIST_ID, //3
|
||||
MediaStore.Audio.Media.ALBUM_ID, //4
|
||||
MediaStore.Audio.Media.DURATION, //5
|
||||
MediaStore.Audio.Media.DATE_ADDED, //6
|
||||
MediaStore.Audio.Media.MIME_TYPE, //7
|
||||
MediaStore.Audio.Media.BUCKET_DISPLAY_NAME //8
|
||||
);
|
||||
|
||||
fun getDocumentTrack(url: String): IPlatformContentDetails? {
|
||||
@@ -275,10 +301,12 @@ class StateLibrary {
|
||||
val cursor = resolver?.query(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media._ID} = ?", arrayOf(id.toString()),
|
||||
null) ?: return null;
|
||||
cursor.moveToFirst();
|
||||
if(cursor.isAfterLast)
|
||||
return null;
|
||||
return audioFromCursor(cursor);
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
if(cursor.isAfterLast)
|
||||
return@use null;
|
||||
return@use audioFromCursor(cursor);
|
||||
}
|
||||
}
|
||||
fun findAudioByName(name: String): IPlatformContentDetails? {
|
||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||
@@ -289,10 +317,12 @@ class StateLibrary {
|
||||
val cursor = resolver?.query(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media.DISPLAY_NAME} = ?", arrayOf(name),
|
||||
null) ?: return null;
|
||||
cursor.moveToFirst();
|
||||
if(cursor.isAfterLast)
|
||||
return null;
|
||||
return audioFromCursor(cursor);
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
if(cursor.isAfterLast)
|
||||
return null;
|
||||
return@use audioFromCursor(cursor);
|
||||
}
|
||||
}
|
||||
fun getVideoTrack(url: String): IPlatformContentDetails? {
|
||||
val uri = Uri.parse(url);
|
||||
@@ -308,10 +338,12 @@ class StateLibrary {
|
||||
val cursor = resolver?.query(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_VIDEO, "${MediaStore.Video.Media._ID} = ?", arrayOf(id.toString()),
|
||||
null) ?: return null;
|
||||
cursor.moveToFirst();
|
||||
if(cursor.isAfterLast)
|
||||
return null;
|
||||
return videoFromCursor(cursor);
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
if(cursor.isAfterLast)
|
||||
return@use null;
|
||||
return@use videoFromCursor(cursor);
|
||||
}
|
||||
}
|
||||
fun findVideoByName(name: String): IPlatformContentDetails? {
|
||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||
@@ -322,21 +354,24 @@ class StateLibrary {
|
||||
val cursor = resolver?.query(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_VIDEO, "${MediaStore.Video.Media.DISPLAY_NAME} = ?", arrayOf(name),
|
||||
null) ?: return null;
|
||||
cursor.moveToFirst();
|
||||
if(cursor.isAfterLast)
|
||||
return null;
|
||||
return videoFromCursor(cursor);
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
if(cursor.isAfterLast)
|
||||
return@use null;
|
||||
return@use videoFromCursor(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
fun audioFromCursor(cursor: Cursor): IPlatformVideoDetails {
|
||||
val id = cursor.getString(0);
|
||||
val displayName = cursor.getString(1);
|
||||
val author = cursor.getString(2);
|
||||
val albumId = cursor.getLong(3);
|
||||
val duration = cursor.getLong(4).let { if(it > 0) it / 1000 else 0 };
|
||||
val date = cursor.getLong(5);
|
||||
val contentType = cursor.getString(6);
|
||||
val category = cursor.getString(7);
|
||||
val authorId = cursor.getStringOrNull(3);
|
||||
val albumId = cursor.getLong(4);
|
||||
val duration = cursor.getLong(5).let { if(it > 0) it / 1000 else 0 };
|
||||
val date = cursor.getLong(6);
|
||||
val contentType = cursor.getString(7);
|
||||
val category = cursor.getString(8);
|
||||
|
||||
val idLong = id.toLongOrNull();
|
||||
val contentUrl = if(idLong != null )
|
||||
@@ -344,6 +379,13 @@ class StateLibrary {
|
||||
else
|
||||
"";
|
||||
|
||||
val authorIdLong = authorId?.toLongOrNull();
|
||||
val authorUrl = if(authorIdLong != null)
|
||||
ContentUris.withAppendedId(MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI, authorIdLong).toString();
|
||||
else
|
||||
"";
|
||||
|
||||
|
||||
val albumContentUrl = if(albumId > 0)
|
||||
ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, albumId)?.toString()
|
||||
else null;
|
||||
@@ -353,7 +395,10 @@ class StateLibrary {
|
||||
else null;
|
||||
|
||||
val authorObj = if(!author.isNullOrBlank())
|
||||
PlatformAuthorLink(PlatformID.NONE, author, "", null, null)
|
||||
PlatformAuthorLink(
|
||||
if(authorId != null) PlatformID("LOCAL", authorId) else PlatformID.NONE,
|
||||
author,
|
||||
authorUrl, null, null)
|
||||
else PlatformAuthorLink.UNKNOWN;
|
||||
|
||||
return LocalVideoDetails(
|
||||
@@ -365,10 +410,10 @@ class StateLibrary {
|
||||
fun videoFromCursor(cursor: Cursor): IPlatformVideoDetails {
|
||||
val id = cursor.getString(0);
|
||||
val displayName = cursor.getString(1);
|
||||
val author = cursor.getString(2);
|
||||
val date = cursor.getLong(3);
|
||||
val contentType = cursor.getString(4);
|
||||
val category = cursor.getString(5);
|
||||
val author = null;//cursor.getString(2);
|
||||
val date = cursor.getLong(2);
|
||||
val contentType = cursor.getString(3);
|
||||
val category = cursor.getString(4);
|
||||
|
||||
val idLong = id.toLongOrNull();
|
||||
val contentUrl = if(idLong != null )
|
||||
@@ -445,6 +490,10 @@ class Artist {
|
||||
return AdhocPager({ listOf() }, getTracksPager(idLong));
|
||||
}
|
||||
|
||||
fun getThumbnailOrAlbum(): String? {
|
||||
return thumbnail ?: tryGetArtistThumbnail(id.toLongOrNull());
|
||||
}
|
||||
|
||||
companion object {
|
||||
val ID_UNKNOWN = "UNKNOWN";
|
||||
val PROJECTION: Array<String> = arrayOf(Artists._ID,
|
||||
@@ -452,6 +501,20 @@ class Artist {
|
||||
Artists.NUMBER_OF_TRACKS,
|
||||
Artists.NUMBER_OF_ALBUMS);
|
||||
|
||||
val thumbnailCache = ConcurrentHashMap<Long, String>();
|
||||
|
||||
fun tryGetArtistThumbnail(artistId: Long?): String? {
|
||||
if(artistId == null)
|
||||
return null;
|
||||
if(thumbnailCache.containsKey(artistId))
|
||||
return thumbnailCache.get(artistId);
|
||||
else {
|
||||
val album = Album.getArtistAlbumWithThumbnail(artistId);
|
||||
thumbnailCache.put(artistId, album?.thumbnail ?: "");
|
||||
return album?.thumbnail;
|
||||
}
|
||||
}
|
||||
|
||||
fun fromCursor(cursor: Cursor): Artist {
|
||||
val id = cursor.getString(0);
|
||||
val artist = cursor.getString(1);
|
||||
@@ -473,12 +536,13 @@ class Artist {
|
||||
val cursor = resolver.query(MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI,
|
||||
Artist.PROJECTION,
|
||||
"${MediaStore.Audio.Artists._ID} = ?",
|
||||
arrayOf(id.toString()), null) ?:
|
||||
return null;
|
||||
cursor.moveToFirst();
|
||||
if(cursor.isAfterLast)
|
||||
return null;
|
||||
return Artist.fromCursor(cursor);
|
||||
arrayOf(id.toString()), null) ?: return null;
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
if(cursor.isAfterLast)
|
||||
return@use null;
|
||||
return@use Artist.fromCursor(cursor);
|
||||
}
|
||||
}
|
||||
fun getArtists(ordering: ArtistOrdering = ArtistOrdering.Alphabethic, query: String? = null, args: Array<String>? = null): List<Artist> {
|
||||
val ordering = when(ordering) {
|
||||
@@ -492,13 +556,18 @@ class Artist {
|
||||
query,
|
||||
args,
|
||||
ordering) ?: return listOf();
|
||||
cursor.moveToFirst();
|
||||
val list = mutableListOf<Artist>()
|
||||
while(!cursor.isAfterLast) {
|
||||
list.add(fromCursor(cursor));
|
||||
cursor.moveToNext();
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
val list = mutableListOf<Artist>()
|
||||
while(!cursor.isAfterLast) {
|
||||
val artist = fromCursor(cursor);
|
||||
cursor.moveToNext();
|
||||
if(artist.name == "<unknown>")
|
||||
continue; //TODO: Better way of detecting unknown?
|
||||
list.add(artist);
|
||||
}
|
||||
return@use list;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
fun getTracksPager(artistId: Long): List<IPlatformVideo> {
|
||||
@@ -510,13 +579,15 @@ class Artist {
|
||||
val cursor = resolver?.query(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media.ARTIST_ID} = ?", arrayOf(artistId.toString()),
|
||||
null) ?: return listOf();
|
||||
cursor.moveToFirst();
|
||||
val list = mutableListOf<IPlatformVideo>()
|
||||
while(!cursor.isAfterLast) {
|
||||
list.add(StateLibrary.audioFromCursor(cursor));
|
||||
cursor.moveToNext();
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
val list = mutableListOf<IPlatformVideo>()
|
||||
while(!cursor.isAfterLast) {
|
||||
list.add(StateLibrary.audioFromCursor(cursor));
|
||||
cursor.moveToNext();
|
||||
}
|
||||
return@use list;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -572,13 +643,15 @@ class Album {
|
||||
val cursor = resolver?.query(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media.ALBUM_ID} = ?", arrayOf(albumId.toString()),
|
||||
null) ?: return listOf();
|
||||
cursor.moveToFirst();
|
||||
val list = mutableListOf<IPlatformVideo>()
|
||||
while(!cursor.isAfterLast) {
|
||||
list.add(StateLibrary.audioFromCursor(cursor));
|
||||
cursor.moveToNext();
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
val list = mutableListOf<IPlatformVideo>()
|
||||
while(!cursor.isAfterLast) {
|
||||
list.add(StateLibrary.audioFromCursor(cursor));
|
||||
cursor.moveToNext();
|
||||
}
|
||||
return@use list;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
fun getAlbum(id: Long): Album? {
|
||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||
@@ -589,12 +662,13 @@ class Album {
|
||||
val cursor = resolver.query(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI,
|
||||
PROJECTION,
|
||||
"${MediaStore.Audio.Albums.ALBUM_ID} = ?",
|
||||
arrayOf(id.toString()), null) ?:
|
||||
return null;
|
||||
cursor.moveToFirst();
|
||||
if(cursor.isAfterLast)
|
||||
return null;
|
||||
return fromCursor(cursor);
|
||||
arrayOf(id.toString()), null) ?: return null;
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
if(cursor.isAfterLast)
|
||||
return@use null;
|
||||
return@use fromCursor(cursor);
|
||||
}
|
||||
}
|
||||
fun getAlbums(query: String? = null, args: Array<String>? = null): List<Album> {
|
||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||
@@ -605,13 +679,15 @@ class Album {
|
||||
val cursor = resolver?.query(
|
||||
MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, PROJECTION, query, args,
|
||||
MediaStore.Audio.Albums.ALBUM + " ASC") ?: return listOf();
|
||||
cursor.moveToFirst();
|
||||
val list = mutableListOf<Album>()
|
||||
while(!cursor.isAfterLast) {
|
||||
list.add(fromCursor(cursor));
|
||||
cursor.moveToNext();
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
val list = mutableListOf<Album>()
|
||||
while(!cursor.isAfterLast) {
|
||||
list.add(fromCursor(cursor));
|
||||
cursor.moveToNext();
|
||||
}
|
||||
return@use list;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
fun getArtistAlbums(artistId: Long): List<Album> {
|
||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||
@@ -622,13 +698,35 @@ class Album {
|
||||
val cursor = resolver?.query(
|
||||
MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, PROJECTION, "${MediaStore.Audio.Media.ARTIST_ID} = ?", arrayOf(artistId.toString()),
|
||||
MediaStore.Audio.Albums.ALBUM + " ASC") ?: return listOf();
|
||||
cursor.moveToFirst();
|
||||
val list = mutableListOf<Album>()
|
||||
while(!cursor.isAfterLast) {
|
||||
list.add(fromCursor(cursor));
|
||||
cursor.moveToNext();
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
val list = mutableListOf<Album>()
|
||||
while(!cursor.isAfterLast) {
|
||||
list.add(fromCursor(cursor));
|
||||
cursor.moveToNext();
|
||||
}
|
||||
return@use list;
|
||||
}
|
||||
}
|
||||
fun getArtistAlbumWithThumbnail(artistId: Long): Album? {
|
||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||
if(resolver == null) {
|
||||
Logger.w(TAG, "Album contentResolver not found");
|
||||
return null;
|
||||
}
|
||||
val cursor = resolver?.query(
|
||||
MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, PROJECTION, "${MediaStore.Audio.Media.ARTIST_ID} = ?", arrayOf(artistId.toString()),
|
||||
MediaStore.Audio.Albums.ALBUM + " ASC") ?: return null;
|
||||
return cursor.use {
|
||||
cursor.moveToFirst();
|
||||
while(!cursor.isAfterLast) {
|
||||
val album = fromCursor(cursor);
|
||||
if(album.thumbnail != null)
|
||||
return album
|
||||
cursor.moveToNext();
|
||||
}
|
||||
return@use null;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.futo.platformplayer.states
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
@@ -111,10 +112,20 @@ class StatePlayer {
|
||||
val onPlayerOpened = Event0();
|
||||
val onPlayerClosed = Event0();
|
||||
|
||||
var currentVideo: IPlatformVideoDetails? = null
|
||||
var currentVideo: IPlatformVideo? = null
|
||||
private set;
|
||||
|
||||
fun setCurrentlyPlaying(video: IPlatformVideoDetails?) {
|
||||
private var _currentPlaylistId: String? = null
|
||||
val playlistId: String? get() = if (_queueType == TYPE_PLAYLIST) _currentPlaylistId else null
|
||||
|
||||
init {
|
||||
onQueueChanged.subscribe {
|
||||
updateLastQueue()
|
||||
}
|
||||
}
|
||||
|
||||
fun setCurrentlyPlaying(video: IPlatformVideo?) {
|
||||
Log.i(TAG, "setCurrentlyPlaying ${video?.url} (${video?.name})")
|
||||
currentVideo = video;
|
||||
}
|
||||
|
||||
@@ -125,6 +136,7 @@ class StatePlayer {
|
||||
onPlayerOpened.emit();
|
||||
}
|
||||
fun setPlayerClosed() {
|
||||
Log.i(TAG, "setCurrentlyPlaying (setPlayerClosed) null")
|
||||
setCurrentlyPlaying(null);
|
||||
isOpen = false;
|
||||
clearQueue();
|
||||
@@ -269,23 +281,6 @@ class StatePlayer {
|
||||
}
|
||||
onQueueChanged.emit(true);
|
||||
}
|
||||
fun setPlaylist(playlist: IPlatformPlaylistDetails, toPlayIndex: Int = 0, focus: Boolean = false, shuffle: Boolean = false) {
|
||||
synchronized(_queue) {
|
||||
_queue.clear();
|
||||
setQueueType(TYPE_PLAYLIST);
|
||||
_queueName = playlist.name;
|
||||
_queue.addAll(playlist.contents.getResults());
|
||||
queueFocused = focus;
|
||||
queueShuffle = shuffle;
|
||||
if (shuffle) {
|
||||
createShuffledQueue();
|
||||
}
|
||||
_queuePosition = toPlayIndex;
|
||||
}
|
||||
playlist.id.value?.let { StatePlaylists.instance.didPlay(it); };
|
||||
|
||||
onQueueChanged.emit(true);
|
||||
}
|
||||
fun setPlaylist(playlist: Playlist, toPlayIndex: Int = 0, focus: Boolean = false, shuffle: Boolean = false) {
|
||||
synchronized(_queue) {
|
||||
_queue.clear();
|
||||
@@ -299,6 +294,7 @@ class StatePlayer {
|
||||
}
|
||||
_queuePosition = toPlayIndex;
|
||||
}
|
||||
_currentPlaylistId = playlist.id
|
||||
StatePlaylists.instance.didPlay(playlist.id);
|
||||
|
||||
onQueueChanged.emit(true);
|
||||
@@ -384,6 +380,27 @@ class StatePlayer {
|
||||
setQueuePosition(video);
|
||||
}
|
||||
}
|
||||
|
||||
fun updateLastQueue() {
|
||||
val queueVideos = synchronized(_queue) {
|
||||
if (!_queue.isEmpty()) {
|
||||
return@synchronized _queue.map { SerializedPlatformVideo.fromVideo(it) }.toList()
|
||||
}
|
||||
|
||||
return@synchronized null
|
||||
}
|
||||
|
||||
if (queueVideos != null) {
|
||||
Logger.i(TAG, "Update last queue: ${queueVideos.size} videos.")
|
||||
val playlist = StatePlaylists.instance.getPlaylist(StatePlaylists.LAST_QUEUE_PLAYLIST_ID)?.apply {
|
||||
videos.clear()
|
||||
videos.addAll(queueVideos)
|
||||
} ?: Playlist("Last Queue", queueVideos).apply {
|
||||
id = StatePlaylists.LAST_QUEUE_PLAYLIST_ID
|
||||
}
|
||||
StatePlaylists.instance.createOrUpdatePlaylist(playlist)
|
||||
}
|
||||
}
|
||||
fun setQueuePosition(video: IPlatformVideo) {
|
||||
synchronized(_queue) {
|
||||
if (getCurrentQueueItem() == video) {
|
||||
|
||||
@@ -200,10 +200,10 @@ class StatePlaylists {
|
||||
}
|
||||
|
||||
fun getLastPlayedPlaylist() : Playlist? {
|
||||
return playlistStore.queryItem { it.maxByOrNull { x -> x.datePlayed } };
|
||||
return playlistStore.queryItem { it.filter { x -> x.id != StatePlaylists.LAST_QUEUE_PLAYLIST_ID }.maxByOrNull { x -> x.datePlayed } };
|
||||
}
|
||||
fun getLastUpdatedPlaylist() : Playlist? {
|
||||
return playlistStore.queryItem { it.maxByOrNull { x -> x.dateUpdate } };
|
||||
return playlistStore.queryItem { it.filter { x -> x.id != StatePlaylists.LAST_QUEUE_PLAYLIST_ID }.maxByOrNull { x -> x.dateUpdate } };
|
||||
}
|
||||
|
||||
fun getPlaylists() : List<Playlist> {
|
||||
@@ -394,6 +394,7 @@ class StatePlaylists {
|
||||
|
||||
companion object {
|
||||
val TAG = "StatePlaylists";
|
||||
val LAST_QUEUE_PLAYLIST_ID = "a70a3287-45dd-4227-832c-6ecde7fb1bf6"
|
||||
private var _instance : StatePlaylists? = null;
|
||||
private var _lockObject = Object()
|
||||
val instance : StatePlaylists
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourceAuth
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LoginFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment.Companion
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -167,7 +168,7 @@ class StatePlugins {
|
||||
if(config.authentication == null)
|
||||
return false;
|
||||
|
||||
LoginActivity.showLogin(context, config) {
|
||||
LoginFragment.showLogin(config) {//LoginActivity.showLogin(context, config) {
|
||||
try {
|
||||
StatePlugins.instance.setPluginAuth(config.id, it);
|
||||
} catch (e: Throwable) {
|
||||
@@ -300,6 +301,7 @@ class StatePlugins {
|
||||
StateAssets.readAssetBinRelative(context, assetConfigPath, config.iconUrl);
|
||||
else null;
|
||||
|
||||
//config.version = config.version - 1;
|
||||
createPlugin(config, script, icon, true);
|
||||
return true;
|
||||
}
|
||||
@@ -317,6 +319,15 @@ class StatePlugins {
|
||||
installPlugins(context, scope, sourceUrls.drop(1), handler);
|
||||
}
|
||||
}
|
||||
fun requestConfig(sourceUrl: String): SourcePluginConfig {
|
||||
val configResp = ManagedHttpClient().get(sourceUrl);
|
||||
if(!configResp.isOk)
|
||||
throw IllegalStateException("Failed request with ${configResp.code}");
|
||||
val configJson = configResp.body?.string();
|
||||
if(configJson.isNullOrEmpty())
|
||||
throw IllegalStateException("No response");
|
||||
return SourcePluginConfig.fromJson(configJson, sourceUrl);
|
||||
}
|
||||
fun installPlugin(context: Context, scope: CoroutineScope, sourceUrl: String, handler: ((Boolean) -> Unit)? = null) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val client = ManagedHttpClient();
|
||||
@@ -329,6 +340,7 @@ class StatePlugins {
|
||||
if(configJson.isNullOrEmpty())
|
||||
throw IllegalStateException("No response");
|
||||
config = SourcePluginConfig.fromJson(configJson, sourceUrl);
|
||||
//config.version = config.version - 1;
|
||||
}
|
||||
catch(ex: SerializationException) {
|
||||
Logger.e(TAG, "Failed decode config", ex);
|
||||
@@ -642,6 +654,9 @@ class StatePlugins {
|
||||
val descriptor = getPlugin(id) ?: throw IllegalArgumentException("Plugin [${id}] does not exist");
|
||||
descriptor.updateAuth(auth);
|
||||
_plugins.save(descriptor);
|
||||
|
||||
if(auth != null)
|
||||
UIDialogs.appToast("Plugin ${descriptor?.config?.name} logged in");
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -463,7 +463,7 @@ class StateSync {
|
||||
for(video in history){
|
||||
val hist = StateHistory.instance.getHistoryByVideo(video.video, true, video.date);
|
||||
if(hist != null)
|
||||
StateHistory.instance.updateHistoryPosition(video.video, hist, true, video.position, video.date)
|
||||
StateHistory.instance.updateHistoryPosition(video.video, hist, true, video.position, video.date, false, video.playlistId)
|
||||
if(lastHistory < video.date)
|
||||
lastHistory = video.date;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ class StateTelemetry {
|
||||
Build.BRAND,
|
||||
Build.MANUFACTURER,
|
||||
Build.MODEL,
|
||||
Build.VERSION.SDK_INT
|
||||
Build.VERSION.SDK_INT,
|
||||
StatePlatform.instance.getEnabledClients().map { it.id }.toList()
|
||||
);
|
||||
|
||||
val headers = hashMapOf(
|
||||
|
||||
@@ -11,7 +11,7 @@ class SearchHistoryStorage : FragmentedStorageFileJson() {
|
||||
if (!lastQueries.contains(text)) {
|
||||
lastQueries.add(0, text);
|
||||
if (lastQueries.size > 10)
|
||||
lastQueries.removeLast();
|
||||
lastQueries.removeAt(lastQueries.size - 1);
|
||||
}
|
||||
else {
|
||||
lastQueries.remove(text);
|
||||
|
||||
@@ -22,13 +22,14 @@ class LibrarySection: ConstraintLayout {
|
||||
val imageNavigate: ImageView;
|
||||
val recycler: RecyclerView;
|
||||
|
||||
val noContent: NoResultsView;
|
||||
|
||||
constructor(context: Context, attr: AttributeSet? = null) : super(context, attr) {
|
||||
inflate(context, R.layout.view_library_section, this);
|
||||
textName = findViewById(R.id.text_label)
|
||||
imageNavigate = findViewById(R.id.image_nav)
|
||||
recycler = findViewById(R.id.recycler_collection);
|
||||
|
||||
noContent = findViewById(R.id.container_no_content);
|
||||
}
|
||||
|
||||
fun setNavIcon(resId: Int) {
|
||||
@@ -46,4 +47,14 @@ class LibrarySection: ConstraintLayout {
|
||||
textName.text = title;
|
||||
imageNavigate.setOnClickListener { onOpen.invoke() };
|
||||
}
|
||||
|
||||
fun setEmpty(title: String, txt: String, iconId: Int) {
|
||||
noContent.isVisible = true;
|
||||
recycler.isVisible = false;
|
||||
noContent.setText(title, txt, iconId);
|
||||
}
|
||||
fun clearEmpty() {
|
||||
noContent.isVisible = false;
|
||||
recycler.isVisible = true;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.futo.platformplayer.views
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
@@ -15,6 +16,13 @@ class NoResultsView: ConstraintLayout {
|
||||
val icon: ImageView;
|
||||
val containerExtraViews: LinearLayout;
|
||||
|
||||
constructor(context: Context, attributes: AttributeSet? = null) : super(context, attributes){
|
||||
inflate(context, R.layout.view_no_results, this);
|
||||
textTitle = findViewById(R.id.text_title)
|
||||
textCentered = findViewById(R.id.text_centered);
|
||||
icon = findViewById(R.id.icon);
|
||||
containerExtraViews = findViewById(R.id.container_extra_views);
|
||||
}
|
||||
|
||||
constructor(context: Context, title: String, text: String, iconId: Int, extraViews: List<View>) : super(context) {
|
||||
inflate(context, R.layout.view_no_results, this);
|
||||
@@ -22,13 +30,21 @@ class NoResultsView: ConstraintLayout {
|
||||
textCentered = findViewById(R.id.text_centered);
|
||||
icon = findViewById(R.id.icon);
|
||||
containerExtraViews = findViewById(R.id.container_extra_views);
|
||||
|
||||
setText(title, text, iconId, extraViews);
|
||||
}
|
||||
|
||||
|
||||
fun setText(title: String, text: String, iconId: Int = -1, extraViews: List<View>? = null) {
|
||||
textTitle.text = title;
|
||||
textCentered.text = text;
|
||||
icon.setImageResource(iconId);
|
||||
if(iconId < 0)
|
||||
icon.visibility = GONE;
|
||||
else
|
||||
icon.setImageResource(iconId);
|
||||
|
||||
for(view in extraViews)
|
||||
containerExtraViews.addView(view);
|
||||
if(extraViews != null)
|
||||
for(view in extraViews)
|
||||
containerExtraViews.addView(view);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,7 @@ package com.futo.platformplayer.views.adapters
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.view.GestureDetector
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
@@ -46,6 +44,7 @@ class CommentViewHolder : ViewHolder {
|
||||
private val _imageLikeIcon: ImageView;
|
||||
private val _textLikes: TextView;
|
||||
private val _imageDislikeIcon: ImageView;
|
||||
private val _buttonCopy: PillButton;
|
||||
private val _textDislikes: TextView;
|
||||
private val _buttonReplies: PillButton;
|
||||
private val _layoutRating: LinearLayout;
|
||||
@@ -69,6 +68,7 @@ class CommentViewHolder : ViewHolder {
|
||||
_textMetadata = itemView.findViewById(R.id.text_metadata);
|
||||
_textBody = itemView.findViewById(R.id.text_body);
|
||||
_imageLikeIcon = itemView.findViewById(R.id.image_like_icon);
|
||||
_buttonCopy = itemView.findViewById(R.id.image_copy);
|
||||
_textLikes = itemView.findViewById(R.id.text_likes);
|
||||
_imageDislikeIcon = itemView.findViewById(R.id.image_dislike_icon);
|
||||
_textDislikes = itemView.findViewById(R.id.text_dislikes);
|
||||
@@ -103,7 +103,8 @@ class CommentViewHolder : ViewHolder {
|
||||
StatePolycentric.instance.updateLikeMap(c.reference, args.hasLiked, args.hasDisliked)
|
||||
};
|
||||
|
||||
_layoutComment.setOnLongClickListener {
|
||||
_buttonCopy.setTransparant()
|
||||
_buttonCopy.onClick.subscribe {
|
||||
val clipboard = viewGroup.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val text = comment?.message.orEmpty()
|
||||
val clip = ClipData.newPlainText("Comment", text)
|
||||
|
||||
+5
-4
@@ -37,13 +37,14 @@ class ArtistTileViewHolder(val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder
|
||||
override fun bind(artist: Artist) {
|
||||
_artist = artist;
|
||||
_imageThumbnail?.let {
|
||||
if (artist.thumbnail != null)
|
||||
val thumbnail = artist.getThumbnailOrAlbum();
|
||||
if (thumbnail != null)
|
||||
Glide.with(it)
|
||||
.load(artist.thumbnail)
|
||||
.placeholder(R.drawable.unknown_music)
|
||||
.load(thumbnail)
|
||||
.placeholder(R.drawable.ic_artist)
|
||||
.into(it)
|
||||
else
|
||||
Glide.with(it).load(R.drawable.unknown_music).into(it);
|
||||
Glide.with(it).load(R.drawable.ic_artist).into(it);
|
||||
};
|
||||
|
||||
_textName.text = artist.name;
|
||||
|
||||
+2
-2
@@ -42,11 +42,11 @@ class FileViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHold
|
||||
_file = file;
|
||||
_imageThumbnail?.let {
|
||||
if(file.isDirectory)
|
||||
it.setImageResource(R.drawable.ic_library);
|
||||
it.setImageResource(R.drawable.ic_folder);
|
||||
else {
|
||||
Glide.with(it)
|
||||
.load(file.thumbnail)
|
||||
.placeholder(R.drawable.ic_music)
|
||||
.placeholder(R.drawable.ic_song)
|
||||
.into(it)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -42,6 +42,7 @@ import kotlinx.coroutines.launch
|
||||
import java.text.DecimalFormat
|
||||
import java.text.DecimalFormatSymbols
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
|
||||
|
||||
class GestureControlView : LinearLayout {
|
||||
@@ -114,6 +115,7 @@ class GestureControlView : LinearLayout {
|
||||
val onZoom = Event1<Float>();
|
||||
val onSoundAdjusted = Event1<Float>();
|
||||
val onToggleFullscreen = Event0();
|
||||
val onTogglePlayPause = Event0();
|
||||
val onSpeedHoldStart = Event0()
|
||||
val onSpeedHoldEnd = Event0()
|
||||
|
||||
@@ -269,8 +271,19 @@ class GestureControlView : LinearLayout {
|
||||
return false;
|
||||
}
|
||||
|
||||
val rewinding = (ev.x / width) < 0.5;
|
||||
startFastForward(rewinding);
|
||||
val centerArea = if (height != 0) 0.33f + (0.20f - 0.33f) * (((width.toFloat() / height.toFloat()) - 1f) / ((20f / 9f) - 1f)).coerceIn(0f, 1f) else 0.2f
|
||||
val rewindArea = (1 - centerArea) / 2
|
||||
val forwardArea = rewindArea
|
||||
assert(abs(centerArea + rewindArea + forwardArea - 1) < 0.01)
|
||||
|
||||
val xfrac = ev.x / width
|
||||
if (xfrac <= rewindArea) {
|
||||
startFastForward(true)
|
||||
} else if (xfrac >= 1 - forwardArea) {
|
||||
startFastForward(false)
|
||||
} else {
|
||||
onTogglePlayPause.emit()
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.futo.platformplayer.views.buttons
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.collection.emptyLongSet
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.views.pills.PillButton
|
||||
|
||||
class ButtonsContainer : LinearLayout {
|
||||
|
||||
val container_buttons: LinearLayout
|
||||
|
||||
var currentButtons: List<Button> = listOf();
|
||||
|
||||
constructor(context: Context, buttons: List<Button>) : super(context) {
|
||||
inflate(context, R.layout.view_buttons, this)
|
||||
container_buttons = findViewById(R.id.container_buttons);
|
||||
setButtons(buttons);
|
||||
}
|
||||
|
||||
fun setButtons(buttons: List<Button>) {
|
||||
this.currentButtons = buttons;
|
||||
container_buttons.removeAllViews();
|
||||
for(button in buttons) {
|
||||
container_buttons.addView(StandardButton(context, button.name) {
|
||||
button?.handler?.invoke();
|
||||
}.apply {
|
||||
this.layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
this.weight = 1f;
|
||||
};
|
||||
if(button.background != null)
|
||||
this.withBackground(button.background);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class Button(
|
||||
val name: String,
|
||||
val background: Int?,
|
||||
val handler: (()->Unit),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.futo.platformplayer.views.buttons
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.futo.platformplayer.R
|
||||
|
||||
class StandardButton : LinearLayout {
|
||||
private val _root: LinearLayout;
|
||||
private val _text: TextView;
|
||||
|
||||
constructor(context: Context, text: String, onClick: ()->Unit) : super(context) {
|
||||
inflate(context, R.layout.view_button_standard, this);
|
||||
_root = findViewById(R.id.root);
|
||||
_text = findViewById(R.id.text_button);
|
||||
_text.text = text;
|
||||
_root.setOnClickListener {
|
||||
onClick.invoke();
|
||||
}
|
||||
}
|
||||
|
||||
fun withPrimaryBackground(): StandardButton {
|
||||
_root.setBackgroundResource(R.drawable.background_button_primary)
|
||||
return this;
|
||||
}
|
||||
fun withAccentBackground(): StandardButton {
|
||||
_root.setBackgroundResource(R.drawable.background_button_accent)
|
||||
return this;
|
||||
}
|
||||
fun withBackground(id: Int): StandardButton {
|
||||
_root.setBackgroundResource(id);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.DefaultTimeBar
|
||||
import androidx.media3.ui.TimeBar
|
||||
@@ -43,6 +44,7 @@ class CastView : ConstraintLayout {
|
||||
private val _buttonSettings: ImageButton;
|
||||
private val _buttonLoop: ImageButton;
|
||||
private val _buttonPlay: ImageButton;
|
||||
private val _buttonAutoplay: ImageButton;
|
||||
private val _buttonPrevious: ImageButton;
|
||||
private val _buttonNext: ImageButton;
|
||||
private val _buttonPause: ImageButton;
|
||||
@@ -78,6 +80,7 @@ class CastView : ConstraintLayout {
|
||||
_buttonMinimize = findViewById(R.id.button_minimize);
|
||||
_buttonSettings = findViewById(R.id.button_settings);
|
||||
_buttonLoop = findViewById(R.id.button_loop);
|
||||
_buttonAutoplay = findViewById(R.id.button_autoplay);
|
||||
_buttonPlay = findViewById(R.id.button_play);
|
||||
_buttonPrevious = findViewById(R.id.button_previous);
|
||||
_buttonNext = findViewById(R.id.button_next);
|
||||
@@ -119,6 +122,15 @@ class CastView : ConstraintLayout {
|
||||
Logger.e(TAG, "Failed to change playback speed to previous hold playback speed: $e")
|
||||
}
|
||||
}
|
||||
_gestureControlView.onTogglePlayPause.subscribe {
|
||||
StateCasting.instance.activeDevice?.let { d ->
|
||||
if (d.isPlaying) {
|
||||
d.pausePlayback()
|
||||
} else {
|
||||
d.resumePlayback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_gestureControlView.onSeek.subscribe {
|
||||
val d = StateCasting.instance.activeDevice ?: return@subscribe;
|
||||
@@ -169,6 +181,17 @@ class CastView : ConstraintLayout {
|
||||
updateNextPrevious();
|
||||
_buttonPrevious.setOnClickListener { onPrevious.emit() };
|
||||
_buttonNext.setOnClickListener { onNext.emit() };
|
||||
|
||||
_buttonAutoplay.setOnClickListener {
|
||||
StatePlayer.instance.autoplay = !StatePlayer.instance.autoplay;
|
||||
updateAutoplayButton()
|
||||
}
|
||||
updateAutoplayButton()
|
||||
}
|
||||
|
||||
private fun updateAutoplayButton() {
|
||||
_buttonAutoplay.setColorFilter(ContextCompat.getColor(context, if (StatePlayer.instance.autoplay) com.futo.futopay.R.color.primary else R.color.white))
|
||||
_buttonAutoplay.setColorFilter(ContextCompat.getColor(context, if (StatePlayer.instance.autoplay) com.futo.futopay.R.color.primary else R.color.white))
|
||||
}
|
||||
|
||||
private fun updateCurrentChapter(chaptPos: Long, isScrub: Boolean = false): Boolean {
|
||||
|
||||
@@ -54,9 +54,14 @@ class CreatorThumbnail : ConstraintLayout {
|
||||
setNewActivity(false);
|
||||
}
|
||||
|
||||
fun setThumbnail(url: String?, animate: Boolean) {
|
||||
fun setThumbnail(url: String?, animate: Boolean, isArtist: Boolean = false) {
|
||||
if (url == null) {
|
||||
clear();
|
||||
if(isArtist) {
|
||||
_imageChannelThumbnail.setImageResource(R.drawable.ic_artist);
|
||||
_imageChannelThumbnail.visibility = View.VISIBLE;
|
||||
}
|
||||
else
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,18 +83,21 @@ class CreatorThumbnail : ConstraintLayout {
|
||||
} else {
|
||||
setHarborAvailable(false, animate, null);
|
||||
}
|
||||
var placeholder = R.drawable.placeholder_channel_thumbnail;
|
||||
if(url.startsWith("content://") || isArtist)
|
||||
placeholder = R.drawable.ic_artist;
|
||||
|
||||
if (animate) {
|
||||
Glide.with(_imageChannelThumbnail)
|
||||
.load(url)
|
||||
.placeholder(R.drawable.placeholder_channel_thumbnail)
|
||||
.placeholder(placeholder)
|
||||
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
|
||||
.crossfade()
|
||||
.into(_imageChannelThumbnail)
|
||||
} else {
|
||||
Glide.with(_imageChannelThumbnail)
|
||||
.load(url)
|
||||
.placeholder(R.drawable.placeholder_channel_thumbnail)
|
||||
.placeholder(placeholder)
|
||||
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
|
||||
.into(_imageChannelThumbnail);
|
||||
}
|
||||
|
||||
@@ -7,22 +7,27 @@ import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.core.view.isVisible
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.views.LoaderView
|
||||
|
||||
class PillButton : LinearLayout {
|
||||
val root: LinearLayout;
|
||||
val icon: ImageView;
|
||||
val text: TextView;
|
||||
val loaderView: LoaderView;
|
||||
val onClick = Event0();
|
||||
private var _isLoading = false;
|
||||
|
||||
constructor(context : Context, attrs : AttributeSet) : super(context, attrs) {
|
||||
constructor(context : Context, attrs : AttributeSet?) : super(context, attrs) {
|
||||
LayoutInflater.from(context).inflate(R.layout.pill_button, this, true);
|
||||
icon = findViewById(R.id.pill_icon);
|
||||
text = findViewById(R.id.pill_text);
|
||||
loaderView = findViewById(R.id.loader)
|
||||
root = findViewById<LinearLayout>(R.id.root);
|
||||
|
||||
val attrArr = context.obtainStyledAttributes(attrs, R.styleable.PillButton, 0, 0);
|
||||
val attrIconRef = attrArr.getResourceId(R.styleable.PillButton_pillIcon, -1);
|
||||
@@ -34,6 +39,13 @@ class PillButton : LinearLayout {
|
||||
val attrText = attrArr.getText(R.styleable.PillButton_pillText) ?: "";
|
||||
text.text = attrText;
|
||||
|
||||
if(text.text.isNullOrBlank()) {
|
||||
val dp6 = 6.dp(resources);
|
||||
val dp7 = 7.dp(resources);
|
||||
val dp12 = 12.dp(resources);
|
||||
root.setPadding(dp7, dp6, dp7, dp7)
|
||||
}
|
||||
|
||||
findViewById<LinearLayout>(R.id.root).setOnClickListener {
|
||||
if (_isLoading) {
|
||||
return@setOnClickListener
|
||||
@@ -43,6 +55,10 @@ class PillButton : LinearLayout {
|
||||
};
|
||||
}
|
||||
|
||||
fun setTransparant() {
|
||||
root.setBackgroundColor(0);
|
||||
}
|
||||
|
||||
fun setLoading(loading: Boolean) {
|
||||
if (loading == _isLoading) {
|
||||
return
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.comments.LazyComment
|
||||
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||
import com.futo.platformplayer.api.media.structures.IAsyncPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
@@ -224,6 +225,12 @@ class CommentsList : ConstraintLayout {
|
||||
_commentsPager = pager;
|
||||
onCommentsLoaded.emit(_comments.size);
|
||||
}
|
||||
fun clearComments() {
|
||||
_comments.clear();
|
||||
_adapterComments.notifyDataSetChanged();
|
||||
_commentsPager = EmptyPager();
|
||||
onCommentsLoaded.emit(0);
|
||||
}
|
||||
|
||||
fun load(readonly: Boolean, loader: suspend () -> IPager<IPlatformComment>) {
|
||||
cancel();
|
||||
|
||||
@@ -285,6 +285,15 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
gestureControl.onTogglePlayPause.subscribe {
|
||||
exoPlayer?.player?.let { player ->
|
||||
if (player.playWhenReady) {
|
||||
player.pause()
|
||||
} else {
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
gestureControl.onSpeedHoldEnd.subscribe {
|
||||
exoPlayer?.player?.let { player ->
|
||||
if (!_speedHoldWasPlaying) player.pause()
|
||||
|
||||
@@ -923,7 +923,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun loadSelectedSources(play: Boolean, resume: Boolean): Boolean {
|
||||
val sourceVideo = if(!isAudioMode || _lastAudioMediaSource == null) _lastVideoMediaSource else null;
|
||||
val sourceVideo = _lastVideoMediaSource;
|
||||
val sourceAudio = _lastAudioMediaSource;
|
||||
val sourceSubs = _lastSubtitleMediaSource;
|
||||
|
||||
|
||||
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#CC000000" />
|
||||
<stroke android:color="#333333" android:width="1dp" />
|
||||
<corners android:radius="1dp" />
|
||||
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#00000000" />
|
||||
<stroke android:color="#000000" android:width="1dp" />
|
||||
<corners android:radius="50dp" />
|
||||
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#000000" />
|
||||
<stroke android:color="#333333" android:width="1dp" />
|
||||
<corners android:radius="50dp" />
|
||||
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#2D63ED" />
|
||||
<stroke android:color="#333333" android:width="1dp" />
|
||||
<corners android:radius="50dp" />
|
||||
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
|
||||
</shape>
|
||||
@@ -2,8 +2,7 @@
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M450,780L450,294.92L222.15,522.77L180,480L480,180L780,480L737.85,522.77L510,294.92L510,780L450,780Z"/>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M294.23,860L294.23,779.23L411.15,697.15L411.15,538.54L100,663.46L100,564.23L411.15,345.62L411.15,168.85Q411.15,140.46 431.39,120.23Q451.62,100 480,100Q508.38,100 528.61,120.23Q548.85,140.46 548.85,168.85L548.85,345.62L860,564.23L860,663.46L548.85,538.54L548.85,697.15L665.38,779.23L665.38,860L480,803.84L294.23,860Z"/>
|
||||
</vector>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 462 B |
@@ -2,9 +2,8 @@
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M493.85,593.85Q531.61,593.85 557.73,567.73Q583.84,541.62 583.84,503.85L583.84,279.23L698.46,279.23L698.46,208.46L548.46,208.46L548.46,434.62Q537,424.23 523.35,419.04Q509.69,413.85 493.85,413.85Q456.08,413.85 429.96,439.96Q403.85,466.08 403.85,503.85Q403.85,541.62 429.96,567.73Q456.08,593.85 493.85,593.85ZM322.31,700Q292,700 271,679Q250,658 250,627.69L250,172.31Q250,142 271,121Q292,100 322.31,100L777.69,100Q808,100 829,121Q850,142 850,172.31L850,627.69Q850,658 829,679Q808,700 777.69,700L322.31,700ZM322.31,640L777.69,640Q782.31,640 786.15,636.15Q790,632.31 790,627.69L790,172.31Q790,167.69 786.15,163.85Q782.31,160 777.69,160L322.31,160Q317.69,160 313.85,163.85Q310,167.69 310,172.31L310,627.69Q310,632.31 313.85,636.15Q317.69,640 322.31,640ZM182.31,840Q152,840 131,819Q110,798 110,767.69L110,252.31L170,252.31L170,767.69Q170,772.31 173.85,776.15Q177.69,780 182.31,780L697.69,780L697.69,840L182.31,840ZM310,160L310,160Q310,160 310,163.46Q310,166.92 310,172.31L310,627.69Q310,633.08 310,636.54Q310,640 310,640L310,640Q310,640 310,636.54Q310,633.08 310,627.69L310,172.31Q310,166.92 310,163.46Q310,160 310,160Z"/>
|
||||
android:pathData="M493.85,593.85Q531.61,593.85 557.73,567.73Q583.84,541.62 583.84,503.85L583.84,279.23L698.46,279.23L698.46,208.46L548.46,208.46L548.46,434.62Q537,424.23 523.35,419.04Q509.69,413.85 493.85,413.85Q456.08,413.85 429.96,439.96Q403.85,466.08 403.85,503.85Q403.85,541.62 429.96,567.73Q456.08,593.85 493.85,593.85ZM322.31,700Q292,700 271,679Q250,658 250,627.69L250,172.31Q250,142 271,121Q292,100 322.31,100L777.69,100Q808,100 829,121Q850,142 850,172.31L850,627.69Q850,658 829,679Q808,700 777.69,700L322.31,700ZM182.31,840Q152,840 131,819Q110,798 110,767.69L110,252.31L170,252.31L170,767.69Q170,772.31 173.85,776.15Q177.69,780 182.31,780L697.69,780L697.69,840L182.31,840Z"/>
|
||||
</vector>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 826 B |
@@ -29,6 +29,8 @@
|
||||
android:id="@+id/image_qr"
|
||||
android:layout_width="200dp"
|
||||
android:layout_height="200dp"
|
||||
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||
android:padding="8dp"
|
||||
app:srcCompat="@drawable/ic_qr"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
@@ -37,15 +39,31 @@
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_qr"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/scan_to_import"
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:textSize="32dp"
|
||||
android:textAlignment="center"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginEnd="20dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/image_qr"
|
||||
app:layout_constraintLeft_toLeftOf="@id/image_qr"
|
||||
app:layout_constraintRight_toRightOf="@id/image_qr" />
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_qr_hint"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/tap_qr_code_for_fullscreen"
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:textSize="14dp"
|
||||
android:textColor="@color/gray_400"
|
||||
android:layout_marginTop="8dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_qr"
|
||||
app:layout_constraintLeft_toLeftOf="@id/text_qr"
|
||||
app:layout_constraintRight_toRightOf="@id/text_qr" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_buttons"
|
||||
@@ -55,7 +73,7 @@
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:layout_marginTop="30dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_qr"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_qr_hint"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent">
|
||||
|
||||
@@ -75,6 +93,15 @@
|
||||
app:buttonSubText="@string/copy_your_identity_to_clipboard"
|
||||
app:buttonIcon="@drawable/ic_copy"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<com.futo.platformplayer.views.buttons.BigButton
|
||||
android:id="@+id/button_export_file"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:buttonText="@string/export_to_file"
|
||||
app:buttonSubText="@string/save_profile_to_file_for_sharing"
|
||||
app:buttonIcon="@drawable/ic_download"
|
||||
android:layout_marginTop="8dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<ProgressBar
|
||||
|
||||
@@ -47,6 +47,28 @@
|
||||
android:text="@string/scan_qr" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/button_import_file"
|
||||
android:layout_width="140dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/background_button_primary_round"
|
||||
android:gravity="center"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:orientation="horizontal"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/button_scan_profile">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16dp"
|
||||
android:text="@string/import_from_file" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_or"
|
||||
android:layout_width="wrap_content"
|
||||
@@ -55,7 +77,7 @@
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:textSize="28dp"
|
||||
android:layout_marginTop="30dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/button_scan_profile"
|
||||
app:layout_constraintTop_toBottomOf="@id/button_import_file"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent" />
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/black">
|
||||
|
||||
<!-- Top navigation bar -->
|
||||
<ImageButton
|
||||
android:id="@+id/button_back_fullscreen"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="50dp"
|
||||
android:contentDescription="@string/cd_button_back"
|
||||
android:padding="10dp"
|
||||
android:scaleType="fitCenter"
|
||||
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||
app:srcCompat="@drawable/ic_back_thin_white_16dp"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginStart="16dp" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/button_close_fullscreen"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="50dp"
|
||||
android:contentDescription="@string/cd_button_close"
|
||||
android:padding="10dp"
|
||||
android:scaleType="fitCenter"
|
||||
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||
app:srcCompat="@drawable/ic_close"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="16dp" />
|
||||
|
||||
<!-- Full screen QR code -->
|
||||
<ImageView
|
||||
android:id="@+id/image_qr_fullscreen"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:scaleType="fitCenter"
|
||||
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||
android:padding="40dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/button_back_fullscreen"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
android:layout_marginTop="20dp"
|
||||
android:layout_marginBottom="20dp"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginEnd="20dp" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
@@ -147,12 +147,32 @@
|
||||
android:textColor="@color/colorPrimary"
|
||||
android:background="@color/transparent" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/button_always"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/background_button_accent"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:clickable="true">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Always"
|
||||
android:textSize="14dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:paddingStart="15dp"
|
||||
android:paddingEnd="15dp"/>
|
||||
</LinearLayout>
|
||||
<LinearLayout
|
||||
android:id="@+id/button_update"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/background_button_primary"
|
||||
android:layout_marginEnd="28dp"
|
||||
android:layout_marginEnd="15dp"
|
||||
android:clickable="true">
|
||||
|
||||
<TextView
|
||||
@@ -164,8 +184,8 @@
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:paddingStart="28dp"
|
||||
android:paddingEnd="28dp"/>
|
||||
android:paddingStart="20dp"
|
||||
android:paddingEnd="20dp"/>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -295,6 +315,26 @@
|
||||
android:layout_marginStart="30dp"
|
||||
android:layout_marginEnd="30dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_changelog_result"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="80dp"
|
||||
android:textAlignment="textStart"
|
||||
android:text="Changelog"
|
||||
android:textSize="9.5sp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="monospace"
|
||||
android:scrollbars="vertical"
|
||||
|
||||
android:background="@color/black"
|
||||
android:paddingLeft="10dp"
|
||||
android:paddingRight="10dp"
|
||||
android:paddingTop="5dp"
|
||||
android:paddingBottom="5dp"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_marginStart="10dp"
|
||||
android:layout_marginEnd="10dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
<com.futo.platformplayer.views.others.CreatorThumbnail
|
||||
android:id="@+id/creator_thumbnail"
|
||||
android:background="@drawable/rounded_outline"
|
||||
android:layout_width="1dp"
|
||||
android:layout_width="35dp"
|
||||
android:layout_height="35dp"
|
||||
android:contentDescription="@string/cd_creator_thumbnail"
|
||||
android:layout_marginStart="8dp"
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout
|
||||
android:id="@+id/feed_root"
|
||||
<androidx.constraintlayout.widget.ConstraintLayout android:id="@+id/feed_root"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
android:layout_height="match_parent"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@id/container_bottom"
|
||||
android:orientation="vertical"
|
||||
tools:context=".fragment.mainactivity.main.FeedFragment">
|
||||
|
||||
@@ -124,6 +126,12 @@
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/container_bottom"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintBottom_toBottomOf="parent" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/overlay_container"
|
||||
android:layout_width="match_parent"
|
||||
@@ -131,4 +139,4 @@
|
||||
android:elevation="100dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
</FrameLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -25,14 +25,96 @@
|
||||
</FrameLayout>
|
||||
|
||||
<!--More Menu-->
|
||||
<LinearLayout
|
||||
android:id="@+id/more_menu_buttons"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:gravity="end">
|
||||
</LinearLayout>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/container_more_options"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="3dp"
|
||||
android:layout_marginLeft="3dp"
|
||||
android:layout_marginRight="0dp"
|
||||
app:layout_constraintBottom_toTopOf="@id/more_menu_buttons"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent">
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_marginBottom="15dp"
|
||||
android:layout_marginRight="0dp"
|
||||
android:gravity="center">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/container_toggle_airplane"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="50dp"
|
||||
android:background="@drawable/background_menu_toggle"
|
||||
android:layout_marginRight="3dp"
|
||||
android:layout_marginLeft="3dp"
|
||||
android:gravity="center">
|
||||
<ImageView
|
||||
android:layout_width="30dp"
|
||||
android:layout_height="30dp"
|
||||
android:src="@drawable/ic_flight" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/container_toggle_privacy"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="50dp"
|
||||
android:background="@drawable/background_menu_toggle"
|
||||
android:layout_marginRight="3dp"
|
||||
android:layout_marginLeft="3dp"
|
||||
android:gravity="center">
|
||||
<ImageView
|
||||
android:layout_width="30dp"
|
||||
android:layout_height="30dp"
|
||||
android:src="@drawable/ic_disabled_visible" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/button_close"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="50dp"
|
||||
android:layout_marginRight="0dp"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:layout_marginTop="10dp"
|
||||
android:gravity="center">
|
||||
<ImageView
|
||||
android:layout_width="30dp"
|
||||
android:layout_height="30dp"
|
||||
android:src="@drawable/ic_close" />
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/more_menu_buttons"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_marginBottom="5dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
android:layoutDirection="rtl"
|
||||
|
||||
android:gravity="end">
|
||||
</androidx.recyclerview.widget.RecyclerView>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<com.futo.platformplayer.views.casting.CastView
|
||||
android:id="@+id/videodetail_cast"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_height="300dp"
|
||||
android:background="@color/transparent"
|
||||
android:visibility="gone"
|
||||
android:elevation="4dp"
|
||||
|
||||
@@ -150,6 +150,15 @@
|
||||
android:textColor="@color/white"
|
||||
android:textSize="13dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<com.futo.platformplayer.views.pills.PillButton
|
||||
android:id="@+id/image_copy"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:pillIcon="@drawable/ic_copy"
|
||||
app:pillText=""
|
||||
android:layout_marginStart="15dp"
|
||||
android:layout_marginEnd="0dp"/>
|
||||
<com.futo.platformplayer.views.pills.PillButton
|
||||
android:id="@+id/button_replies"
|
||||
android:layout_width="wrap_content"
|
||||
@@ -157,7 +166,7 @@
|
||||
android:contentDescription="@string/cd_button_replies"
|
||||
app:pillIcon="@drawable/ic_forum"
|
||||
app:pillText="55 Replies"
|
||||
android:layout_marginStart="15dp" />
|
||||
android:layout_marginStart="2dp" />
|
||||
|
||||
<Space android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
android:layout_marginBottom="5dp"
|
||||
android:layout_marginLeft="10dp"
|
||||
android:layout_marginRight="10dp"
|
||||
android:background="@drawable/background_16_round_4dp"
|
||||
android:background="@drawable/background_1b_round_6dp"
|
||||
android:id="@+id/root"
|
||||
android:clickable="true">
|
||||
|
||||
@@ -23,12 +23,10 @@
|
||||
android:layout_marginLeft="8dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
android:background="@drawable/background_1b_round_6dp">
|
||||
app:layout_constraintLeft_toLeftOf="parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/image_thumbnail"
|
||||
android:alpha="0.4"
|
||||
android:layout_height="34dp"
|
||||
android:layout_width="34dp"
|
||||
android:scaleType="centerCrop"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="86dp"
|
||||
android:layout_height="146dp"
|
||||
android:layout_marginTop="3dp"
|
||||
android:layout_marginBottom="3dp"
|
||||
android:layout_marginLeft="3dp"
|
||||
android:layout_marginRight="3dp"
|
||||
android:background="@drawable/background_menu"
|
||||
android:id="@+id/root"
|
||||
android:clickable="true">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/image_icon"
|
||||
android:layout_height="50dp"
|
||||
android:layout_width="50dp"
|
||||
android:scaleType="centerCrop"
|
||||
app:layout_constraintDimensionRatio="H,1,1"
|
||||
app:shapeAppearanceOverlay="@style/roundedCorners_4dp"
|
||||
app:srcCompat="@drawable/unknown_music"
|
||||
android:background="@drawable/video_thumbnail_outline"
|
||||
android:layout_marginTop="12dp"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent" />
|
||||
|
||||
<!--TODO: Fix word wrapping with autosize-->
|
||||
<TextView
|
||||
android:id="@+id/text_name"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="11dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_medium"
|
||||
tools:text="The Beetles"
|
||||
android:maxLines="2"
|
||||
|
||||
android:gravity="center"
|
||||
app:layout_constraintLeft_toRightOf="@id/image_icon"
|
||||
app:layout_constraintTop_toBottomOf="@id/image_icon"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:layout_marginLeft="10dp"
|
||||
android:layout_marginRight="10dp"
|
||||
android:layout_marginTop="0dp"
|
||||
/>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:layout_gravity="bottom"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/background_button_accent"
|
||||
android:gravity="center"
|
||||
android:id="@+id/root">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:autoSizeTextType="uniform"
|
||||
android:padding="12dp"
|
||||
android:text="Play all" />
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="bottom"
|
||||
android:orientation="vertical"
|
||||
android:id="@+id/root">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/container_buttons"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="14dp"
|
||||
android:layout_marginEnd="14dp"
|
||||
android:layout_marginTop="14dp"
|
||||
android:layout_marginBottom="14dp"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
android:showDividers="middle"
|
||||
android:divider="@drawable/divider_transparent_4dp">
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
@@ -6,15 +6,16 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="bottom"
|
||||
android:orientation="vertical">
|
||||
android:orientation="vertical"
|
||||
android:background="@color/black">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/image_thumbnail"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scaleType="centerCrop"
|
||||
android:scaleType="fitCenter"
|
||||
app:srcCompat="@drawable/placeholder_video_thumbnail"
|
||||
android:layout_marginBottom="7dp"/>
|
||||
android:layout_marginBottom="7dp" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/layout_background"
|
||||
@@ -46,6 +47,17 @@
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/button_autoplay"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="50dp"
|
||||
android:contentDescription="@string/cd_button_autoplay"
|
||||
android:scaleType="fitCenter"
|
||||
android:clickable="true"
|
||||
android:padding="12dp"
|
||||
app:srcCompat="@drawable/autoplay_24px" />
|
||||
|
||||
<com.futo.platformplayer.views.casting.CastButton
|
||||
android:id="@+id/button_cast"
|
||||
android:layout_width="50dp"
|
||||
|
||||
@@ -41,6 +41,16 @@
|
||||
android:layout_marginTop="10dp"
|
||||
android:orientation="horizontal" />
|
||||
|
||||
<com.futo.platformplayer.views.NoResultsView
|
||||
android:id="@+id/container_no_content"
|
||||
android:visibility="gone"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_label"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent" />
|
||||
|
||||
<!--
|
||||
<ImageButton
|
||||
android:id="@+id/button_play"
|
||||
|
||||
@@ -388,6 +388,7 @@
|
||||
<string name="unhandled_exception_in_vs">استثناء غير معالج في VS</string>
|
||||
<string name="send_exception_to_developers">إرسال الاستثناء للمطورين…</string>
|
||||
<string name="your_license_key_has_been_set_an_app_restart_might_be_required">تم تعيين مفتاح الترخيص الخاص بك!\nقد يكون هناك حاجة لإعادة تشغيل التطبيق.</string>
|
||||
<string name="qr_code_too_large_use_text_below">رمز الاستجابة السريعة كبير جدًا. استخدم النص أدناه لمشاركة ملفك الشخصي.</string>
|
||||
<string name="invalid_license_format">تنسيق الترخيص غير صالح</string>
|
||||
<string name="unknown_content_format">تنسيق المحتوى غير معروف</string>
|
||||
<string name="unknown_file_format">تنسيق الملف غير معروف</string>
|
||||
|
||||
@@ -395,6 +395,7 @@
|
||||
<string name="unhandled_exception_in_vs">Unbehandelte Ausnahme in VS</string>
|
||||
<string name="send_exception_to_developers">Ausnahme an Entwickler senden…</string>
|
||||
<string name="your_license_key_has_been_set_an_app_restart_might_be_required">Ihr Lizenzschlüssel wurde festgelegt!\nEin Neustart der App könnte erforderlich sein.</string>
|
||||
<string name="qr_code_too_large_use_text_below">QR-Code zu groß. Verwenden Sie den Text unten, um Ihr Profil zu teilen.</string>
|
||||
<string name="invalid_license_format">Ungültiges Lizenzformat</string>
|
||||
<string name="unknown_content_format">Unbekanntes Inhaltsformat</string>
|
||||
<string name="unknown_file_format">Unbekanntes Dateiformat</string>
|
||||
|
||||
@@ -372,6 +372,7 @@
|
||||
<string name="unhandled_exception_in_vs">Excepción no manejada en VS</string>
|
||||
<string name="send_exception_to_developers">Enviar excepción a los desarrolladores...</string>
|
||||
<string name="your_license_key_has_been_set_an_app_restart_might_be_required">¡Se ha configurado tu clave de licencia!\nPuede ser necesario reiniciar la aplicación.</string>
|
||||
<string name="qr_code_too_large_use_text_below">Código QR demasiado grande. Use el texto de abajo para compartir su perfil.</string>
|
||||
<string name="invalid_license_format">Formato de licencia no válido</string>
|
||||
<string name="unknown_content_format">Formato de contenido desconocido</string>
|
||||
<string name="unknown_file_format">Formato de archivo desconocido</string>
|
||||
@@ -609,17 +610,6 @@
|
||||
<item>Recomendaciones</item>
|
||||
<item>Suscripciones</item>
|
||||
</string-array>
|
||||
<string-array name="playback_speeds">
|
||||
<item>0.25</item>
|
||||
<item>0.5</item>
|
||||
<item>0.75</item>
|
||||
<item>1.0</item>
|
||||
<item>1.25</item>
|
||||
<item>1.5</item>
|
||||
<item>1.75</item>
|
||||
<item>2.0</item>
|
||||
<item>2.25</item>
|
||||
</string-array>
|
||||
<string-array name="preferred_quality_array">
|
||||
<item>Automático (720p)</item>
|
||||
<item>2160p</item>
|
||||
|
||||
@@ -411,6 +411,7 @@
|
||||
<string name="unhandled_exception_in_vs">Exception non gérée dans VS</string>
|
||||
<string name="send_exception_to_developers">Envoyer l\'exception aux développeurs…</string>
|
||||
<string name="your_license_key_has_been_set_an_app_restart_might_be_required">Votre clé de licence a été définie !\nUn redémarrage de l\'application peut être nécessaire.</string>
|
||||
<string name="qr_code_too_large_use_text_below">Code QR trop volumineux. Utilisez le texte ci-dessous pour partager votre profil.</string>
|
||||
<string name="invalid_license_format">Format de licence invalide</string>
|
||||
<string name="unknown_content_format">Format de contenu inconnu</string>
|
||||
<string name="unknown_file_format">Format de fichier inconnu</string>
|
||||
|
||||
@@ -617,6 +617,7 @@
|
||||
<string name="unhandled_exception_in_vs">Eccezione non gestita in VS</string>
|
||||
<string name="send_exception_to_developers">Invio eccezione agli sviluppatori…</string>
|
||||
<string name="your_license_key_has_been_set_an_app_restart_might_be_required">La tua chiave di licenza è stata impostata!\nIl riavvio dell\'app potrebbe essere richiesto.</string>
|
||||
<string name="qr_code_too_large_use_text_below">Codice QR troppo grande. Usa il testo qui sotto per condividere il tuo profilo.</string>
|
||||
<string name="invalid_license_format">Formato licenza non valido</string>
|
||||
<string name="unknown_content_format">Formato contenuto sconosciuto</string>
|
||||
<string name="unknown_file_format">Formato file sconosciuto</string>
|
||||
@@ -914,17 +915,6 @@
|
||||
<item>Suggerimenti</item>
|
||||
<item>Sottoscrizioni</item>
|
||||
</string-array>
|
||||
<string-array name="playback_speeds" translatable="false">
|
||||
<item>0.25</item>
|
||||
<item>0.5</item>
|
||||
<item>0.75</item>
|
||||
<item>1.0</item>
|
||||
<item>1.25</item>
|
||||
<item>1.5</item>
|
||||
<item>1.75</item>
|
||||
<item>2.0</item>
|
||||
<item>2.25</item>
|
||||
</string-array>
|
||||
<string-array name="preferred_quality_array">
|
||||
<item>Automatico (720p)</item>
|
||||
<item>2160p</item>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user