Compare commits

..

4 Commits

229 changed files with 1861 additions and 3483 deletions
+2 -16
View File
@@ -49,23 +49,9 @@ We encourage developers to write their own plugins. Please refer to the "Getting
## Contributing to Core
**We are currently not accepting contributions to the core.**
### License
The core is currently licensed under the [Source First License 1.1](./LICENSE.md). All contributors have to sign FUTO Individual Contributor License Agreement before contributions can be accepted. You can read more about it at [https://cla.futo.org/](https://cla.futo.org/).
### How to Contribute
1. Fork the core repository.
2. Clone your fork.
3. Make your changes.
4. Commit and push your changes.
5. Open a pull request.
### Guidelines
- Ensure your code adheres to the existing style.
- Include documentation and unit tests (where applicable).
The core is currently licensed under the FUTO Temporary License (FTL). The licensing and ownership of contributions to the core are complex topics that we are still working on. We'll update these guidelines when we have more clarity.
---
+16 -19
View File
@@ -9,8 +9,8 @@ technologies that frustrate centralization and industry consolidation.
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/video.png" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/video-details.png" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/video.jpg" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/video-details.jpg" height="700" /></b></td>
</tr>
<tr>
<td>Video</td>
@@ -24,10 +24,12 @@ The FUTO media app is a player that exposes multiple video websites as sources i
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/source.png" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/sources.jpg" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/sources-disabled.jpg" height="700" /></b></td>
</tr>
<tr>
<td>Sources</td>
<td>Sources (all enabled)</td>
<td>Sources (one disabled)</td>
</tr>
</table>
@@ -36,7 +38,7 @@ Additional sources can also be installed. These sources are JavaScript sources,
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/source-install.png" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/source-settings.png" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/source-settings.jpg" height="700" /></b></td>
</tr>
<tr>
<td>Install a new source</td>
@@ -52,8 +54,8 @@ When a user enters a search term into the search bar, the query is posted to th
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/search-list.png" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/search-preview.png" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/search-list.jpg" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/search-preview.jpg" height="700" /></b></td>
</tr>
<tr>
<td>Search (list)</td>
@@ -69,7 +71,7 @@ Creators are able to configure their profile using NeoPass.
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/channel.png" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/channel.jpg" height="700" /></b></td>
</tr>
<tr>
<td>Channel</td>
@@ -110,7 +112,7 @@ The app offers a lot of settings customizing how the app looks and feels. An exa
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/settings.png" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/settings.jpg" height="700" /></b></td>
</tr>
<tr>
<td>Settings</td>
@@ -123,8 +125,8 @@ Playlists allow you to make a collection of videos that you can create and custo
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/playlists.png" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/playlist.png" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/playlists.jpg" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/playlist.jpg" height="700" /></b></td>
</tr>
<tr>
<td>Playlists</td>
@@ -140,7 +142,7 @@ Both individual videos and playlists can be downloaded for local, offline playba
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/downloads.png" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/downloads.jpg" height="700" /></b></td>
</tr>
<tr>
<td>Downloads</td>
@@ -155,7 +157,7 @@ For more information about casting please click [here](./docs/casting.md).
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/casting.png" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/casting.jpg" height="700" /></b></td>
</tr>
<tr>
<td>Casting</td>
@@ -180,12 +182,6 @@ In the future we hope to offer users the choice of their desired recommendation
1. Download a copy of the repository.
2. Open the project in Android Studio: Once the repository is cloned, you can open it in Android Studio by selecting "Open an Existing Project" from the welcome screen and navigating to the directory where you cloned the repository.
3. Open the terminal in Android Studio by clicking on the terminal icon on bottom left and run the following command:
```sh
git submodule update --init --recursive
```
3. Build the project: With the project open in Android Studio, you can build it by selecting "Build > Make Project" from the main menu. This will compile the code and generate an APK file that you can install on your device or emulator.
4. Run the project: To run the project, select "Run > Run 'app'" from the main menu. This will launch the app on your device or emulator, allowing you to test it and make any necessary changes.
@@ -203,6 +199,7 @@ Create a tag on the master branch, incrementing the last version number by 1 (fo
Click on the CI/CD tab, you should now see the tests and build are in progress. If the build succeeds the last step will become available. The last step is a manual action which can be triggered by clicking the run button on the action. This action will deploy the build to all users using the app through auto-update.
## Documentation
The documentation can be found [here](https://gitlab.futo.org/videostreaming/documents/-/wikis/API-Overview).
+10 -7
View File
@@ -36,12 +36,6 @@
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
</provider>
<receiver android:name=".receivers.MediaButtonReceiver" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<service android:name=".services.MediaPlaybackService"
android:enabled="true"
android:foregroundServiceType="mediaPlayback" />
@@ -57,8 +51,9 @@
android:name=".activities.MainActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout"
android:exported="true"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar"
android:launchMode="singleInstance"
android:launchMode="singleTask"
android:resizeableActivity="true"
android:supportsPictureInPicture="true">
@@ -151,9 +146,11 @@
<data android:scheme="polycentric" />
</intent-filter>
</activity>
<activity
android:name=".activities.TestActivity"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.SettingsActivity"
android:screenOrientation="sensorPortrait"
@@ -176,6 +173,7 @@
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.AddSourceActivity"
android:screenOrientation="sensorPortrait"
android:exported="true"
android:theme="@style/Theme.FutoVideo.NoActionBar">
<intent-filter>
@@ -219,6 +217,7 @@
android:name=".activities.ManageTabsActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.QRCaptureActivity"
android:screenOrientation="sensorPortrait"
@@ -227,6 +226,10 @@
android:name=".activities.FCastGuideActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.PolycentricWhyActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.SyncHomeActivity"
android:screenOrientation="sensorPortrait"
+1 -39
View File
@@ -367,16 +367,6 @@ class VideoUrlSource {
this.requestModifier = obj.requestModifier;
}
}
class VideoUrlWidevineSource extends VideoUrlSource {
constructor(obj) {
super(obj);
this.plugin_type = "VideoUrlWidevineSource";
this.licenseUri = obj.licenseUri;
if(obj.getLicenseRequestExecutor)
this.getLicenseRequestExecutor = obj.getLicenseRequestExecutor;
}
}
class VideoUrlRangeSource extends VideoUrlSource {
constructor(obj) {
super(obj);
@@ -409,26 +399,8 @@ class AudioUrlWidevineSource extends AudioUrlSource {
super(obj);
this.plugin_type = "AudioUrlWidevineSource";
this.bearerToken = obj.bearerToken;
this.licenseUri = obj.licenseUri;
if(obj.getLicenseRequestExecutor)
this.getLicenseRequestExecutor = obj.getLicenseRequestExecutor;
// deprecated api conversion
if(obj.bearerToken) {
this.getLicenseRequestExecutor = () => {
return {
executeRequest: (url, _headers, _method, license_request_data) => {
return http.POST(
url,
license_request_data,
{ Authorization: `Bearer ${obj.bearerToken}` },
false,
true
).body
}
}
}
}
}
}
class AudioUrlRangeSource extends AudioUrlSource {
@@ -471,16 +443,6 @@ class DashSource {
this.requestModifier = obj.requestModifier;
}
}
class DashWidevineSource extends DashSource {
constructor(obj) {
super(obj);
this.plugin_type = "DashWidevineSource";
this.licenseUri = obj.licenseUri;
if(obj.getLicenseRequestExecutor)
this.getLicenseRequestExecutor = obj.getLicenseRequestExecutor;
}
}
class DashManifestRawSource {
constructor(obj) {
obj = obj ?? {};
@@ -0,0 +1,122 @@
import android.app.Activity
import android.content.Context
import android.content.pm.ActivityInfo
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class AdvancedOrientationListener(private val activity: Activity, private val lifecycleScope: CoroutineScope) {
private val sensorManager: SensorManager = activity.getSystemService(Context.SENSOR_SERVICE) as SensorManager
private val accelerometer: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
private val magnetometer: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
private var lastOrientation: Int = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
private var lastStableOrientation: Int = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
private var lastOrientationChangeTime = 0L
private val debounceTime = 200L
private val stabilityThresholdTime = 800L
private var deviceAspectRatio: Float = 1.0f
private val gravity = FloatArray(3)
private val geomagnetic = FloatArray(3)
private val rotationMatrix = FloatArray(9)
private val orientationAngles = FloatArray(3)
val onOrientationChanged = Event1<Int>()
private val sensorListener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
when (event.sensor.type) {
Sensor.TYPE_ACCELEROMETER -> {
System.arraycopy(event.values, 0, gravity, 0, gravity.size)
}
Sensor.TYPE_MAGNETIC_FIELD -> {
System.arraycopy(event.values, 0, geomagnetic, 0, geomagnetic.size)
}
}
if (gravity.isNotEmpty() && geomagnetic.isNotEmpty()) {
val success = SensorManager.getRotationMatrix(rotationMatrix, null, gravity, geomagnetic)
if (success) {
SensorManager.getOrientation(rotationMatrix, orientationAngles)
val azimuth = Math.toDegrees(orientationAngles[0].toDouble()).toFloat()
val pitch = Math.toDegrees(orientationAngles[1].toDouble()).toFloat()
val roll = Math.toDegrees(orientationAngles[2].toDouble()).toFloat()
val newOrientation = when {
roll in -155f .. -15f && isWithinThreshold(pitch, 0f, 30.0) -> {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
roll in 15f .. 155f && isWithinThreshold(pitch, 0f, 30.0) -> {
ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
}
isWithinThreshold(pitch, -90f, 30.0 * deviceAspectRatio) && roll in -15f .. 15f -> {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
isWithinThreshold(pitch, 90f, 30.0 * deviceAspectRatio) && roll in -15f .. 15f -> {
ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
}
else -> lastOrientation
}
//Logger.i("AdvancedOrientationListener", "newOrientation = ${newOrientation}, roll = ${roll}, pitch = ${pitch}, azimuth = ${azimuth}")
if (newOrientation != lastStableOrientation) {
val currentTime = System.currentTimeMillis()
if (currentTime - lastOrientationChangeTime > debounceTime) {
lastOrientationChangeTime = currentTime
lastStableOrientation = newOrientation
lifecycleScope.launch(Dispatchers.Main) {
try {
delay(stabilityThresholdTime)
if (newOrientation == lastStableOrientation) {
lastOrientation = newOrientation
onOrientationChanged.emit(newOrientation)
}
} catch (e: Throwable) {
Logger.i(TAG, "Failed to trigger onOrientationChanged", e)
}
}
}
}
}
}
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
}
private fun isWithinThreshold(value: Float, target: Float, threshold: Double): Boolean {
return Math.abs(value - target) <= threshold
}
init {
sensorManager.registerListener(sensorListener, accelerometer, SensorManager.SENSOR_DELAY_GAME)
sensorManager.registerListener(sensorListener, magnetometer, SensorManager.SENSOR_DELAY_GAME)
val metrics = activity.resources.displayMetrics
deviceAspectRatio = (metrics.heightPixels.toFloat() / metrics.widthPixels.toFloat())
if (deviceAspectRatio == 0.0f)
deviceAspectRatio = 1.0f
lastOrientation = activity.resources.configuration.orientation
}
fun stopListening() {
sensorManager.unregisterListener(sensorListener)
}
companion object {
private val TAG = "AdvancedOrientationListener"
}
}
@@ -2,8 +2,11 @@ package com.futo.platformplayer
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Context.POWER_SERVICE
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.webkit.CookieManager
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.MainActivity
@@ -24,6 +27,7 @@ import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePayment
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.states.StateUpdate
import com.futo.platformplayer.stores.FragmentedStorage
@@ -33,7 +37,9 @@ import com.futo.platformplayer.views.fields.DropdownFieldOptionsId
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.FormField
import com.futo.platformplayer.views.fields.FormFieldButton
import com.futo.platformplayer.views.fields.FormFieldWarning
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.stripe.android.customersheet.injection.CustomerSheetViewModelModule_Companion_ContextFactory.context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -144,6 +150,7 @@ class Settings : FragmentedStorageFileJson() {
fun import() {
val act = SettingsActivity.getActivity() ?: return;
val intent = MainActivity.getImportOptionsIntent(act);
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK;
act.startActivity(intent);
}
@@ -412,13 +419,17 @@ class Settings : FragmentedStorageFileJson() {
var preferredPreviewQuality: Int = 5;
fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality);
@FormField(R.string.simplify_sources, FieldForm.TOGGLE, R.string.simplify_sources_description, 4)
var simplifySources: Boolean = true;
@FormField(R.string.force_enable_auto_rotate_in_full_screen, FieldForm.TOGGLE, R.string.force_enable_auto_rotate_in_full_screen_description, 5)
var forceAllowFullScreenRotation: Boolean = false
@FormField(R.string.auto_rotate, FieldForm.DROPDOWN, -1, 5)
@DropdownFieldOptionsId(R.array.system_enabled_disabled_array)
var autoRotate: Int = 2;
@FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 6)
fun isAutoRotate() = (autoRotate == 1 && !StatePlayer.instance.rotationLock) || (autoRotate == 2 && StateApp.instance.getCurrentSystemAutoRotate() && !StatePlayer.instance.rotationLock);
@FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 7)
@DropdownFieldOptionsId(R.array.player_background_behavior)
var backgroundPlay: Int = 2;
@@ -473,6 +484,17 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.reverse_portrait, FieldForm.TOGGLE, R.string.reverse_portrait_description, 14)
var reversePortrait: Boolean = false;
@FormField(R.string.rotation_zone, FieldForm.DROPDOWN, R.string.rotation_zone_description, 15)
@DropdownFieldOptionsId(R.array.rotation_zone)
var rotationZone: Int = 2;
@FormField(R.string.stability_threshold_time, FieldForm.DROPDOWN, R.string.stability_threshold_time_description, 16)
@DropdownFieldOptionsId(R.array.rotation_threshold_time)
var stabilityThresholdTime: Int = 1;
@FormField(R.string.full_autorotate_lock, FieldForm.TOGGLE, R.string.full_autorotate_lock_description, 17)
var fullAutorotateLock: Boolean = false;
@FormField(R.string.prefer_webm, FieldForm.TOGGLE, R.string.prefer_webm_description, 18)
var preferWebmVideo: Boolean = false;
@FormField(R.string.prefer_webm_audio, FieldForm.TOGGLE, R.string.prefer_webm_audio_description, 19)
@@ -483,9 +505,6 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.autoplay, FieldForm.TOGGLE, R.string.autoplay, 21)
var autoplay: Boolean = false;
@FormField(R.string.delete_watchlist_on_finish, FieldForm.TOGGLE, R.string.delete_watchlist_on_finish_description, 22)
var deleteFromWatchLaterAuto: Boolean = true;
}
@FormField(R.string.comments, "group", R.string.comments_description, 6)
@@ -843,14 +862,10 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 2)
fun clearPayment() {
SettingsActivity.getActivity()?.let { context ->
UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete your license?", {
StatePayment.instance.clearLicenses();
SettingsActivity.getActivity()?.let {
UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart));
it.reloadSettings();
}
})
StatePayment.instance.clearLicenses();
SettingsActivity.getActivity()?.let {
UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart));
it.reloadSettings();
}
}
}
@@ -859,14 +874,12 @@ class Settings : FragmentedStorageFileJson() {
var other = Other();
@Serializable
class Other {
@FormField(R.string.playlist_delete_confirmation, FieldForm.TOGGLE, R.string.playlist_delete_confirmation_description, 2)
var playlistDeleteConfirmation: Boolean = true;
@FormField(R.string.bypass_rotation_prevention, FieldForm.TOGGLE, R.string.bypass_rotation_prevention_description, 1)
@FormFieldWarning(R.string.bypass_rotation_prevention_warning)
var bypassRotationPrevention: Boolean = false;
@FormField(R.string.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 3)
@FormField(R.string.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 1)
var polycentricEnabled: Boolean = true;
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 4)
var polycentricLocalCache: Boolean = true;
}
@FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19)
@@ -906,7 +919,7 @@ class Settings : FragmentedStorageFileJson() {
var enabled: Boolean = true;
@FormField(R.string.broadcast, FieldForm.TOGGLE, R.string.broadcast_description, 1)
var broadcast: Boolean = false;
var broadcast: Boolean = true;
@FormField(R.string.connect_discovered, FieldForm.TOGGLE, R.string.connect_discovered_description, 2)
var connectDiscovered: Boolean = true;
@@ -0,0 +1,86 @@
package com.futo.platformplayer
import android.app.Activity
import android.content.pm.ActivityInfo
import android.hardware.SensorManager
import android.view.OrientationEventListener
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class SimpleOrientationListener(
private val activity: Activity,
private val lifecycleScope: CoroutineScope
) {
private var lastOrientation: Int = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
private var lastStableOrientation: Int = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
private var _currentJob: Job? = null
val onOrientationChanged = Event1<Int>()
private val orientationListener = object : OrientationEventListener(activity, SensorManager.SENSOR_DELAY_UI) {
override fun onOrientationChanged(orientation: Int) {
//val rotationZone = 45
val stabilityThresholdTime = when (Settings.instance.playback.stabilityThresholdTime) {
0 -> 100L
1 -> 500L
2 -> 750L
3 -> 1000L
4 -> 1500L
5 -> 2000L
else -> 500L
}
val rotationZone = when (Settings.instance.playback.rotationZone) {
0 -> 15
1 -> 30
2 -> 45
else -> 45
}
val newOrientation = when {
orientation in (90 - rotationZone)..(90 + rotationZone - 1) -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
orientation in (180 - rotationZone)..(180 + rotationZone - 1) -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
orientation in (270 - rotationZone)..(270 + rotationZone - 1) -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
orientation in (360 - rotationZone)..(360 + rotationZone - 1) || orientation in 0..(rotationZone - 1) -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
else -> lastOrientation
}
if (newOrientation != lastStableOrientation) {
lastStableOrientation = newOrientation
_currentJob?.cancel()
_currentJob = lifecycleScope.launch(Dispatchers.Main) {
try {
delay(stabilityThresholdTime)
if (newOrientation == lastStableOrientation) {
lastOrientation = newOrientation
onOrientationChanged.emit(newOrientation)
}
} catch (e: Throwable) {
Logger.i(TAG, "Failed to trigger onOrientationChanged", e)
}
}
}
}
}
init {
orientationListener.enable()
lastOrientation = activity.resources.configuration.orientation
}
fun stopListening() {
_currentJob?.cancel()
_currentJob = null
orientationListener.disable()
}
companion object {
private val TAG = "SimpleOrientationListener"
}
}
@@ -6,7 +6,6 @@ import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.text.Layout
import android.text.method.ScrollingMovementMethod
import android.util.TypedValue
import android.view.Gravity
@@ -199,6 +198,7 @@ class UIDialogs {
dialog.show();
}
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action) {
val builder = AlertDialog.Builder(context);
val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null);
@@ -214,19 +214,18 @@ class UIDialogs {
this.text = text;
};
view.findViewById<TextView>(R.id.dialog_text_details).apply {
if (textDetails == null)
if(textDetails == null)
this.visibility = View.GONE;
else {
else
this.text = textDetails;
}
};
view.findViewById<TextView>(R.id.dialog_text_code).apply {
if (code == null) this.visibility = View.GONE;
if(code == null)
this.visibility = View.GONE;
else {
this.text = code;
this.movementMethod = ScrollingMovementMethod.getInstance();
this.visibility = View.VISIBLE;
this.textAlignment = View.TEXT_ALIGNMENT_VIEW_START
}
};
view.findViewById<LinearLayout>(R.id.dialog_buttons).apply {
@@ -349,13 +348,6 @@ class UIDialogs {
showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction)
}
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null) {
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
val doNotAskAgain = Action(context.getString(R.string.do_not_ask_again), doNotAskAgainAction ?: {}, ActionStyle.NONE)
showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction)
}
fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) {
val dialog = AutoUpdateDialog(context);
registerDialogOpened(dialog);
@@ -25,7 +25,6 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
import com.futo.platformplayer.downloads.VideoLocal
@@ -880,12 +879,6 @@ class UISlideOverlays {
val items = arrayListOf<View>();
val lastUpdated = StatePlaylists.instance.getLastUpdatedPlaylist();
val isLimited = video?.url != null && StatePlatform.instance.getContentClientOrNull(video!!.url)?.let {
if (it is JSClient)
return@let it.config.reduceFunctionsInLimitedVersion && BuildConfig.IS_PLAYSTORE_BUILD
else false;
} ?: false;
if (lastUpdated != null) {
items.add(
SlideUpMenuGroup(container.context, container.context.getString(R.string.recently_used_playlist), "recentlyusedplaylist",
@@ -906,18 +899,17 @@ class UISlideOverlays {
val watchLater = StatePlaylists.instance.getWatchLater();
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
(listOf(
if(!isLimited)
SlideUpMenuItem(
container.context,
R.drawable.ic_download,
container.context.getString(R.string.download),
container.context.getString(R.string.download_the_video),
tag = "download",
call = {
showDownloadVideoOverlay(video, container, true);
},
invokeParent = false
) else null,
SlideUpMenuItem(
container.context,
R.drawable.ic_download,
container.context.getString(R.string.download),
container.context.getString(R.string.download_the_video),
tag = "download",
call = {
showDownloadVideoOverlay(video, container, true);
},
invokeParent = false
),
SlideUpMenuItem(
container.context,
R.drawable.ic_share,
@@ -944,7 +936,7 @@ class UISlideOverlays {
StateMeta.instance.addHiddenCreator(video.author.url);
UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home");
}))
+ actions).filterNotNull()
+ actions)
));
items.add(
SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto",
@@ -959,7 +951,7 @@ class UISlideOverlays {
"${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "",
"${watchLater.size} " + container.context.getString(R.string.videos),
tag = "watch later",
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true); }),
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }),
SlideUpMenuItem(container.context,
R.drawable.ic_history,
container.context.getString(R.string.add_to_history),
@@ -1040,8 +1032,16 @@ class UISlideOverlays {
StatePlayer.TYPE_WATCHLATER,
"${watchLater.size} " + container.context.getString(R.string.videos),
tag = "watch later",
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true); }),
)
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }),
SlideUpMenuItem(
container.context,
R.drawable.ic_download,
container.context.getString(R.string.download),
container.context.getString(R.string.download_the_video),
tag = container.context.getString(R.string.download),
call = { showDownloadVideoOverlay(video, container, true); },
invokeParent = false
))
);
val playlistItems = arrayListOf<SlideUpMenuItem>();
@@ -32,7 +32,6 @@ import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.*
import java.util.concurrent.ThreadLocalRandom
@@ -233,49 +232,4 @@ fun String.decodeUnicode(): String {
i++
}
return sb.toString()
}
fun <T> smartMerge(targetArr: List<T>, toMerge: List<T>) : List<T>{
val missingToMerge = toMerge.filter { !targetArr.contains(it) }.toList();
val newArrResult = targetArr.toMutableList();
for(missing in missingToMerge) {
val newIndex = findNewIndex(toMerge, newArrResult, missing);
newArrResult.add(newIndex, missing);
}
return newArrResult;
}
fun <T> findNewIndex(originalArr: List<T>, newArr: List<T>, item: T): Int{
var originalIndex = originalArr.indexOf(item);
var newIndex = -1;
for(i in originalIndex-1 downTo 0) {
val previousItem = originalArr[i];
val indexInNewArr = newArr.indexOfFirst { it == previousItem };
if(indexInNewArr >= 0) {
newIndex = indexInNewArr + 1;
break;
}
}
if(newIndex < 0) {
for(i in originalIndex+1 until originalArr.size) {
val previousItem = originalArr[i];
val indexInNewArr = newArr.indexOfFirst { it == previousItem };
if(indexInNewArr >= 0) {
newIndex = indexInNewArr - 1;
break;
}
}
}
if(newIndex < 0)
return originalArr.size;
else
return newIndex;
}
fun ByteBuffer.toUtf8String(): String {
val remainingBytes = ByteArray(remaining())
get(remainingBytes)
return String(remainingBytes, Charsets.UTF_8)
}
}
@@ -1,14 +1,13 @@
package com.futo.platformplayer.activities
import android.annotation.SuppressLint
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.media.AudioManager
import android.net.Uri
import android.net.wifi.WifiManager
import android.os.Bundle
import android.os.StrictMode
import android.os.StrictMode.VmPolicy
@@ -30,12 +29,10 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentContainerView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
@@ -74,7 +71,6 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.models.UrlVideoWithTime
import com.futo.platformplayer.receivers.MediaButtonReceiver
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
@@ -85,7 +81,6 @@ import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.stores.SubscriptionStorage
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.views.ToastView
@@ -93,14 +88,11 @@ import com.futo.polycentric.core.ApiMethods
import com.google.gson.JsonParser
import com.google.zxing.integration.android.IntentIntegrator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.io.PrintWriter
@@ -118,7 +110,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
private val HEIGHT_VIDEO_MINIMIZED_DP = 60f;
//Containers
lateinit var rootView: MotionLayout;
lateinit var rootView : MotionLayout;
private lateinit var _overlayContainer: FrameLayout;
private lateinit var _toastView: ToastView;
@@ -175,11 +167,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragVideoDetail: VideoDetailFragment;
//State
private val _queue: Queue<Pair<MainFragment, Any?>> = LinkedList();
lateinit var fragCurrent: MainFragment private set;
private val _queue : Queue<Pair<MainFragment, Any?>> = LinkedList();
lateinit var fragCurrent : MainFragment private set;
private var _parameterCurrent: Any? = null;
var fragBeforeOverlay: MainFragment? = null; private set;
var fragBeforeOverlay : MainFragment? = null; private set;
val onNavigated = Event1<MainFragment>();
@@ -215,7 +207,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
.build()
)
}
ApiMethods.UserAgent = "Grayjay Android (${BuildConfig.VERSION_CODE})";
Thread.setDefaultUncaughtExceptionHandler { _, throwable ->
@@ -225,15 +217,15 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
Logger.e("Application", "Uncaught", excp);
//Resolve invocation chains
while (excp is InvocationTargetException || excp is java.lang.RuntimeException) {
while(excp is InvocationTargetException || excp is java.lang.RuntimeException) {
val before = excp;
if (excp is InvocationTargetException)
if(excp is InvocationTargetException)
excp = excp.targetException ?: excp.cause ?: excp;
else if (excp is java.lang.RuntimeException)
else if(excp is java.lang.RuntimeException)
excp = excp.cause ?: excp;
if (excp == before)
if(excp == before)
break;
}
writer.write((excp.message ?: "Empty error") + "\n\n");
@@ -255,9 +247,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
@UnstableApi
override fun onCreate(savedInstanceState: Bundle?) {
Logger.i(TAG, "MainActivity Starting");
ApiMethods.initCache(cacheDir);
StateApp.instance.setGlobalContext(this, lifecycleScope);
StateApp.instance.mainAppStarting(this);
@@ -265,8 +258,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
setContentView(R.layout.activity_main);
setNavigationBarColorAndIcons();
if (Settings.instance.playback.allowVideoToGoUnderCutout)
window.attributes.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
runBlocking {
StatePlatform.instance.updateAvailableClients(this@MainActivity);
@@ -340,12 +332,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
updateSegmentPaddings();
};
_fragVideoDetail.onTransitioning.subscribe {
if (it || _fragVideoDetail.state != VideoDetailFragment.State.MINIMIZED)
_fragContainerOverlay.elevation =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15f, resources.displayMetrics);
if(it || _fragVideoDetail.state != VideoDetailFragment.State.MINIMIZED)
_fragContainerOverlay.elevation = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15f, resources.displayMetrics);
else
_fragContainerOverlay.elevation =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics);
_fragContainerOverlay.elevation = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics);
}
_fragVideoDetail.onCloseEvent.subscribe {
@@ -362,39 +352,40 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_buttonIncognito.alpha = 0f;
StateApp.instance.privateModeChanged.subscribe {
//Messing with visibility causes some issues with layout ordering?
if (it) {
if(it) {
_buttonIncognito.elevation = 99f;
_buttonIncognito.alpha = 1f;
} else {
}
else {
_buttonIncognito.elevation = -99f;
_buttonIncognito.alpha = 0f;
}
}
_buttonIncognito.setOnClickListener {
if (!StateApp.instance.privateMode)
if(!StateApp.instance.privateMode)
return@setOnClickListener;
UIDialogs.showDialog(
this, R.drawable.ic_disabled_visible_purple, "Disable Privacy Mode",
UIDialogs.showDialog(this, R.drawable.ic_disabled_visible_purple, "Disable Privacy Mode",
"Do you want to disable privacy mode? New videos will be tracked again.", null, 0,
UIDialogs.Action("Cancel", {
StateApp.instance.setPrivacyMode(true);
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Disable", {
StateApp.instance.setPrivacyMode(false);
}, UIDialogs.ActionStyle.DANGEROUS)
);
}, UIDialogs.ActionStyle.DANGEROUS));
};
_fragVideoDetail.onFullscreenChanged.subscribe {
Logger.i(TAG, "onFullscreenChanged ${it}");
if (it) {
if(it) {
_buttonIncognito.elevation = -99f;
_buttonIncognito.alpha = 0f;
} else {
if (StateApp.instance.privateMode) {
}
else {
if(StateApp.instance.privateMode) {
_buttonIncognito.elevation = 99f;
_buttonIncognito.alpha = 1f;
} else {
}
else {
_buttonIncognito.elevation = -99f;
_buttonIncognito.alpha = 0f;
}
@@ -407,7 +398,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
return@subscribe;
}
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
if(_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
if (fragCurrent !is VideoDetailFragment) {
val toPlay = StatePlayer.instance.getCurrentQueueItem();
navigate(_fragVideoDetail, toPlay);
@@ -455,12 +446,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragSubGroupList.topBar = _fragTopBarAdd;
_fragBrowser.topBar = _fragTopBarNavigation;
fragCurrent = _fragMainHome;
val defaultTab = Settings.instance.tabs.mapNotNull {
val buttonDefinition =
MenuBottomBarFragment.buttonDefinitions.firstOrNull { bd -> it.id == bd.id };
val buttonDefinition = MenuBottomBarFragment.buttonDefinitions.firstOrNull { bd -> it.id == bd.id };
if (buttonDefinition == null) {
return@mapNotNull null;
} else {
@@ -519,11 +509,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
//startActivity(Intent(this, TestActivity::class.java));
// updates the requestedOrientation based on user settings
_fragVideoDetail.updateOrientation()
val sharedPreferences =
getSharedPreferences("GrayjayFirstBoot", Context.MODE_PRIVATE)
val sharedPreferences = getSharedPreferences("GrayjayFirstBoot", Context.MODE_PRIVATE)
val isFirstBoot = sharedPreferences.getBoolean("IsFirstBoot", true)
if (isFirstBoot) {
UIDialogs.showConfirmationDialog(this, getString(R.string.do_you_want_to_see_the_tutorials_you_can_find_them_at_any_time_through_the_more_button), {
@@ -532,64 +518,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
}
val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus")
val numSubscriptions = StateSubscriptions.instance.getSubscriptionCount()
val subscriptionsThreshold = 20
if (
submissionStatus.value == ""
&& StateApp.instance.getCurrentNetworkState() != StateApp.NetworkState.DISCONNECTED
&& numSubscriptions >= subscriptionsThreshold
) {
UIDialogs.showDialog(
this,
R.drawable.ic_internet,
getString(R.string.contribute_personal_subscriptions_list),
getString(R.string.contribute_personal_subscriptions_list_description),
null,
0,
UIDialogs.Action("Cancel", {
submissionStatus.setAndSave("dismissed")
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Upload", {
submissionStatus.setAndSave("submitted")
GlobalScope.launch(Dispatchers.IO) {
@Serializable
data class CreatorInfo(val pluginId: String, val url: String)
val subscriptions =
StateSubscriptions.instance.getSubscriptions().map { original ->
CreatorInfo(
pluginId = original.channel.id.pluginId ?: "",
url = original.channel.url
)
}
val json = Json.encodeToString(subscriptions)
val url = "https://data.grayjay.app/donate-subscription-list"
val client = ManagedHttpClient();
val headers = hashMapOf(
"Content-Type" to "application/json"
)
try {
val response = client.post(url, json, headers)
// if it failed retry one time
if (!response.isOk) {
client.post(url, json, headers)
}
} catch (e: Exception) {
Logger.i(TAG, "Failed to submit subscription list.", e)
}
}
}, UIDialogs.ActionStyle.PRIMARY)
)
}
}
/*
@@ -654,45 +582,39 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
private fun handleIntent(intent: Intent?) {
if (intent == null)
if(intent == null)
return;
Logger.i(TAG, "handleIntent started by " + intent.action);
var targetData: String? = null;
when (intent.action) {
when(intent.action) {
Intent.ACTION_SEND -> {
targetData = intent.getStringExtra(Intent.EXTRA_STREAM)
?: intent.getStringExtra(Intent.EXTRA_TEXT);
targetData = intent.getStringExtra(Intent.EXTRA_STREAM) ?: intent.getStringExtra(Intent.EXTRA_TEXT);
Logger.i(TAG, "Share Received: " + targetData);
}
Intent.ACTION_VIEW -> {
targetData = intent.dataString
if (!targetData.isNullOrEmpty()) {
if(!targetData.isNullOrEmpty()) {
Logger.i(TAG, "View Received: " + targetData);
}
}
"VIDEO" -> {
val url = intent.getStringExtra("VIDEO");
navigate(_fragVideoDetail, url);
}
"IMPORT_OPTIONS" -> {
UIDialogs.showImportOptionsDialog(this);
}
"ACTION" -> {
val action = intent.getStringExtra("ACTION");
StateDeveloper.instance.testState = "TestPlayback";
StateDeveloper.instance.testPlayback();
}
"TAB" -> {
when (intent.getStringExtra("TAB")) {
when(intent.getStringExtra("TAB")){
"Sources" -> {
runBlocking {
StatePlatform.instance.updateAvailableClients(this@MainActivity, true) //Ideally this is not needed..
@@ -703,7 +625,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
navigate(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf(
Pair("grayjay") { req ->
StateApp.instance.contextOrNull?.let {
if (it is MainActivity) {
if(it is MainActivity) {
runBlocking {
it.handleUrlAll(req.url.toString());
}
@@ -722,7 +644,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
handleUrlAll(targetData)
}
}
} catch (ex: Throwable) {
}
catch(ex: Throwable) {
UIDialogs.showGeneralErrorDialog(this, getString(R.string.failed_to_handle_file), ex);
}
}
@@ -731,31 +654,35 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
val uri = Uri.parse(url)
when (uri.scheme) {
"grayjay" -> {
if (url.startsWith("grayjay://license/")) {
if (StatePayment.instance.setPaymentLicenseUrl(url)) {
if(url.startsWith("grayjay://license/")) {
if(StatePayment.instance.setPaymentLicenseUrl(url))
{
UIDialogs.showDialogOk(this, R.drawable.ic_check, getString(R.string.your_license_key_has_been_set_an_app_restart_might_be_required));
if (fragCurrent is BuyFragment)
if(fragCurrent is BuyFragment)
closeSegment(fragCurrent);
} else
}
else
UIDialogs.toast(getString(R.string.invalid_license_format));
} else if (url.startsWith("grayjay://plugin/")) {
}
else if(url.startsWith("grayjay://plugin/")) {
val intent = Intent(this, AddSourceActivity::class.java).apply {
data = Uri.parse(url.substring("grayjay://plugin/".length));
};
startActivity(intent);
} else if (url.startsWith("grayjay://video/")) {
}
else if(url.startsWith("grayjay://video/")) {
val videoUrl = url.substring("grayjay://video/".length);
navigate(_fragVideoDetail, videoUrl);
} else if (url.startsWith("grayjay://channel/")) {
}
else if(url.startsWith("grayjay://channel/")) {
val channelUrl = url.substring("grayjay://channel/".length);
navigate(_fragMainChannel, channelUrl);
}
}
"content" -> {
if (!handleContent(url, intent.type)) {
if(!handleContent(url, intent.type)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
@@ -764,9 +691,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
{ });
}
}
"file" -> {
if (!handleFile(url)) {
if(!handleFile(url)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
@@ -775,9 +701,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
{ });
}
}
"polycentric" -> {
if (!handlePolycentric(url)) {
if(!handlePolycentric(url)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
@@ -786,9 +711,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
{ });
}
}
"fcast" -> {
if (!handleFCast(url)) {
if(!handleFCast(url)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_cast,
@@ -797,7 +721,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
{ });
}
}
else -> {
if (!handleUrl(url)) {
UIDialogs.showSingleButtonDialog(
@@ -819,7 +742,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if (StatePlatform.instance.hasEnabledVideoClient(url)) {
Logger.i(TAG, "handleUrl(url=$url) found video client");
lifecycleScope.launch(Dispatchers.Main) {
if (position > 0)
if(position > 0)
navigate(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true));
else
navigate(_fragVideoDetail, url);
@@ -838,7 +761,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} else if (StatePlatform.instance.hasEnabledPlaylistClient(url)) {
Logger.i(TAG, "handleUrl(url=$url) found playlist client");
lifecycleScope.launch(Dispatchers.Main) {
navigate(_fragMainRemotePlaylist, url);
navigate(_fragMainPlaylist, url);
delay(100);
_fragVideoDetail.minimizeVideoDetail();
};
@@ -847,25 +770,24 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
return@withContext false;
}
}
fun handleContent(file: String, mime: String? = null): Boolean {
Logger.i(TAG, "handleContent(url=$file)");
val data = readSharedContent(file);
if (file.lowercase().endsWith(".json") || mime == "application/json") {
if(file.lowercase().endsWith(".json") || mime == "application/json") {
var recon = String(data);
if (!recon.trim().startsWith("["))
if(!recon.trim().startsWith("["))
return handleUnknownJson(recon);
var reconLines = Json.decodeFromString<List<String>>(recon);
val cacheStr =
reconLines.find { it.startsWith("__CACHE:") }?.substring("__CACHE:".length);
val cacheStr = reconLines.find { it.startsWith("__CACHE:") }?.substring("__CACHE:".length);
reconLines = reconLines.filter { !it.startsWith("__CACHE:") }; //TODO: constant prefix
var cache: ImportCache? = null;
try {
if (cacheStr != null)
if(cacheStr != null)
cache = Json.decodeFromString(cacheStr);
} catch (ex: Throwable) {
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to deserialize cache");
}
@@ -874,31 +796,32 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
Logger.i(TAG, "Opened shared playlist reconstruction\n${recon}");
handleReconstruction(recon, cache);
return true;
} else if (file.lowercase().endsWith(".zip") || mime == "application/zip") {
}
else if(file.lowercase().endsWith(".zip") || mime == "application/zip") {
StateBackup.importZipBytes(this, lifecycleScope, data);
return true;
} else if (file.lowercase().endsWith(".txt") || mime == "text/plain") {
}
else if(file.lowercase().endsWith(".txt") || mime == "text/plain") {
return handleUnknownText(String(data));
}
return false;
}
fun handleFile(file: String): Boolean {
Logger.i(TAG, "handleFile(url=$file)");
if (file.lowercase().endsWith(".json")) {
if(file.lowercase().endsWith(".json")) {
var recon = String(readSharedFile(file));
if (!recon.startsWith("["))
if(!recon.startsWith("["))
return handleUnknownJson(recon);
var reconLines = Json.decodeFromString<List<String>>(recon);
val cacheStr =
reconLines.find { it.startsWith("__CACHE:") }?.substring("__CACHE:".length);
val cacheStr = reconLines.find { it.startsWith("__CACHE:") }?.substring("__CACHE:".length);
reconLines = reconLines.filter { !it.startsWith("__CACHE:") }; //TODO: constant prefix
var cache: ImportCache? = null;
try {
if (cacheStr != null)
if(cacheStr != null)
cache = Json.decodeFromString(cacheStr);
} catch (ex: Throwable) {
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to deserialize cache");
}
recon = reconLines.joinToString("\n");
@@ -906,18 +829,19 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
Logger.i(TAG, "Opened shared playlist reconstruction\n${recon}");
handleReconstruction(recon, cache);
return true;
} else if (file.lowercase().endsWith(".zip")) {
}
else if(file.lowercase().endsWith(".zip")) {
StateBackup.importZipBytes(this, lifecycleScope, readSharedFile(file));
return true;
} else if (file.lowercase().endsWith(".txt")) {
}
else if(file.lowercase().endsWith(".txt")) {
return handleUnknownText(String(readSharedFile(file)));
}
return false;
}
fun handleReconstruction(recon: String, cache: ImportCache? = null) {
val type = ManagedStore.getReconstructionIdentifier(recon);
val store: ManagedStore<*> = when (type) {
val store: ManagedStore<*> = when(type) {
"Playlist" -> StatePlaylists.instance.playlistStore
else -> {
UIDialogs.toast(getString(R.string.unknown_reconstruction_type) + " ${type}", false);
@@ -925,15 +849,13 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
};
};
val name = when (type) {
"Playlist" -> recon.split("\n")
.filter { !it.startsWith(ManagedStore.RECONSTRUCTION_HEADER_OPERATOR) }
.firstOrNull() ?: type;
val name = when(type) {
"Playlist" -> recon.split("\n").filter { !it.startsWith(ManagedStore.RECONSTRUCTION_HEADER_OPERATOR) }.firstOrNull() ?: type;
else -> type
}
if (!type.isNullOrEmpty()) {
if(!type.isNullOrEmpty()) {
UIDialogs.showImportDialog(this, store, name, listOf(recon), cache) {
}
@@ -942,18 +864,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
fun handleUnknownText(text: String): Boolean {
try {
if (text.startsWith("@/Subscription") || text.startsWith("Subscriptions")) {
if(text.startsWith("@/Subscription") || text.startsWith("Subscriptions")) {
val lines = text.split("\n").map { it.trim() }.drop(1).filter { it.isNotEmpty() };
navigate(_fragImportSubscriptions, lines);
return true;
}
} catch (ex: Throwable) {
}
catch(ex: Throwable) {
Logger.e(TAG, ex.message, ex);
UIDialogs.showGeneralErrorDialog(this, getString(R.string.failed_to_parse_text_file), ex);
}
return false;
}
fun handleUnknownJson(json: String): Boolean {
val context = this;
@@ -965,7 +887,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
return false;//throw IllegalArgumentException("Invalid NewPipe json structure found");
StateBackup.importNewPipeSubs(this, newPipeSubsParsed);
} catch (ex: Exception) {
}
catch(ex: Exception) {
Logger.e(TAG, ex.message, ex);
UIDialogs.showGeneralErrorDialog(context, getString(R.string.failed_to_parse_newpipe_subscriptions), ex);
}
@@ -1011,7 +934,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
private fun readSharedFile(filePath: String): ByteArray {
val dataFile = File(filePath);
if (!dataFile.exists())
if(!dataFile.exists())
throw IllegalArgumentException("Opened file does not exist or not permitted");
val data = dataFile.readBytes();
return data;
@@ -1020,13 +943,13 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
override fun onBackPressed() {
Logger.i(TAG, "onBackPressed")
if (_fragBotBarMenu.onBackPressed())
if(_fragBotBarMenu.onBackPressed())
return;
if (_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.onBackPressed())
if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.onBackPressed())
return;
if (!fragCurrent.onBackPressed())
if(!fragCurrent.onBackPressed())
closeSegment();
}
@@ -1034,7 +957,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
super.onUserLeaveHint();
Logger.i(TAG, "onUserLeaveHint")
if (_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED || _fragVideoDetail.state == VideoDetailFragment.State.MINIMIZED)
if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED || _fragVideoDetail.state == VideoDetailFragment.State.MINIMIZED)
_fragVideoDetail.onUserLeaveHint();
}
@@ -1070,12 +993,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
fun navigate(segment: MainFragment, parameter: Any? = null, withHistory: Boolean = true, isBack: Boolean = false) {
Logger.i(TAG, "Navigate to $segment (parameter=$parameter, withHistory=$withHistory, isBack=$isBack)")
if (segment != fragCurrent) {
if (segment is VideoDetailFragment) {
if (_fragContainerVideoDetail.visibility != View.VISIBLE)
if(segment != fragCurrent) {
if(segment is VideoDetailFragment) {
if(_fragContainerVideoDetail.visibility != View.VISIBLE)
_fragContainerVideoDetail.visibility = View.VISIBLE;
when (segment.state) {
when(segment.state) {
VideoDetailFragment.State.MINIMIZED -> segment.maximizeVideoDetail()
VideoDetailFragment.State.CLOSED -> segment.maximizeVideoDetail()
else -> {}
@@ -1083,10 +1006,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
segment.onShown(parameter, isBack);
return;
}
fragCurrent.onHide();
if (segment.isMainView) {
if(segment.isMainView) {
var transaction = supportFragmentManager.beginTransaction();
if (segment.topBar != null) {
if (segment.topBar != fragCurrent.topBar) {
@@ -1095,7 +1019,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
.replace(R.id.fragment_top_bar, segment.topBar as Fragment);
fragCurrent.topBar?.onHide();
}
} else if (fragCurrent.topBar != null)
}
else if(fragCurrent.topBar != null)
transaction.hide(fragCurrent.topBar as Fragment);
transaction = transaction.replace(R.id.fragment_main, segment);
@@ -1103,24 +1028,25 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if (segment.hasBottomBar) {
if (!fragCurrent.hasBottomBar)
transaction = transaction.show(_fragBotBarMenu);
} else {
if (fragCurrent.hasBottomBar)
}
else {
if(fragCurrent.hasBottomBar)
transaction = transaction.hide(_fragBotBarMenu);
}
transaction.commitNow();
} else {
if (!segment.hasBottomBar) {
if(!segment.hasBottomBar) {
supportFragmentManager.beginTransaction()
.hide(_fragBotBarMenu)
.commitNow();
}
}
if (fragCurrent.isHistory && withHistory && _queue.lastOrNull() != fragCurrent)
if(fragCurrent.isHistory && withHistory && _queue.lastOrNull() != fragCurrent)
_queue.add(Pair(fragCurrent, _parameterCurrent));
if (segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory)
if(segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory)
fragBeforeOverlay = fragCurrent;
@@ -1138,12 +1064,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
* If called with a non-null fragment, it will only close if the current fragment is the provided one
*/
fun closeSegment(fragment: MainFragment? = null) {
if (fragment is VideoDetailFragment) {
if(fragment is VideoDetailFragment) {
fragment.onHide();
return;
}
if ((fragment?.isOverlay ?: false) && fragBeforeOverlay != null) {
if((fragment?.isOverlay ?: false) && fragBeforeOverlay != null) {
navigate(fragBeforeOverlay!!, null, false, true);
} else {
val last = _queue.lastOrNull();
@@ -1165,8 +1091,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
/**
* Provides the fragment instance for the provided fragment class
*/
inline fun <reified T : Fragment> getFragment(): T {
return when (T::class) {
inline fun <reified T : Fragment> getFragment() : T {
return when(T::class) {
HomeFragment::class -> _fragMainHome as T;
TutorialFragment::class -> _fragMainTutorial as T;
ContentSearchResultsFragment::class -> _fragMainVideoSearchResults as T;
@@ -1203,21 +1129,15 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
private fun updateSegmentPaddings() {
var paddingBottom = 0f;
if (fragCurrent.hasBottomBar)
if(fragCurrent.hasBottomBar)
paddingBottom += HEIGHT_MENU_DP;
_fragContainerOverlay.setPadding(
0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, paddingBottom - HEIGHT_MENU_DP, resources.displayMetrics)
.toInt()
);
_fragContainerOverlay.setPadding(0,0,0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, paddingBottom - HEIGHT_MENU_DP, resources.displayMetrics).toInt());
if (_fragVideoDetail.state == VideoDetailFragment.State.MINIMIZED)
if(_fragVideoDetail.state == VideoDetailFragment.State.MINIMIZED)
paddingBottom += HEIGHT_VIDEO_MINIMIZED_DP;
_fragContainerMain.setPadding(
0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, paddingBottom, resources.displayMetrics)
.toInt()
);
_fragContainerMain.setPadding(0,0,0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, paddingBottom, resources.displayMetrics).toInt());
}
@@ -1233,18 +1153,14 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
ContextCompat.checkSelfPermission(this, notifPermission) == PackageManager.PERMISSION_GRANTED -> {
}
ActivityCompat.shouldShowRequestPermissionRationale(this, notifPermission) -> {
UIDialogs.showDialog(
this, R.drawable.ic_notifications, "Notifications Required",
UIDialogs.showDialog(this, R.drawable.ic_notifications, "Notifications Required",
reason, null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Enable", {
requestPermissionLauncher.launch(notifPermission);
}, UIDialogs.ActionStyle.PRIMARY)
);
}, UIDialogs.ActionStyle.PRIMARY));
}
else -> {
requestPermissionLauncher.launch(notifPermission);
}
@@ -1256,16 +1172,15 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
fun showAppToast(toast: ToastView.Toast) {
synchronized(_toastQueue) {
_toastQueue.add(toast);
if (_toastJob?.isActive != true)
if(_toastJob?.isActive != true)
_toastJob = lifecycleScope.launch(Dispatchers.Default) {
launchAppToastJob();
};
}
}
private suspend fun launchAppToastJob() {
Logger.i(TAG, "Starting appToast loop");
while (!_toastQueue.isEmpty()) {
while(!_toastQueue.isEmpty()) {
val toast = _toastQueue.poll() ?: continue;
Logger.i(TAG, "Showing next toast (${toast.msg})");
@@ -1278,7 +1193,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_toastView.setToastAnimated(toast);
}
}
if (toast.long)
if(toast.long)
delay(5000);
else
delay(3000);
@@ -1292,19 +1207,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
//TODO: Only calls last handler due to missing request codes on ActivityResultLaunchers.
private var resultLauncherMap = mutableMapOf<Int, (ActivityResult) -> Unit>();
private var resultLauncherMap = mutableMapOf<Int, (ActivityResult)->Unit>();
private var requestCode: Int? = -1;
private val resultLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result: ActivityResult ->
ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult ->
val handler = synchronized(resultLauncherMap) {
resultLauncherMap.remove(requestCode);
}
if (handler != null)
if(handler != null)
handler(result);
};
override fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult) -> Unit) {
override fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult)->Unit) {
synchronized(resultLauncherMap) {
resultLauncherMap[code] = handler;
}
@@ -1315,34 +1229,32 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
companion object {
private val TAG = "MainActivity"
fun getTabIntent(context: Context, tab: String): Intent {
fun getTabIntent(context: Context, tab: String) : Intent {
val sourcesIntent = Intent(context, MainActivity::class.java);
sourcesIntent.action = "TAB";
sourcesIntent.putExtra("TAB", tab);
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
return sourcesIntent;
}
fun getVideoIntent(context: Context, videoUrl: String): Intent {
fun getVideoIntent(context: Context, videoUrl: String) : Intent {
val sourcesIntent = Intent(context, MainActivity::class.java);
sourcesIntent.action = "VIDEO";
sourcesIntent.putExtra("VIDEO", videoUrl);
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
return sourcesIntent;
}
fun getActionIntent(context: Context, action: String): Intent {
fun getActionIntent(context: Context, action: String) : Intent {
val sourcesIntent = Intent(context, MainActivity::class.java);
sourcesIntent.action = "ACTION";
sourcesIntent.putExtra("ACTION", action);
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
return sourcesIntent;
}
fun getImportOptionsIntent(context: Context): Intent {
val sourcesIntent = Intent(context, MainActivity::class.java);
sourcesIntent.action = "IMPORT_OPTIONS";
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
return sourcesIntent;
}
}
@@ -1,63 +0,0 @@
package com.futo.platformplayer.api.media.models.comments
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.models.ratings.RatingType
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.logging.Logger
import kotlinx.coroutines.Deferred
import java.time.OffsetDateTime
class LazyComment: IPlatformComment {
private var _commentDeferred: Deferred<IPlatformComment>;
private var _commentLoaded: IPlatformComment? = null;
private var _commentException: Throwable? = null;
override val contextUrl: String
get() = _commentLoaded?.contextUrl ?: "";
override val author: PlatformAuthorLink
get() = _commentLoaded?.author ?: PlatformAuthorLink.UNKNOWN;
override val message: String
get() = _commentLoaded?.message ?: "";
override val rating: IRating
get() = _commentLoaded?.rating ?: RatingLikes(0);
override val date: OffsetDateTime?
get() = _commentLoaded?.date ?: OffsetDateTime.MIN;
override val replyCount: Int?
get() = _commentLoaded?.replyCount ?: 0;
val isAvailable: Boolean get() = _commentLoaded != null;
private var _uiHandler: ((LazyComment)->Unit)? = null;
constructor(commentDeferred: Deferred<IPlatformComment>) {
_commentDeferred = commentDeferred;
_commentDeferred.invokeOnCompletion {
if(it == null) {
_commentLoaded = commentDeferred.getCompleted();
Logger.i("LazyComment", "Resolved comment");
}
else {
_commentException = it;
Logger.e("LazyComment", "Resolving comment failed: ${it.message}", it);
}
_uiHandler?.invoke(this);
}
}
fun getUnderlyingComment(): IPlatformComment? {
return _commentLoaded;
}
fun setUIHandler(handler: (LazyComment)->Unit){
_uiHandler = handler;
}
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment>? {
return _commentLoaded?.getReplies(client);
}
}
@@ -1,3 +1,6 @@
package com.futo.platformplayer.api.media.models.streams.sources
interface IAudioUrlWidevineSource : IAudioUrlSource, IWidevineSource
interface IAudioUrlWidevineSource : IAudioUrlSource {
val bearerToken: String
val licenseUri: String
}
@@ -1,5 +0,0 @@
package com.futo.platformplayer.api.media.models.streams.sources
interface IDashManifestWidevineSource : IWidevineSource {
val url: String
}
@@ -1,3 +0,0 @@
package com.futo.platformplayer.api.media.models.streams.sources
interface IVideoUrlWidevineSource : IVideoUrlSource, IWidevineSource
@@ -1,9 +0,0 @@
package com.futo.platformplayer.api.media.models.streams.sources
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
interface IWidevineSource {
val licenseUri: String
val hasLicenseRequestExecutor: Boolean
fun getLicenseRequestExecutor(): JSRequestExecutor?
}
@@ -50,8 +50,7 @@ class SourcePluginConfig(
var primaryClaimFieldType: Int? = null,
var developerSubmitUrl: String? = null,
var allowAllHttpHeaderAccess: Boolean = false,
var maxDownloadParallelism: Int = 0,
var reduceFunctionsInLimitedVersion: Boolean = false,
var maxDownloadParallelism: Int = 0
) : IV8PluginConfig {
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
@@ -71,8 +71,6 @@ abstract class JSPager<T> : IPager<T> {
warnIfMainThread("JSPager.getResults");
val items = pager.getOrThrow<V8ValueArray>(config, "results", "JSPager");
if(items.v8Runtime.isDead || items.v8Runtime.isClosed)
throw IllegalStateException("Runtime closed");
val newResults = items.toArray()
.map { convertResult(it as V8ValueObject) }
.toList();
@@ -10,7 +10,6 @@ import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.api.media.structures.ReusablePager
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.models.Playlist
import java.util.UUID
class JSPlaylistDetails: JSPlaylist, IPlatformPlaylistDetails {
override val contents: IPager<IPlatformVideo>;
@@ -38,6 +37,6 @@ class JSPlaylistDetails: JSPlaylist, IPlatformPlaylistDetails {
onProgress?.invoke(videos.size);
}
return Playlist(UUID.randomUUID().toString(), name, videos.map { SerializedPlatformVideo.fromVideo(it)});
return Playlist(id.toString(), name, videos.map { SerializedPlatformVideo.fromVideo(it)});
}
}
@@ -42,7 +42,7 @@ class JSRequestExecutor {
//TODO: Executor properties?
@Throws(ScriptException::class)
open fun executeRequest(method: String, url: String, body: ByteArray?, headers: Map<String, String>): ByteArray {
open fun executeRequest(url: String, headers: Map<String, String>): ByteArray {
if (_executor.isClosed)
throw IllegalStateException("Executor object is closed");
@@ -53,7 +53,7 @@ class JSRequestExecutor {
"[${_config.name}] JSRequestExecutor",
"builder.modifyRequest()"
) {
_executor.invoke("executeRequest", url, headers, method, body);
_executor.invoke("executeRequest", url, headers);
} as V8Value;
}
else V8Plugin.catchScriptErrors<Any>(
@@ -61,7 +61,7 @@ class JSRequestExecutor {
"[${_config.name}] JSRequestExecutor",
"builder.modifyRequest()"
) {
_executor.invoke("executeRequest", url, headers, method, body);
_executor.invoke("executeRequest", url, headers);
} as V8Value;
try {
@@ -3,39 +3,22 @@ package com.futo.platformplayer.api.media.platforms.js.models.sources
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlWidevineSource
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrThrow
class JSAudioUrlWidevineSource : JSAudioUrlSource, IAudioUrlWidevineSource {
override val bearerToken: String
override val licenseUri: String
override val hasLicenseRequestExecutor: Boolean
@Suppress("ConvertSecondaryConstructorToPrimary")
constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin, obj) {
val contextName = "JSAudioUrlWidevineSource"
val config = plugin.config
bearerToken = _obj.getOrThrow(config, "bearerToken", contextName)
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
}
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
if (!hasLicenseRequestExecutor || _obj.isClosed)
return null
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invoke("getLicenseRequestExecutor", arrayOf<Any>())
}
if (result !is V8ValueObject)
return null
return JSRequestExecutor(_plugin, result)
}
override fun toString(): String {
val url = getAudioUrl()
return "(name=$name, container=$container, bitrate=$bitrate, codec=$codec, url=$url, language=$language, duration=$duration, hasLicenseRequestExecutor=${hasLicenseRequestExecutor}, licenseUri=$licenseUri)"
return "(name=$name, container=$container, bitrate=$bitrate, codec=$codec, url=$url, language=$language, duration=$duration, bearerToken=$bearerToken, licenseUri=$licenseUri)"
}
}
@@ -1,60 +0,0 @@
package com.futo.platformplayer.api.media.platforms.js.models.sources
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestWidevineSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrNull
import com.futo.platformplayer.getOrThrow
class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
IDashManifestWidevineSource, JSSource {
override val width: Int = 0
override val height: Int = 0
override val container: String = "application/dash+xml"
override val codec: String = "Dash"
override val name: String
override val bitrate: Int? = null
override val url: String
override val duration: Long
override var priority: Boolean = false
override val licenseUri: String
override val hasLicenseRequestExecutor: Boolean
@Suppress("ConvertSecondaryConstructorToPrimary")
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
val contextName = "DashWidevineSource"
val config = plugin.config
name = _obj.getOrThrow(config, "name", contextName)
url = _obj.getOrThrow(config, "url", contextName)
duration = _obj.getOrThrow(config, "duration", contextName)
priority = obj.getOrNull(config, "priority", contextName) ?: false
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
}
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
if (!hasLicenseRequestExecutor || _obj.isClosed)
return null
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSDashManifestWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invoke("getLicenseRequestExecutor", arrayOf<Any>())
}
if (result !is V8ValueObject)
return null
return JSRequestExecutor(_plugin, result)
}
override fun getVideoUrl(): String {
return url
}
}
@@ -98,22 +98,18 @@ abstract class JSSource {
const val TYPE_AUDIO_WITH_METADATA = "AudioUrlRangeSource";
const val TYPE_VIDEO_WITH_METADATA = "VideoUrlRangeSource";
const val TYPE_DASH = "DashSource";
const val TYPE_DASH_WIDEVINE = "DashWidevineSource";
const val TYPE_DASH_RAW = "DashRawSource";
const val TYPE_DASH_RAW_AUDIO = "DashRawAudioSource";
const val TYPE_HLS = "HLSSource";
const val TYPE_AUDIOURL_WIDEVINE = "AudioUrlWidevineSource"
const val TYPE_VIDEOURL_WIDEVINE = "VideoUrlWidevineSource"
fun fromV8VideoNullable(plugin: JSClient, obj: V8Value?) : IVideoSource? = obj.orNull { fromV8Video(plugin, it as V8ValueObject) };
fun fromV8Video(plugin: JSClient, obj: V8ValueObject) : IVideoSource? {
val type = obj.getString("plugin_type");
return when(type) {
TYPE_VIDEOURL -> JSVideoUrlSource(plugin, obj);
TYPE_VIDEOURL_WIDEVINE -> JSVideoUrlWidevineSource(plugin, obj);
TYPE_VIDEO_WITH_METADATA -> JSVideoUrlRangeSource(plugin, obj);
TYPE_HLS -> fromV8HLS(plugin, obj);
TYPE_DASH_WIDEVINE -> JSDashManifestWidevineSource(plugin, obj)
TYPE_DASH -> fromV8Dash(plugin, obj);
TYPE_DASH_RAW -> fromV8DashRaw(plugin, obj);
else -> {
@@ -1,41 +0,0 @@
package com.futo.platformplayer.api.media.platforms.js.models.sources
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlWidevineSource
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrThrow
class JSVideoUrlWidevineSource : JSVideoUrlSource, IVideoUrlWidevineSource {
override val licenseUri: String
override val hasLicenseRequestExecutor: Boolean
@Suppress("ConvertSecondaryConstructorToPrimary")
constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin, obj) {
val contextName = "JSAudioUrlWidevineSource"
val config = plugin.config
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
}
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
if (!hasLicenseRequestExecutor || _obj.isClosed)
return null
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invoke("getLicenseRequestExecutor", arrayOf<Any>())
}
if (result !is V8ValueObject)
return null
return JSRequestExecutor(_plugin, result)
}
override fun toString(): String {
val url = getVideoUrl()
return "(width=$width, height=$height, container=$container, codec=$codec, name=$name, bitrate=$bitrate, duration=$duration, url=$url, hasLicenseRequestExecutor=$hasLicenseRequestExecutor, licenseUri=$licenseUri)"
}
}
@@ -88,8 +88,7 @@ class DashBuilder : XMLBuilder {
fun withRepresentationOnDemand(id: String, subtitleSource: ISubtitleSource, subtitleUrl: String) {
withRepresentation(id, mapOf(
Pair("mimeType", subtitleSource.format ?: "text/vtt"),
Pair("default", "true"),
Pair("lang", "en"),
Pair("startWithSAP", "1"),
Pair("bandwidth", "1000")
)) {
it.withBaseURL(subtitleUrl)
@@ -152,7 +151,7 @@ class DashBuilder : XMLBuilder {
)
) {
//TODO: Verify if & really should be replaced like this?
it.withRepresentationOnDemand("caption_en", subtitleSource, subtitleUrl.replace("&", "&amp;"))
it.withRepresentationOnDemand("1", subtitleSource, subtitleUrl.replace("&", "&amp;"))
}
}
//Video
@@ -165,7 +164,7 @@ class DashBuilder : XMLBuilder {
Pair("subsegmentStartsWithSAP", "1")
)
) {
it.withRepresentationOnDemand("2", vidSource, vidUrl.replace("&", "&amp;"));
it.withRepresentationOnDemand("1", vidSource, vidUrl.replace("&", "&amp;"));
}
}
@@ -1245,7 +1245,7 @@ class StateCasting {
val videoExecutor = _videoExecutor;
if (videoExecutor != null) {
val data = videoExecutor.executeRequest("GET", originalUrl, null, httpContext.headers)
val data = videoExecutor.executeRequest(originalUrl, httpContext.headers)
httpContext.respondBytes(200, HttpHeaders().apply {
put("Content-Type", mediaType)
}, data);
@@ -1263,7 +1263,7 @@ class StateCasting {
val audioExecutor = _audioExecutor;
if (audioExecutor != null) {
val data = audioExecutor.executeRequest("GET", originalUrl, null, httpContext.headers)
val data = audioExecutor.executeRequest(originalUrl, httpContext.headers)
httpContext.respondBytes(200, HttpHeaders().apply {
put("Content-Type", mediaType)
}, data);
@@ -22,7 +22,6 @@ class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
private lateinit var _buttonCancel: ImageButton;
private lateinit var _editPassword: EditText;
private lateinit var _editPassword2: EditText;
private lateinit var _inputMethodManager: InputMethodManager;
@@ -35,7 +34,6 @@ class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
_buttonStop = findViewById(R.id.button_stop);
_buttonStart = findViewById(R.id.button_start);
_editPassword = findViewById(R.id.edit_password);
_editPassword2 = findViewById(R.id.edit_password2);
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
@@ -54,13 +52,6 @@ class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
}
_buttonStart.setOnClickListener {
val p1 = _editPassword.text.toString();
val p2 = _editPassword2.text.toString();
if(!(p1?.equals(p2) ?: false)) {
UIDialogs.toast(context, "Password fields do not match, confirm that you typed it correctly.");
return@setOnClickListener;
}
val pbytes = _editPassword.text.toString().toByteArray();
if(pbytes.size < 4 || pbytes.size > 32) {
UIDialogs.toast(context, "Password needs to be atleast 4 bytes long and smaller than 32 bytes", false);
@@ -6,7 +6,6 @@ import android.graphics.Color
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
@@ -58,21 +57,11 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
_editComment = findViewById(R.id.edit_comment);
_textCharacterCount = findViewById(R.id.character_count);
_textCharacterCountMax = findViewById(R.id.character_count_max);
setCanceledOnTouchOutside(false)
setOnKeyListener { _, keyCode, event ->
if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) {
handleCloseAttempt()
true
} else {
false
}
}
_editComment.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) = Unit
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, c: Int) {
val count = s?.length ?: 0;
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
_textCharacterCount.text = count.toString();
if (count > PolycentricPlatformComment.MAX_COMMENT_SIZE) {
@@ -90,13 +79,10 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
_buttonCancel.setOnClickListener {
handleCloseAttempt()
clearFocus();
dismiss();
};
setOnCancelListener {
handleCloseAttempt()
}
_buttonCreate.setOnClickListener {
clearFocus();
@@ -148,22 +134,6 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
focus();
}
private fun handleCloseAttempt() {
if (_editComment.text.isEmpty()) {
clearFocus()
dismiss()
} else {
UIDialogs.showConfirmationDialog(
context,
context.resources.getString(R.string.not_empty_close),
action = {
clearFocus()
dismiss()
}
)
}
}
private fun focus() {
_editComment.requestFocus();
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE);
@@ -100,7 +100,6 @@ class VideoDownload {
var requireVideoSource: Boolean = false;
var requireAudioSource: Boolean = false;
var requiredCheck: Boolean = false;
@Contextual
@Transient
@@ -165,7 +164,7 @@ class VideoDownload {
onStateChanged.emit(newState);
}
constructor(video: IPlatformVideo, targetPixelCount: Long? = null, targetBitrate: Long? = null, optionalSources: Boolean = false) {
constructor(video: IPlatformVideo, targetPixelCount: Long? = null, targetBitrate: Long? = null) {
this.video = SerializedPlatformVideo.fromVideo(video);
this.videoSource = null;
this.audioSource = null;
@@ -176,9 +175,8 @@ class VideoDownload {
this.requiresLiveVideoSource = false;
this.requiresLiveAudioSource = false;
this.targetVideoName = videoSource?.name;
this.requireVideoSource = targetPixelCount != null;
this.requireVideoSource = targetPixelCount != null
this.requireAudioSource = targetBitrate != null; //TODO: May not be a valid check.. can only be determined after live fetch?
this.requiredCheck = optionalSources;
}
constructor(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?) {
this.video = SerializedPlatformVideo.fromVideo(video);
@@ -252,30 +250,6 @@ class VideoDownload {
if(original !is IPlatformVideoDetails)
throw IllegalStateException("Original content is not media?");
if(requiredCheck) {
if(original.video is VideoUnMuxedSourceDescriptor) {
if(requireVideoSource) {
if((original.video as VideoUnMuxedSourceDescriptor).audioSources.any() && !original.video.videoSources.any()) {
requireVideoSource = false;
targetPixelCount = null;
}
}
if(requireAudioSource) {
if(!(original.video as VideoUnMuxedSourceDescriptor).audioSources.any() && original.video.videoSources.any()) {
requireAudioSource = false;
targetBitrate = null;
}
}
}
else {
if(requireAudioSource) {
requireAudioSource = false;
targetBitrate = null;
}
}
requiredCheck = false;
}
if(original.video.hasAnySource() && !original.isDownloadable()) {
Logger.i(TAG, "Attempted to download unsupported video [${original.name}]:${original.url}");
throw DownloadException("Unsupported video for downloading", false);
@@ -689,7 +663,7 @@ class VideoDownload {
val url = foundTemplateUrl.replace("\$Number\$", indexCounter.toString());
val data = if(executor != null)
executor.executeRequest("GET", url, null, mapOf());
executor.executeRequest(url, mapOf());
else {
val resp = client.get(url, mutableMapOf());
if(!resp.isOk)
@@ -1,13 +1,12 @@
package com.futo.platformplayer.fragment.channel.tab
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
@@ -16,6 +15,7 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.models.JSPager
import com.futo.platformplayer.api.media.structures.IAsyncPager
import com.futo.platformplayer.api.media.structures.IPager
@@ -41,11 +41,10 @@ import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.math.max
class ChannelContentsFragment : Fragment(), IChannelTabFragment {
private var _recyclerResults: RecyclerView? = null;
private var _glmVideo: GridLayoutManager? = null;
private var _llmVideo: LinearLayoutManager? = null;
private var _loading = false;
private var _pager_parent: IPager<IPlatformContent>? = null;
private var _pager: IPager<IPlatformContent>? = null;
@@ -119,7 +118,7 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
super.onScrolled(recyclerView, dx, dy);
val recyclerResults = _recyclerResults ?: return;
val llmVideo = _glmVideo ?: return;
val llmVideo = _llmVideo ?: return;
val visibleItemCount = recyclerResults.childCount;
val firstVisibleItem = llmVideo.findFirstVisibleItemPosition();
@@ -164,10 +163,9 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
this.onLongPress.subscribe(this@ChannelContentsFragment.onLongPress::emit);
}
val numColumns = max((resources.configuration.screenWidthDp.toDouble() / resources.getInteger(R.integer.column_width_dp)).toInt(), 1)
_glmVideo = GridLayoutManager(view.context, numColumns);
_llmVideo = LinearLayoutManager(view.context);
_recyclerResults?.adapter = _adapterResults;
_recyclerResults?.layoutManager = _glmVideo;
_recyclerResults?.layoutManager = _llmVideo;
_recyclerResults?.addOnScrollListener(_scrollListener);
return view;
@@ -183,13 +181,6 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
_nextPageHandler.cancel();
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
_glmVideo?.spanCount =
max((resources.configuration.screenWidthDp.toDouble() / resources.getInteger(R.integer.column_width_dp)).toInt(), 1)
}
/*
private fun setPager(pager: IPager<IPlatformContent>, cache: FeedFragment.ItemCache<IPlatformContent>? = null) {
if (_pager_parent != null && _pager_parent is IRefreshPager<*>) {
@@ -1,13 +1,12 @@
package com.futo.platformplayer.fragment.channel.tab
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
@@ -37,11 +36,10 @@ import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.math.max
class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment {
private var _recyclerResults: RecyclerView? = null
private var _glmPlaylist: GridLayoutManager? = null
private var _llmPlaylist: LinearLayoutManager? = null
private var _loading = false
private var _pagerParent: IPager<IPlatformPlaylist>? = null
private var _pager: IPager<IPlatformPlaylist>? = null
@@ -111,7 +109,7 @@ class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment {
super.onScrolled(recyclerView, dx, dy)
val recyclerResults = _recyclerResults ?: return
val llmPlaylist = _glmPlaylist ?: return
val llmPlaylist = _llmPlaylist ?: return
val visibleItemCount = recyclerResults.childCount
val firstVisibleItem = llmPlaylist.findFirstVisibleItemPosition()
@@ -160,10 +158,9 @@ class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment {
this.onLongPress.subscribe(this@ChannelPlaylistsFragment.onLongPress::emit)
}
val numColumns = max((resources.configuration.screenWidthDp.toDouble() / resources.getInteger(R.integer.column_width_dp)).toInt(), 1)
_glmPlaylist = GridLayoutManager(view.context, numColumns)
_llmPlaylist = LinearLayoutManager(view.context)
_recyclerResults?.adapter = _adapterResults
_recyclerResults?.layoutManager = _glmPlaylist
_recyclerResults?.layoutManager = _llmPlaylist
_recyclerResults?.addOnScrollListener(_scrollListener)
return view
@@ -179,13 +176,6 @@ class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment {
_nextPageHandler.cancel()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
_glmPlaylist?.spanCount =
max((resources.configuration.screenWidthDp.toDouble() / resources.getInteger(R.integer.column_width_dp)).toInt(), 1)
}
private fun setPager(
pager: IPager<IPlatformPlaylist>
) {
@@ -7,7 +7,6 @@ import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -35,7 +34,7 @@ import kotlin.math.roundToInt
class MenuBottomBarFragment : MainActivityFragment() {
private var _view: MenuBottomBarView? = null;
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = MenuBottomBarView(this, inflater);
_view = view;
return view;
@@ -57,13 +56,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
return _view?.onBackPressed() ?: false;
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
_view?.updateAllButtonVisibility()
}
@SuppressLint("ViewConstructor")
@SuppressLint("ViewConstructor")
class MenuBottomBarView : LinearLayout {
private val _fragment: MenuBottomBarFragment;
private val _inflater: LayoutInflater;
@@ -83,7 +76,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
private var _buttonsVisible = 0;
private var _subscriptionsVisible = true;
private var currentButtonDefinitions: List<ButtonDefinition>? = null;
var currentButtonDefinitions: List<ButtonDefinition>? = null;
constructor(fragment: MenuBottomBarFragment, inflater: LayoutInflater) : super(inflater.context) {
_fragment = fragment;
@@ -139,7 +132,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
val staggerFactor = 3.0f
if (visible) {
moreOverlay.visibility = VISIBLE
moreOverlay.visibility = LinearLayout.VISIBLE
val animations = arrayListOf<Animator>()
animations.add(ObjectAnimator.ofFloat(moreOverlayBackground, "alpha", 0.0f, 1.0f).setDuration(duration))
@@ -168,7 +161,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
animatorSet.doOnEnd {
_moreVisibleAnimating = false
_moreVisible = false
moreOverlay.visibility = INVISIBLE
moreOverlay.visibility = LinearLayout.INVISIBLE
}
animatorSet.playTogether(animations)
animatorSet.start()
@@ -185,7 +178,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
_layoutBottomBarButtons.removeAllViews();
_layoutBottomBarButtons.addView(Space(context).apply {
layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
})
for ((index, button) in buttons.withIndex()) {
@@ -199,7 +192,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
_layoutBottomBarButtons.addView(menuButton)
if (index < buttonDefinitions.size - 1) {
_layoutBottomBarButtons.addView(Space(context).apply {
layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
})
}
@@ -207,7 +200,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
}
_layoutBottomBarButtons.addView(Space(context).apply {
layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
})
}
@@ -262,20 +255,9 @@ class MenuBottomBarFragment : MainActivityFragment() {
button.updateActive(_fragment);
}
override fun onConfigurationChanged(newConfig: Configuration?) {
super.onConfigurationChanged(newConfig)
updateAllButtonVisibility()
}
fun updateAllButtonVisibility() {
// if the more fly-out menu is open the we should close it
if(_moreVisible) {
setMoreVisible(false)
}
val defs = currentButtonDefinitions?.toMutableList() ?: return
val metrics = resources.displayMetrics
val metrics = StateApp.instance.displayMetrics ?: resources.displayMetrics;
_buttonsVisible = floor(metrics.widthPixels.toDouble() / 65.dp(resources).toDouble()).roundToInt();
if (_buttonsVisible >= defs.size) {
updateBottomMenuButtons(defs.toMutableList(), false);
@@ -407,7 +389,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
}),
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,
"All requests will be processed anonymously (unauthenticated), playback and history tracking will be disabled.\n\nTap the icon to disable.", null, 0,
UIDialogs.Action("Cancel", {
StateApp.instance.setPrivacyMode(false);
}, UIDialogs.ActionStyle.NONE),
@@ -10,7 +10,6 @@ import android.widget.TextView
import androidx.lifecycle.lifecycleScope
import com.futo.futopay.PaymentConfigurations
import com.futo.futopay.PaymentManager
import com.futo.futopay.formatMoney
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
@@ -90,13 +89,14 @@ class BuyFragment : MainFragment() {
try {
val currencies = StatePayment.instance.getAvailableCurrencies("grayjay");
val prices = StatePayment.instance.getAvailableCurrencyPrices("grayjay");
val country = StatePayment.instance.getPaymentCountryFromIP(true)?.let { c -> PaymentConfigurations.COUNTRIES.find { it.id.equals(c, ignoreCase = true) } };
val country = StatePayment.instance.getPaymentCountryFromIP()?.let { c -> PaymentConfigurations.COUNTRIES.find { it.id.equals(c, ignoreCase = true) } };
val currency = country?.let { c -> PaymentConfigurations.CURRENCIES.find { it.id == c.defaultCurrencyId && (currencies.contains(it.id)) } };
if(currency != null && prices.containsKey(currency.id)) {
val price = prices[currency.id]!!;
val priceDecimal = (price.toDouble() / 100);
withContext(Dispatchers.Main) {
_buttonBuyText.text = formatMoney(country.id, currency.id, price) + context.getString(R.string.plus_tax);
_buttonBuyText.text = currency.symbol + String.format("%.2f", priceDecimal) + context.getString(R.string.plus_tax);
}
}
}
@@ -237,7 +237,11 @@ class ChannelFragment : MainFragment() {
}
adapter.onAddToWatchLaterClicked.subscribe { content ->
if (content is IPlatformVideo) {
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true)
StatePlaylists.instance.addToWatchLater(
SerializedPlatformVideo.fromVideo(
content
)
)
UIDialogs.toast("Added to watch later\n[${content.name}]")
}
}
@@ -4,7 +4,7 @@ import android.content.Context
import android.util.Log
import android.view.LayoutInflater
import android.widget.LinearLayout
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
@@ -33,7 +33,6 @@ import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import com.futo.platformplayer.withTimestamp
import kotlin.math.floor
import kotlin.math.max
abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent, IPlatformContent, IPager<IPlatformContent>, ContentPreviewViewHolder> where TFragment : MainFragment {
private var _exoPlayer: PlayerManager? = null;
@@ -46,7 +45,9 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
private var _videoOptionsOverlay: SlideUpMenuOverlay? = null;
protected open val shouldShowTimeBar: Boolean get() = true
constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData)
constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
}
override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
return results;
@@ -54,10 +55,16 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList<IPlatformContent>): InsertedViewAdapterWithLoader<ContentPreviewViewHolder> {
val player = StatePlayer.instance.getThumbnailPlayerOrCreate(context);
player.modifyState("ThumbnailPlayer") { state -> state.muted = true };
player.modifyState("ThumbnailPlayer", { state -> state.muted = true });
_exoPlayer = player;
return PreviewContentListAdapter(context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(), arrayListOf(), shouldShowTimeBar).apply {
val v = LinearLayout(context).apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
orientation = LinearLayout.VERTICAL;
};
headerView = v;
return PreviewContentListAdapter(context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(v), arrayListOf(), shouldShowTimeBar).apply {
attachAdapterEvents(this);
}
}
@@ -82,7 +89,7 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
};
adapter.onAddToWatchLaterClicked.subscribe(this) {
if(it is IPlatformVideo) {
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true);
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it));
UIDialogs.toast("Added to watch later\n[${it.name}]");
}
};
@@ -135,10 +142,7 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
val newQueue = listOf(content) + recyclerData.results
.filterIsInstance<IPlatformVideo>()
.filter { it != content };
StatePlayer.instance.setQueue(newQueue, StatePlayer.TYPE_QUEUE, "Feed Queue",
focus = true,
shuffle = false
);
StatePlayer.instance.setQueue(newQueue, StatePlayer.TYPE_QUEUE, "Feed Queue", true, false);
})
);
}
@@ -156,22 +160,21 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
adapter.onLongPress.remove(this);
}
override fun onRestoreCachedData(cachedData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>) {
override fun onRestoreCachedData(cachedData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>) {
super.onRestoreCachedData(cachedData)
val v = LinearLayout(context).apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
orientation = LinearLayout.VERTICAL;
};
headerView = v;
cachedData.adapter.viewsToPrepend.add(v);
(cachedData.adapter as PreviewContentListAdapter?)?.let { attachAdapterEvents(it) };
}
override fun createLayoutManager(
recyclerResults: RecyclerView,
context: Context
): GridLayoutManager {
val glmResults =
GridLayoutManager(
context,
max((resources.configuration.screenWidthDp.toDouble() / resources.getInteger(R.integer.column_width_dp)).toInt(), 1)
);
return glmResults
override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): LinearLayoutManager {
val llmResults = LinearLayoutManager(context);
llmResults.orientation = LinearLayoutManager.VERTICAL;
return llmResults;
}
override fun onScrollStateChanged(newState: Int) {
@@ -214,11 +217,11 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
}
private fun playPreview() {
if(feedStyle == FeedStyle.THUMBNAIL || recyclerData.layoutManager.spanCount > 1)
if(feedStyle == FeedStyle.THUMBNAIL)
return;
val firstVisible = recyclerData.layoutManager.findFirstVisibleItemPosition()
val lastVisible = recyclerData.layoutManager.findLastVisibleItemPosition()
val firstVisible = recyclerData.layoutManager.findFirstVisibleItemPosition();
val lastVisible = recyclerData.layoutManager.findLastVisibleItemPosition();
val itemsVisible = lastVisible - firstVisible + 1;
val autoPlayIndex = (firstVisible + floor(itemsVisible / 2.0 + 0.49).toInt()).coerceAtLeast(0).coerceAtMost((recyclerData.results.size - 1));
@@ -238,7 +241,7 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
(recyclerData.adapter as PreviewContentListAdapter?)?.preview(viewHolder.childViewHolder)
}
private fun stopVideo() {
fun stopVideo() {
//TODO: Is this still necessary?
(recyclerData.adapter as PreviewContentListAdapter?)?.stopPreview();
}
@@ -266,6 +269,6 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
}
companion object {
private const val TAG = "ContentFeedView";
private val TAG = "ContentFeedView";
}
}
@@ -3,9 +3,13 @@ package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.ViewGroup.MarginLayoutParams
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.structures.*
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.*
@@ -14,7 +18,9 @@ import com.futo.platformplayer.views.adapters.viewholders.CreatorViewHolder
abstract class CreatorFeedView<TFragment> : FeedView<TFragment, PlatformAuthorLink, PlatformAuthorLink, IPager<PlatformAuthorLink>, CreatorViewHolder> where TFragment : MainFragment {
override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator;
constructor(fragment: TFragment, inflater: LayoutInflater) : super(fragment, inflater)
constructor(fragment: TFragment, inflater: LayoutInflater) : super(fragment, inflater) {
}
override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList<PlatformAuthorLink>): InsertedViewAdapterWithLoader<CreatorViewHolder> {
return InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
@@ -28,31 +34,18 @@ abstract class CreatorFeedView<TFragment> : FeedView<TFragment, PlatformAuthorLi
);
}
/*
* An empty override to remove the inherited span count update functionality
*/
override fun updateSpanCount(){
}
override fun createLayoutManager(
recyclerResults: RecyclerView,
context: Context
): GridLayoutManager {
val glmResults = GridLayoutManager(context, 2)
override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): LinearLayoutManager {
val glmResults = GridLayoutManager(context, 2);
glmResults.orientation = LinearLayoutManager.VERTICAL;
_swipeRefresh.layoutParams = (_swipeRefresh.layoutParams as MarginLayoutParams?)?.apply {
rightMargin = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
8.0f,
context.resources.displayMetrics
).toInt()
}
rightMargin = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8.0f, context.resources.displayMetrics).toInt();
};
return glmResults
return glmResults;
}
companion object {
private const val TAG = "CreatorFeedView";
private val TAG = "CreatorFeedView";
}
}
@@ -8,7 +8,6 @@ import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.Spinner
import androidx.core.widget.addTextChangedListener
import androidx.recyclerview.widget.LinearLayoutManager
@@ -26,20 +25,11 @@ class CreatorsFragment : MainFragment() {
private var _overlayContainer: FrameLayout? = null;
private var _containerSearch: FrameLayout? = null;
private var _editSearch: EditText? = null;
private var _buttonClearSearch: ImageButton? = null
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = inflater.inflate(R.layout.fragment_creators, container, false);
_containerSearch = view.findViewById(R.id.container_search);
val editSearch: EditText = view.findViewById(R.id.edit_search);
val buttonClearSearch: ImageButton = view.findViewById(R.id.button_clear_search)
_editSearch = editSearch
_buttonClearSearch = buttonClearSearch
buttonClearSearch.setOnClickListener {
editSearch.text.clear()
editSearch.requestFocus()
_buttonClearSearch?.visibility = View.INVISIBLE;
}
_editSearch = view.findViewById(R.id.edit_search);
val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription));
adapter.onClick.subscribe { platformUser -> navigate<ChannelFragment>(platformUser) };
@@ -61,12 +51,7 @@ class CreatorsFragment : MainFragment() {
_spinnerSortBy = spinnerSortBy;
_editSearch?.addTextChangedListener {
adapter.query = it.toString()
if (it?.isEmpty() == true) {
_buttonClearSearch?.visibility = View.INVISIBLE
} else {
_buttonClearSearch?.visibility = View.VISIBLE
}
adapter.query = it.toString();
}
val recyclerView = view.findViewById<RecyclerView>(R.id.recycler_subscriptions);
@@ -1,14 +1,13 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.content.res.Configuration
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.LayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
@@ -25,21 +24,18 @@ import com.futo.platformplayer.views.others.ProgressBar
import com.futo.platformplayer.views.others.TagsView
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.InsertedViewHolder
import com.futo.platformplayer.views.announcements.AnnouncementView
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
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 _recyclerResults: RecyclerView;
protected val _overlayContainer: FrameLayout;
protected val _swipeRefresh: SwipeRefreshLayout;
private val _progressBar: ProgressBar;
private val _progress_bar: ProgressBar;
private val _spinnerSortBy: Spinner;
private val _containerSortBy: LinearLayout;
private val _announcementView: AnnouncementView;
private val _tagsView: TagsView;
private val _textCentered: TextView;
private val _emptyPagerContainer: FrameLayout;
@@ -48,7 +44,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
private var _loading: Boolean = true;
private val _pagerLock = Object();
private val _pager_lock = Object();
private var _cache: ItemCache<TResult>? = null;
open val visibleThreshold = 15;
@@ -62,22 +58,21 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
private var _activeTags: List<String>? = null;
private var _nextPageHandler: TaskHandler<TPager, List<TResult>>;
val recyclerData: RecyclerData<InsertedViewAdapterWithLoader<TViewHolder>, GridLayoutManager, TPager, TResult, TConverted, InsertedViewHolder<TViewHolder>>;
val recyclerData: RecyclerData<InsertedViewAdapterWithLoader<TViewHolder>, LinearLayoutManager, TPager, TResult, TConverted, InsertedViewHolder<TViewHolder>>;
val fragment: TFragment;
private val _scrollListener: RecyclerView.OnScrollListener;
private var _automaticNextPageCounter = 0;
constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<TViewHolder>, GridLayoutManager, TPager, TResult, TConverted, InsertedViewHolder<TViewHolder>>? = null) : super(inflater.context) {
constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<TViewHolder>, LinearLayoutManager, TPager, TResult, TConverted, InsertedViewHolder<TViewHolder>>? = null) : super(inflater.context) {
this.fragment = fragment;
inflater.inflate(R.layout.fragment_feed, this);
_textCentered = findViewById(R.id.text_centered);
_emptyPagerContainer = findViewById(R.id.empty_pager_container);
_progressBar = findViewById(R.id.progress_bar);
_announcementView = findViewById(R.id.announcement_view)
_progressBar.inactiveColor = Color.TRANSPARENT;
_progress_bar = findViewById(R.id.progress_bar);
_progress_bar.inactiveColor = Color.TRANSPARENT;
_swipeRefresh = findViewById(R.id.swipe_refresh);
val recyclerResults: RecyclerView = findViewById(R.id.list_results);
@@ -163,7 +158,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
super.onScrolled(recyclerView, dx, dy);
val visibleItemCount = _recyclerResults.childCount;
val firstVisibleItem = recyclerData.layoutManager.findFirstVisibleItemPosition()
val firstVisibleItem = recyclerData.layoutManager.findFirstVisibleItemPosition();
//Logger.i(TAG, "onScrolled loadNextPage visibleItemCount=$visibleItemCount firstVisibleItem=$visibleItemCount")
if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= recyclerData.results.size && firstVisibleItem > 0) {
@@ -176,10 +171,6 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
_recyclerResults.addOnScrollListener(_scrollListener);
}
protected fun showAnnouncementView() {
_announcementView.visibility = View.VISIBLE
}
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
val canScroll = if (recyclerData.results.isEmpty()) false else {
val layoutManager = recyclerData.layoutManager
@@ -188,13 +179,14 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
if (firstVisibleItemPosition != RecyclerView.NO_POSITION) {
val firstVisibleView = layoutManager.findViewByPosition(firstVisibleItemPosition)
val itemHeight = firstVisibleView?.height ?: 0
val occupiedSpace = recyclerData.results.size / recyclerData.layoutManager.spanCount * itemHeight
val occupiedSpace = recyclerData.results.size * itemHeight
val recyclerViewHeight = _recyclerResults.height
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage occupiedSpace=$occupiedSpace recyclerViewHeight=$recyclerViewHeight")
occupiedSpace >= recyclerViewHeight
} else {
false
}
}
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter")
if (!canScroll || filteredResults.isEmpty()) {
@@ -234,20 +226,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
}
}
open fun updateSpanCount() {
recyclerData.layoutManager.spanCount =
max((resources.configuration.screenWidthDp.toDouble() / resources.getInteger(R.integer.column_width_dp)).toInt(), 1)
}
override fun onConfigurationChanged(newConfig: Configuration?) {
super.onConfigurationChanged(newConfig)
updateSpanCount()
}
fun onResume() {
updateSpanCount()
//Reload the pager if the plugin was killed
val pager = recyclerData.pager;
if((pager is MultiPager<*> && pager.findPager { it is JSPager<*> && !it.isAvailable } != null) ||
@@ -273,7 +252,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
protected open fun setActiveTags(activeTags: List<String>?) {
_activeTags = activeTags;
if (!activeTags.isNullOrEmpty()) {
if (activeTags != null && activeTags.isNotEmpty()) {
_tagsView.setTags(activeTags);
_tagsView.visibility = View.VISIBLE;
} else {
@@ -283,7 +262,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
protected open fun setSortByOptions(options: List<String>?) {
_sortByOptions = options;
if (!options.isNullOrEmpty()) {
if (options != null && options.isNotEmpty()) {
val allOptions = arrayListOf<String>();
allOptions.add("Default");
allOptions.addAll(options);
@@ -298,19 +277,19 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
}
}
protected abstract fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList<TConverted>): InsertedViewAdapterWithLoader<TViewHolder>;
protected abstract fun createLayoutManager(recyclerResults: RecyclerView, context: Context): GridLayoutManager;
protected open fun onRestoreCachedData(cachedData: RecyclerData<InsertedViewAdapterWithLoader<TViewHolder>, GridLayoutManager, TPager, TResult, TConverted, InsertedViewHolder<TViewHolder>>) {}
protected abstract fun createLayoutManager(recyclerResults: RecyclerView, context: Context): LinearLayoutManager;
protected open fun onRestoreCachedData(cachedData: RecyclerData<InsertedViewAdapterWithLoader<TViewHolder>, LinearLayoutManager, TPager, TResult, TConverted, InsertedViewHolder<TViewHolder>>) {}
protected fun setProgress(fin: Int, total: Int) {
val progress = (fin.toFloat() / total);
_progressBar.progress = progress;
_progress_bar.progress = progress;
if(progress > 0 && progress < 1)
{
if(_progressBar.height == 0)
_progressBar.layoutParams = LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 5);
if(_progress_bar.height == 0)
_progress_bar.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 5);
}
else if(_progressBar.height > 0) {
_progressBar.layoutParams = LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0);
else if(_progress_bar.height > 0) {
_progress_bar.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0);
}
}
@@ -366,7 +345,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
//insertPagerResults(_cache!!.cachePager.getResults(), false);
}
fun setPager(pager: TPager, cache: ItemCache<TResult>? = null) {
synchronized(_pagerLock) {
synchronized(_pager_lock) {
detachParentPagerEvents();
detachPagerEvents();
@@ -446,7 +425,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
val p = recyclerData.pager;
if(p is IReplacerPager<*>) {
p.onReplaced.subscribe(this) { _, newItem ->
synchronized(_pagerLock) {
synchronized(_pager_lock) {
val filtered = filterResults(listOf(newItem as TResult));
if(filtered.isEmpty())
return@subscribe;
@@ -464,7 +443,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
var _lastNextPage = false;
private fun loadNextPage() {
synchronized(_pagerLock) {
synchronized(_pager_lock) {
val pager: TPager = recyclerData.pager ?: return;
val hasMorePages = pager.hasMorePages();
Logger.i(TAG, "loadNextPage() hasMorePages=$hasMorePages, page size=${pager.getResults().size}");
@@ -489,7 +468,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
}
companion object {
private const val TAG = "FeedView";
private val TAG = "FeedView";
}
abstract class ItemCache<TResult>(val cachePager: IPager<TResult>) {
@@ -6,7 +6,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import com.futo.platformplayer.*
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
@@ -18,9 +18,13 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.NoResultsView
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
@@ -28,8 +32,11 @@ import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.InsertedViewHolder
import com.futo.platformplayer.views.announcements.AnnouncementView
import com.futo.platformplayer.views.buttons.BigButton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.time.OffsetDateTime
import java.util.UUID
class HomeFragment : MainFragment() {
override val isMainView : Boolean = true;
@@ -37,7 +44,7 @@ class HomeFragment : MainFragment() {
override val hasBottomBar: Boolean get() = true;
private var _view: HomeView? = null;
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null;
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null;
fun reloadFeed() {
_view?.reloadFeed()
@@ -94,10 +101,16 @@ class HomeFragment : MainFragment() {
class HomeView : ContentFeedView<HomeFragment> {
override val feedStyle: FeedStyle get() = Settings.instance.home.getHomeFeedStyle();
private var _announcementsView: AnnouncementView;
private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>;
override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
_announcementsView = AnnouncementView(context, null).apply {
headerView.addView(this);
};
_taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({ fragment.lifecycleScope }, {
StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope)
})
@@ -128,7 +141,6 @@ class HomeFragment : MainFragment() {
};
setPreviewsEnabled(Settings.instance.home.previewFeedItems);
showAnnouncementView()
}
fun onShown() {
@@ -162,7 +174,7 @@ class HomeFragment : MainFragment() {
loadResults();
}
override fun getEmptyPagerView(): View {
override fun getEmptyPagerView(): View? {
val dp10 = 10.dp(resources);
val dp30 = 30.dp(resources);
@@ -194,7 +206,8 @@ class HomeFragment : MainFragment() {
listOf(BigButton(context, "Sources", "Go to the sources tab", R.drawable.ic_creators) {
fragment.navigate<SourcesFragment>();
}.withMargin(dp10, dp30))
)
);
return null;
}
override fun reload() {
@@ -214,7 +227,7 @@ class HomeFragment : MainFragment() {
//StateAnnouncement.instance.registerAnnouncement(UUID.randomUUID().toString(), context.getString(R.string.no_home_available), context.getString(R.string.no_home_page_is_available_please_check_if_you_are_connected_to_the_internet_and_refresh), AnnouncementType.SESSION);
}
Logger.i(TAG, "Got new home pager $pager");
Logger.i(TAG, "Got new home pager ${pager}");
finishRefreshLayoutLoader();
setLoading(false);
setPager(pager);
@@ -224,7 +237,7 @@ class HomeFragment : MainFragment() {
}
companion object {
const val TAG = "HomeFragment";
val TAG = "HomeFragment";
fun newInstance() = HomeFragment().apply {}
}
@@ -70,7 +70,7 @@ class PlaylistFragment : MainFragment() {
private var _editPlaylistOverlay: SlideUpMenuOverlay? = null;
private var _url: String? = null;
private val _taskLoadPlaylist: TaskHandler<String, Playlist>;
private val _taskLoadPlaylist: TaskHandler<String, IPlatformPlaylistDetails>;
constructor(fragment: PlaylistFragment, inflater: LayoutInflater) : super(inflater) {
_fragment = fragment;
@@ -137,16 +137,16 @@ class PlaylistFragment : MainFragment() {
);
};
_taskLoadPlaylist = TaskHandler<String, Playlist>(
_taskLoadPlaylist = TaskHandler<String, IPlatformPlaylistDetails>(
StateApp.instance.scopeGetter,
{
return@TaskHandler StatePlatform.instance.getPlaylist(it).toPlaylist();
return@TaskHandler StatePlatform.instance.getPlaylist(it);
})
.success {
setName(it.name);
//TODO: Implement support for pagination
setVideos(it.videos, false);
setVideoCount(it.videos.size);
setVideos(it.toPlaylist().videos, false);
setVideoCount(it.videoCount);
setLoading(false);
}
.exception<Throwable> {
@@ -12,7 +12,6 @@ import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
@@ -24,8 +23,6 @@ import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.views.adapters.*
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import com.google.android.material.appbar.AppBarLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class PlaylistsFragment : MainFragment() {
@@ -122,9 +119,7 @@ class PlaylistsFragment : MainFragment() {
findViewById<TextView>(R.id.text_view_all).setOnClickListener { _fragment.navigate<WatchLaterFragment>(context.getString(R.string.watch_later)); };
StatePlaylists.instance.onWatchLaterChanged.subscribe(this) {
fragment.lifecycleScope.launch(Dispatchers.Main) {
updateWatchLater();
}
updateWatchLater();
};
}
@@ -180,7 +180,7 @@ class SubscriptionGroupFragment : MainFragment() {
UIDialogs.showDialog(context, R.drawable.ic_trash, "Delete Group", "Are you sure you want to this group?\n[${g.name}]?", null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Delete", {
StateSubscriptionGroups.instance.deleteSubscriptionGroup(g.id, true);
StateSubscriptionGroups.instance.deleteSubscriptionGroup(g.id);
_didDelete = true;
fragment.close(true);
}, UIDialogs.ActionStyle.DANGEROUS))
@@ -253,7 +253,7 @@ class SubscriptionGroupFragment : MainFragment() {
if(g.urls.isEmpty() && g.image == null) {
//Obtain image
for(sub in it) {
val sub = StateSubscriptions.instance.getSubscription(sub) ?: StateSubscriptions.instance.getSubscriptionOther(sub);
val sub = StateSubscriptions.instance.getSubscription(sub);
if(sub != null && sub.channel.thumbnail != null) {
g.image = ImageVariable.fromUrl(sub.channel.thumbnail!!);
g.image?.setImageView(_imageGroup);
@@ -308,10 +308,8 @@ class SubscriptionGroupFragment : MainFragment() {
if(group != null) {
val urls = group.urls.toList();
val subs = urls.map {
(StateSubscriptions.instance.getSubscription(it) ?: StateSubscriptions.instance.getSubscriptionOther(it))?.channel
}.filterNotNull();
_enabledCreators.addAll(subs);
val subs = StateSubscriptions.instance.getSubscriptions().map { it.channel }
_enabledCreators.addAll(subs.filter { urls.contains(it.url) });
}
updateMeta();
filterCreators();
@@ -14,7 +14,6 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.activities.AddSourceOptionsActivity
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
@@ -58,19 +57,10 @@ class SubscriptionGroupListFragment : MainFragment() {
};
it.onDelete.subscribe { group ->
context?.let { context ->
UIDialogs.showDialog(context, R.drawable.ic_trash, "Delete Group", "Are you sure you want to this group?\n[${group.name}]?", null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Delete", {
StateSubscriptionGroups.instance.deleteSubscriptionGroup(group.id, true);
val loc = _subs.indexOf(group);
_subs.remove(group);
_list?.adapter?.notifyItemRangeRemoved(loc);
StateSubscriptionGroups.instance.deleteSubscriptionGroup(group.id, true);
}, UIDialogs.ActionStyle.DANGEROUS));
}
val loc = _subs.indexOf(group);
_subs.remove(group);
_list?.adapter?.notifyItemRangeRemoved(loc);
StateSubscriptionGroups.instance.deleteSubscriptionGroup(group.id);
};
it.onDragDrop.subscribe {
_touchHelper?.startDrag(it);
@@ -5,10 +5,12 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import com.futo.platformplayer.*
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
@@ -25,7 +27,6 @@ import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.FragmentedStorageFileJson
@@ -35,6 +36,7 @@ import com.futo.platformplayer.views.ToastView
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.InsertedViewHolder
import com.futo.platformplayer.views.announcements.AnnouncementView
import com.futo.platformplayer.views.buttons.BigButton
import com.futo.platformplayer.views.subscriptions.SubscriptionBar
import kotlinx.coroutines.CancellationException
@@ -44,6 +46,7 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.nio.channels.Channel
import java.time.OffsetDateTime
import kotlin.system.measureTimeMillis
@@ -54,7 +57,7 @@ class SubscriptionsFeedFragment : MainFragment() {
private var _view: SubscriptionsFeedView? = null;
private var _group: SubscriptionGroup? = null;
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null;
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null;
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
@@ -107,7 +110,7 @@ class SubscriptionsFeedFragment : MainFragment() {
var subGroup: SubscriptionGroup? = null;
constructor(fragment: SubscriptionsFeedFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
constructor(fragment: SubscriptionsFeedFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
Logger.i(TAG, "SubscriptionsFeedFragment constructor()");
StateSubscriptions.instance.global.onUpdateProgress.subscribe(this) { progress, total ->
};
@@ -124,9 +127,6 @@ class SubscriptionsFeedFragment : MainFragment() {
initializeToolbarContent();
setPreviewsEnabled(Settings.instance.subscriptions.previewFeedItems);
if (Settings.instance.tabs.find { it.id == 0 }?.enabled != true) {
showAnnouncementView()
}
}
fun onShown() {
@@ -147,6 +147,23 @@ class SubscriptionsFeedFragment : MainFragment() {
}
}
val announcementsView = _announcementsView;
val homeTab = Settings.instance.tabs.find { it.id == 0 };
val isHomeEnabled = homeTab?.enabled == true;
if (announcementsView != null && isHomeEnabled) {
headerView.removeView(announcementsView);
_announcementsView = null;
}
if (announcementsView == null && !isHomeEnabled) {
val c = context;
if (c != null) {
_announcementsView = AnnouncementView(c, null).apply {
headerView.addView(this)
};
}
}
if (!StateSubscriptions.instance.global.isGlobalUpdating) {
finishRefreshLayoutLoader();
}
@@ -174,6 +191,8 @@ class SubscriptionsFeedFragment : MainFragment() {
private var _subscriptionBar: SubscriptionBar? = null;
private var _announcementsView: AnnouncementView? = null;
@Serializable
class FeedFilterSettings: FragmentedStorageFileJson() {
val allowContentTypes: MutableList<ContentType> = mutableListOf(ContentType.MEDIA, ContentType.POST);
@@ -195,7 +214,7 @@ class SubscriptionsFeedFragment : MainFragment() {
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount(group);
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n");
val rateLimitPlugins = subRequestCounts.filter { clientCount -> clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true }
Logger.w(TAG, "Trying to refreshing subscriptions with requests:\n$reqCountStr");
Logger.w(TAG, "Trying to refreshing subscriptions with requests:\n" + reqCountStr);
if(rateLimitPlugins.any())
throw RateLimitException(rateLimitPlugins.map { it.key.id });
}
@@ -257,7 +276,7 @@ class SubscriptionsFeedFragment : MainFragment() {
private fun initializeToolbarContent() {
_subscriptionBar = SubscriptionBar(context).apply {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
};
_subscriptionBar?.onClickChannel?.subscribe { c -> fragment.navigate<ChannelFragment>(c); };
_subscriptionBar?.onToggleGroup?.subscribe { g ->
@@ -345,7 +364,6 @@ class SubscriptionsFeedFragment : MainFragment() {
}
override fun reload() {
StatePlugins.instance.clearUpdating(); //Fallback in case it doesnt clear, UI should be blocked.
loadResults(true);
}
@@ -377,7 +395,7 @@ class SubscriptionsFeedFragment : MainFragment() {
_taskGetPager.run(withRefetch);
}
override fun onRestoreCachedData(cachedData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>) {
override fun onRestoreCachedData(cachedData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>) {
super.onRestoreCachedData(cachedData);
setEmptyPager(cachedData.results.isEmpty());
}
@@ -432,7 +450,7 @@ class SubscriptionsFeedFragment : MainFragment() {
if (toShow is PluginException)
UIDialogs.appToast(ToastView.Toast(
toShow.message +
(if(channel != null) "\nChannel: $channel" else ""), false, null,
(if(channel != null) "\nChannel: " + channel else ""), false, null,
"Plugin ${toShow.config.name} failed")
);
else
@@ -443,14 +461,14 @@ class SubscriptionsFeedFragment : MainFragment() {
val failedChannels = exs.filterIsInstance<ChannelException>().map { it.channelNameOrUrl }.distinct().toList();
val failedPlugins = exs.filter { it is PluginException || (it is ChannelException && it.cause is PluginException) }
.map { if(it is ChannelException) (it.cause as PluginException) else if(it is PluginException) it else null }
.filterNotNull()
.filter { it != null }
.distinctBy { it?.config?.name }
.map { it!! }
.toList();
for(distinctPluginFail in failedPlugins)
UIDialogs.appToast(context.getString(R.string.plugin_pluginname_failed_message).replace("{pluginName}", distinctPluginFail.config.name).replace("{message}", distinctPluginFail.message ?: ""));
if(failedChannels.isNotEmpty())
UIDialogs.appToast(ToastView.Toast(failedChannels.take(3).map { "- $it" }.joinToString("\n") +
UIDialogs.appToast(ToastView.Toast(failedChannels.take(3).map { "- ${it}" }.joinToString("\n") +
(if(failedChannels.size >= 3) "\nAnd ${failedChannels.size - 3} more" else ""), false, null, "Failed Channels"));
}
} catch (e: Throwable) {
@@ -462,7 +480,7 @@ class SubscriptionsFeedFragment : MainFragment() {
}
companion object {
const val TAG = "SubscriptionsFeedFragment";
val TAG = "SubscriptionsFeedFragment";
fun newInstance() = SubscriptionsFeedFragment().apply {}
}
@@ -6,7 +6,6 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.IPlatformClient
@@ -59,15 +58,7 @@ class TutorialFragment : MainFragment() {
}
@SuppressLint("ViewConstructor")
class TutorialView(fragment: TutorialFragment, inflater: LayoutInflater) :
ScrollView(inflater.context) {
init {
addView(TutorialContainer(fragment, inflater))
}
}
@SuppressLint("ViewConstructor")
class TutorialContainer : LinearLayout {
class TutorialView : LinearLayout {
val fragment: TutorialFragment
constructor(fragment: TutorialFragment, inflater: LayoutInflater) : super(inflater.context) {
@@ -159,7 +150,7 @@ class TutorialFragment : MainFragment() {
}
companion object {
const val TAG = "HomeFragment";
val TAG = "HomeFragment";
fun newInstance() = TutorialFragment().apply {}
val initialSetupVideos = listOf(
@@ -1,12 +1,12 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.util.Log
import android.view.LayoutInflater
import android.view.OrientationEventListener
import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets
@@ -14,9 +14,10 @@ import android.view.WindowInsetsController
import android.view.WindowManager
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.core.view.WindowCompat
import androidx.media3.common.util.UnstableApi
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.SimpleOrientationListener
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
@@ -24,36 +25,29 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.listeners.AutoRotateChangeListener
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.PlatformVideoWithTime
import com.futo.platformplayer.models.UrlVideoWithTime
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.views.containers.SingleViewTouchableMotionLayout
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.min
//region Fragment
@UnstableApi
class VideoDetailFragment() : MainFragment() {
override val isMainView: Boolean = false;
class VideoDetailFragment : MainFragment {
override val isMainView : Boolean = false;
override val hasBottomBar: Boolean = true;
override val isOverlay: Boolean = true;
override val isOverlay : Boolean = true;
override val isHistory: Boolean = false;
private var _isActive: Boolean = false;
private var _viewDetail : VideoDetailView? = null;
private var _view : SingleViewTouchableMotionLayout? = null;
private lateinit var _autoRotateChangeListener: AutoRotateChangeListener
private lateinit var _orientationListener: SimpleOrientationListener
private var _currentOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
var isFullscreen : Boolean = false;
/**
* whether the view is in the process of switching from full-screen maximized to minimized
* this is used to detect that the app is skipping the non full-screen maximized state
*/
var isMinimizingFromFullScreen : Boolean = false;
val onFullscreenChanged = Event1<Boolean>();
var isTransitioning : Boolean = false
private set;
@@ -82,10 +76,9 @@ class VideoDetailFragment() : MainFragment() {
private var _loadUrlOnCreate: UrlVideoWithTime? = null;
private var _leavingPiP = false;
private var _landscapeOrientationListener: LandscapeOrientationListener? = null
private var _portraitOrientationListener: PortraitOrientationListener? = null
private var _lastSetOrientation: Int = Configuration.ORIENTATION_UNDEFINED
private var _ignoreNextNewOrientation = false
//region Fragment
constructor() : super() {
}
fun nextVideo() {
_viewDetail?.nextVideo(true, true, true);
@@ -95,130 +88,65 @@ class VideoDetailFragment() : MainFragment() {
_viewDetail?.prevVideo(true);
}
private fun isSmallWindow(): Boolean {
return min(
resources.configuration.screenWidthDp,
resources.configuration.screenHeightDp
) < resources.getInteger(R.integer.column_width_dp) * 2
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
val isLandscapeVideo: Boolean = _viewDetail?.isLandscapeVideo() ?: false
val isSmallWindow = isSmallWindow()
val temp = _lastSetOrientation
if (_ignoreNextNewOrientation) {
_ignoreNextNewOrientation = false
} else {
// the device has rotated so update our state tracking what the physical orientation of the device is
_lastSetOrientation = newConfig.orientation
}
if (
isSmallWindow
&& newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE
&& !isFullscreen
&& state == State.MAXIMIZED
) {
_viewDetail?.setFullscreen(true)
} else if (
isSmallWindow
&& isFullscreen
&& !Settings.instance.playback.fullscreenPortrait
&& newConfig.orientation == Configuration.ORIENTATION_PORTRAIT
&& temp == Configuration.ORIENTATION_LANDSCAPE
&& isLandscapeVideo
) {
_viewDetail?.setFullscreen(false)
}
}
private fun onStateChanged(state: State) {
if (
isSmallWindow()
&& state == State.MAXIMIZED
&& !isFullscreen
&& resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
) {
_viewDetail?.setFullscreen(true)
}
private fun onStateChanged(state: VideoDetailFragment.State) {
updateOrientation()
}
private fun onVideoChanged(videoWidth : Int, videoHeight: Int) {
if (
isSmallWindow()
&& state == State.MAXIMIZED
&& !isFullscreen
&& videoHeight > videoWidth
) {
_viewDetail?.setFullscreen(true)
}
}
fun updateOrientation() {
private fun updateOrientation() {
val a = activity ?: return
val isFullScreenPortraitAllowed = Settings.instance.playback.fullscreenPortrait
val isReversePortraitAllowed = Settings.instance.playback.reversePortrait
val rotationLock = StatePlayer.instance.rotationLock
val isMaximized = state == State.MAXIMIZED
val isFullScreenPortraitAllowed = Settings.instance.playback.fullscreenPortrait;
val bypassRotationPrevention = Settings.instance.other.bypassRotationPrevention;
val fullAutorotateLock = Settings.instance.playback.fullAutorotateLock
val currentRequestedOrientation = a.requestedOrientation
var currentOrientation = if (_currentOrientation == -1) currentRequestedOrientation else _currentOrientation
if (currentOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT && !Settings.instance.playback.reversePortrait)
currentOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
val isLandscapeVideo: Boolean = _viewDetail?.isLandscapeVideo() ?: false
val isAutoRotate = Settings.instance.playback.isAutoRotate()
val isFs = isFullscreen
val isSmallWindow = isSmallWindow()
val autoRotateEnabled = android.provider.Settings.System.getInt(
context?.contentResolver,
android.provider.Settings.System.ACCELEROMETER_ROTATION, 0
) == 1
// For small windows if the device isn't landscape right now and full screen portrait isn't allowed then we should force landscape
if (isSmallWindow && isFullscreen && !isFullScreenPortraitAllowed && _lastSetOrientation != Configuration.ORIENTATION_LANDSCAPE && !rotationLock && isLandscapeVideo) {
if (Settings.instance.playback.forceAllowFullScreenRotation) {
a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
if (fullAutorotateLock) {
if (isFs && isMaximized) {
if (isFullScreenPortraitAllowed) {
if (isAutoRotate) {
a.requestedOrientation = currentOrientation
}
} else if (currentOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE || currentOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE) {
if (isAutoRotate || currentOrientation != currentRequestedOrientation && (currentRequestedOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT || currentRequestedOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT)) {
a.requestedOrientation = currentOrientation
}
} else {
a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
} else if (bypassRotationPrevention) {
a.requestedOrientation = currentOrientation
} else if (currentOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT || currentOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT) {
a.requestedOrientation = currentOrientation
} else {
a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
// the next orientation change will not reflect the device because we are manually setting the orientation to landscape
_ignoreNextNewOrientation = true
if (autoRotateEnabled
) {
// start listening for the device to rotate to landscape
// at which point we'll be able to set requestedOrientation to back to UNSPECIFIED
_landscapeOrientationListener?.enableListener()
}
}
// For small windows if the device isn't in a portrait orientation and we're in the maximized state then we should force portrait
else if (isSmallWindow && !isMinimizingFromFullScreen && !isFullscreen && state == State.MAXIMIZED && _lastSetOrientation == Configuration.ORIENTATION_LANDSCAPE) {
a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
// the next orientation change will not reflect the device because we are manually setting the orientation to portrait
_ignoreNextNewOrientation = true
if (autoRotateEnabled
) {
// start listening for the device to rotate to portrait
// at which point we'll be able to set requestedOrientation to back to UNSPECIFIED
_portraitOrientationListener?.enableListener()
} else {
// the rotation state resets to portrait when changing requestedOrientation
_lastSetOrientation = Configuration.ORIENTATION_PORTRAIT
}
} else if (rotationLock) {
_portraitOrientationListener?.disableListener()
_landscapeOrientationListener?.disableListener()
a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED
} else {
_portraitOrientationListener?.disableListener()
_landscapeOrientationListener?.disableListener()
a.requestedOrientation = if (isReversePortraitAllowed) {
ActivityInfo.SCREEN_ORIENTATION_FULL_USER
if (isFs && isMaximized) {
if (isFullScreenPortraitAllowed) {
a.requestedOrientation = currentOrientation
} else if (currentOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE || currentOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE) {
a.requestedOrientation = currentOrientation
} else if (currentRequestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE || currentRequestedOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE) {
//Don't change anything
} else {
a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
} else if (bypassRotationPrevention) {
a.requestedOrientation = currentOrientation
} else if (currentOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT || currentOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT) {
a.requestedOrientation = currentOrientation
} else {
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
}
Log.i(TAG, "updateOrientation (isFs = ${isFs}, currentOrientation = ${currentOrientation}, fullAutorotateLock = ${fullAutorotateLock}, currentRequestedOrientation = ${currentRequestedOrientation}, isMaximized = ${isMaximized}, isAutoRotate = ${isAutoRotate}, isFullScreenPortraitAllowed = ${isFullScreenPortraitAllowed}) resulted in requested orientation ${activity?.requestedOrientation}");
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
@@ -260,6 +188,10 @@ class VideoDetailFragment() : MainFragment() {
return true;
}
override fun onHide() {
super.onHide();
}
fun preventPictureInPicture() {
Logger.i(TAG, "preventPictureInPicture() preventPictureInPicture = true");
_viewDetail?.preventPictureInPicture = true;
@@ -299,9 +231,7 @@ class VideoDetailFragment() : MainFragment() {
_viewDetail = _view!!.findViewById<VideoDetailView>(R.id.fragview_videodetail).also {
it.applyFragment(this);
it.onFullscreenChanged.subscribe(::onFullscreenChanged);
it.onVideoChanged.subscribe(::onVideoChanged)
it.onMinimize.subscribe {
isMinimizingFromFullScreen = true
_view!!.transitionToStart();
};
it.onClose.subscribe {
@@ -338,7 +268,6 @@ class VideoDetailFragment() : MainFragment() {
if (state != State.MINIMIZED && progress < 0.1) {
state = State.MINIMIZED;
isMinimizingFromFullScreen = false
onMinimize.emit();
}
else if (state != State.MAXIMIZED && progress > 0.9) {
@@ -377,6 +306,13 @@ class VideoDetailFragment() : MainFragment() {
minimizeVideoDetail();
}
_autoRotateChangeListener = AutoRotateChangeListener(requireContext(), Handler()) { _ ->
if (updateAutoFullscreen()) {
return@AutoRotateChangeListener
}
updateOrientation()
}
_loadUrlOnCreate?.let { _viewDetail?.setVideo(it.url, it.timeSeconds, it.playWhenReady) };
maximizeVideoDetail();
@@ -385,33 +321,40 @@ class VideoDetailFragment() : MainFragment() {
}
StatePlayer.instance.onRotationLockChanged.subscribe(this) {
if (updateAutoFullscreen()) {
return@subscribe
}
updateOrientation()
}
val delayBeforeRemoveRotationLock = 800L
_orientationListener = SimpleOrientationListener(requireActivity(), lifecycleScope)
_orientationListener.onOrientationChanged.subscribe {
_currentOrientation = it
Logger.i(TAG, "Current orientation changed (_currentOrientation = ${_currentOrientation})")
_landscapeOrientationListener = LandscapeOrientationListener(requireContext())
{
CoroutineScope(Dispatchers.Main).launch {
// delay to make sure that the system auto rotate updates
delay(delayBeforeRemoveRotationLock)
_lastSetOrientation = Configuration.ORIENTATION_LANDSCAPE
updateOrientation()
if (updateAutoFullscreen()) {
return@subscribe
}
updateOrientation()
}
_portraitOrientationListener = PortraitOrientationListener(requireContext())
{
CoroutineScope(Dispatchers.Main).launch {
// delay to make sure that the system auto rotate updates
delay(delayBeforeRemoveRotationLock)
_lastSetOrientation = Configuration.ORIENTATION_PORTRAIT
updateOrientation()
}
}
return _view!!;
}
private fun updateAutoFullscreen(): Boolean {
if (Settings.instance.playback.isAutoRotate()) {
if (state == State.MAXIMIZED && !isFullscreen && (_currentOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE || _currentOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE)) {
_viewDetail?.setFullscreen(true)
return true
}
if (state == State.MAXIMIZED && isFullscreen && !Settings.instance.playback.fullscreenPortrait && (_currentOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT || _currentOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT)) {
_viewDetail?.setFullscreen(false)
return true
}
}
return false
}
fun onUserLeaveHint() {
val viewDetail = _viewDetail;
Logger.i(TAG, "onUserLeaveHint preventPictureInPicture=${viewDetail?.preventPictureInPicture} isCasting=${StateCasting.instance.isCasting} isBackgroundPictureInPicture=${Settings.instance.playback.isBackgroundPictureInPicture()} allowBackground=${viewDetail?.allowBackground}");
@@ -500,19 +443,19 @@ class VideoDetailFragment() : MainFragment() {
if(shouldStop) {
_viewDetail?.onStop();
StateCasting.instance.onStop();
Logger.v(TAG, "called onStop() shouldStop: $shouldStop");
}
}
override fun onDestroyMainView() {
super.onDestroyMainView();
Logger.v(TAG, "onDestroyMainView");
_autoRotateChangeListener?.unregister()
_orientationListener.stopListening()
SettingsActivity.settingsActivityClosed.remove(this)
StatePlayer.instance.onRotationLockChanged.remove(this)
_landscapeOrientationListener?.disableListener()
_portraitOrientationListener?.disableListener()
_viewDetail?.let {
_viewDetail = null;
it.onDestroy();
@@ -589,7 +532,7 @@ class VideoDetailFragment() : MainFragment() {
}
companion object {
private const val TAG = "VideoDetailFragment";
private val TAG = "VideoDetailFragment";
fun newInstance() = VideoDetailFragment().apply {}
}
@@ -605,66 +548,4 @@ class VideoDetailFragment() : MainFragment() {
//region View
//TODO: Determine if encapsulated would be readable enough
//endregion
}
class LandscapeOrientationListener(
context: Context,
private val onLandscapeDetected: () -> Unit
) : OrientationEventListener(context) {
private var isListening = false
override fun onOrientationChanged(orientation: Int) {
if (!isListening) return
if (orientation in 60..120 || orientation in 240..300) {
onLandscapeDetected()
disableListener()
}
}
fun enableListener() {
if (!isListening) {
isListening = true
enable()
}
}
fun disableListener() {
if (isListening) {
isListening = false
disable()
}
}
}
class PortraitOrientationListener(
context: Context,
private val onPortraitDetected: () -> Unit
) : OrientationEventListener(context) {
private var isListening = false
override fun onOrientationChanged(orientation: Int) {
if (!isListening) return
if (orientation in 0..30 || orientation in 330..360 || orientation in 150..210) {
onPortraitDetected()
disableListener()
}
}
fun enableListener() {
if (!isListening) {
isListening = true
enable()
}
}
fun disableListener() {
if (isListening) {
isListening = false
disable()
}
}
}
}
@@ -4,7 +4,6 @@ import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.Rect
@@ -41,7 +40,6 @@ import androidx.media3.ui.TimeBar
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
@@ -74,7 +72,6 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails
import com.futo.platformplayer.api.media.structures.IPager
@@ -82,7 +79,6 @@ import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.downloads.VideoLocal
import com.futo.platformplayer.dp
@@ -115,12 +111,9 @@ import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.states.StateSync
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.platformplayer.stores.db.types.DBHistory
import com.futo.platformplayer.sync.internal.GJSyncOpcodes
import com.futo.platformplayer.sync.models.SendToDevicePackage
import com.futo.platformplayer.toHumanBitrate
import com.futo.platformplayer.toHumanBytesSize
import com.futo.platformplayer.toHumanNowDiffString
@@ -161,20 +154,20 @@ import com.futo.polycentric.core.Opinion
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.google.protobuf.ByteString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.Dispatcher
import org.w3c.dom.Text
import userpackage.Protocol
import java.time.OffsetDateTime
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToLong
@UnstableApi
@androidx.media3.common.util.UnstableApi
class VideoDetailView : ConstraintLayout {
private val TAG = "VideoDetailView"
@@ -187,7 +180,7 @@ class VideoDetailView : ConstraintLayout {
private var _searchVideo: IPlatformVideo? = null;
var video: IPlatformVideoDetails? = null
private set;
private var videoLocal: VideoLocal? = null;
var videoLocal: VideoLocal? = null;
private var _playbackTracker: IPlaybackTracker? = null;
private var _historyIndex: DBHistory.Index? = null;
@@ -202,7 +195,7 @@ class VideoDetailView : ConstraintLayout {
private val _timeBar: TimeBar;
private var _upNext: UpNextView;
private val rootView: ConstraintLayout;
val rootView: ConstraintLayout;
private val _title: TextView;
private val _subTitle: TextView;
@@ -291,7 +284,7 @@ class VideoDetailView : ConstraintLayout {
var isPlaying: Boolean = false
private set;
private var lastPositionMilliseconds: Long = 0
var lastPositionMilliseconds: Long = 0
private set;
private var _historicalPosition: Long = 0;
private var _commentsCount = 0;
@@ -306,7 +299,6 @@ class VideoDetailView : ConstraintLayout {
val onFullscreenChanged = Event1<Boolean>();
val onEnterPictureInPicture = Event0();
val onPlayChanged = Event1<Boolean>();
val onVideoChanged = Event2<Int, Int>()
var allowBackground : Boolean = false
private set;
@@ -532,14 +524,12 @@ class VideoDetailView : ConstraintLayout {
_cast.onChapterChanged.subscribe(onChapterChanged);
_cast.onMinimizeClick.subscribe {
// emit minimize before toggling fullscreen so we know that the full screen toggle is happening during a minimize operation
onMinimize.emit()
_player.setFullScreen(false)
_player.setFullScreen(false);
onMinimize.emit();
};
_player.onMinimize.subscribe {
// emit minimize before toggling fullscreen so we know that the full screen toggle is happening during a minimize operation
onMinimize.emit()
_player.setFullScreen(false)
_player.setFullScreen(false);
onMinimize.emit();
};
_player.onTimeBarChanged.subscribe { position, _ ->
@@ -647,27 +637,6 @@ class VideoDetailView : ConstraintLayout {
StatePlayer.instance.onVideoChanging.subscribe(this) {
setVideoOverview(it);
};
var hadDevice = false;
StateSync.instance.deviceUpdatedOrAdded.subscribe(this) { id, session ->
val hasDevice = StateSync.instance.hasAtLeastOneOnlineDevice();
if(hasDevice != hadDevice) {
hadDevice = hasDevice;
fragment.lifecycleScope.launch(Dispatchers.Main) {
updateMoreButtons();
}
}
};
StateSync.instance.deviceRemoved.subscribe(this) { id ->
val hasDevice = StateSync.instance.hasAtLeastOneOnlineDevice();
if(hasDevice != hadDevice) {
hadDevice = hasDevice;
fragment.lifecycleScope.launch(Dispatchers.Main) {
updateMoreButtons();
}
}
}
MediaControlReceiver.onLowerVolumeReceived.subscribe(this) { handleLowerVolume() };
MediaControlReceiver.onPlayReceived.subscribe(this) { handlePlay() };
MediaControlReceiver.onPauseReceived.subscribe(this) { handlePause() };
@@ -728,8 +697,7 @@ class VideoDetailView : ConstraintLayout {
if (c is PolycentricPlatformComment) {
var parentComment: PolycentricPlatformComment = c;
_container_content_replies.load(
_tabIndex!! != 0, metadata, c.contextUrl, c.reference, c,
_container_content_replies.load(if (_tabIndex!! == 0) false else true, metadata, c.contextUrl, c.reference, c,
{ StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) },
{
val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1);
@@ -737,13 +705,12 @@ class VideoDetailView : ConstraintLayout {
parentComment = newComment;
});
} else {
_container_content_replies.load(_tabIndex!! != 0, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) });
_container_content_replies.load(if (_tabIndex!! == 0) false else true, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) });
}
switchContentView(_container_content_replies);
};
onClose.subscribe {
checkAndRemoveWatchLater();
_lastVideoSource = null;
_lastAudioSource = null;
_lastSubtitleSource = null;
@@ -852,11 +819,6 @@ class VideoDetailView : ConstraintLayout {
}
fun updateMoreButtons() {
val isLimitedVersion = video?.url != null && StatePlatform.instance.getContentClientOrNull(video!!.url)?.let {
if (it is JSClient)
return@let it.config.reduceFunctionsInLimitedVersion && BuildConfig.IS_PLAYSTORE_BUILD
else false;
} ?: false;
val buttons = listOf(RoundButton(context, R.drawable.ic_add, context.getString(R.string.add), TAG_ADD) {
(video ?: _searchVideo)?.let {
_slideUpOverlay = UISlideOverlays.showAddToOverlay(it, _overlayContainer) {
@@ -876,44 +838,38 @@ class VideoDetailView : ConstraintLayout {
}
_slideUpOverlay?.hide();
} else null,
if(!isLimitedVersion)
RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.background), TAG_BACKGROUND) {
if(!allowBackground) {
_player.switchToAudioMode();
allowBackground = true;
it.text.text = resources.getString(R.string.background_revert);
}
else {
_player.switchToVideoMode();
allowBackground = false;
it.text.text = resources.getString(R.string.background);
}
_slideUpOverlay?.hide();
RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.background), TAG_BACKGROUND) {
if(!allowBackground) {
_player.switchToAudioMode();
allowBackground = true;
it.text.text = resources.getString(R.string.background_revert);
}
else null,
if(!isLimitedVersion)
RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) {
video?.let {
_slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver);
};
else {
_player.switchToVideoMode();
allowBackground = false;
it.text.text = resources.getString(R.string.background);
}
else null,
RoundButton(context, R.drawable.ic_share, context.getString(R.string.share), TAG_SHARE) {
video?.let {
Logger.i(TAG, "Share preventPictureInPicture = true");
preventPictureInPicture = true;
shareVideo();
};
_slideUpOverlay?.hide();
},
if(!isLimitedVersion)
RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.overlay), TAG_OVERLAY) {
this.startPictureInPicture();
fragment.forcePictureInPicture();
//PiPActivity.startPiP(context);
_slideUpOverlay?.hide();
}
else null,
_slideUpOverlay?.hide();
},
RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) {
video?.let {
_slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver);
};
},
RoundButton(context, R.drawable.ic_share, context.getString(R.string.share), TAG_SHARE) {
video?.let {
Logger.i(TAG, "Share preventPictureInPicture = true");
preventPictureInPicture = true;
shareVideo();
};
_slideUpOverlay?.hide();
},
RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.overlay), TAG_OVERLAY) {
this.startPictureInPicture();
fragment.forcePictureInPicture();
//PiPActivity.startPiP(context);
_slideUpOverlay?.hide();
},
RoundButton(context, R.drawable.ic_export, context.getString(R.string.page), TAG_OPEN) {
video?.let {
val url = video?.shareUrl ?: _searchVideo?.shareUrl ?: _url;
@@ -922,22 +878,6 @@ class VideoDetailView : ConstraintLayout {
};
_slideUpOverlay?.hide();
},
if(StateSync.instance.hasAtLeastOneOnlineDevice()) {
RoundButton(context, R.drawable.ic_device, context.getString(R.string.send_to_device), TAG_SEND_TO_DEVICE) {
val devices = StateSync.instance.getSessions();
val videoToSend = video ?: return@RoundButton;
if(devices.size > 1) {
//not implemented
}
else if(devices.size == 1){
val device = devices.first();
UIDialogs.showConfirmationDialog(context, "Would you like to open\n[${videoToSend.name}]\non ${device.remotePublicKey}" , {
fragment.lifecycleScope.launch(Dispatchers.IO) {
device.sendJsonData(GJSyncOpcodes.sendToDevices, SendToDevicePackage(videoToSend.url, (lastPositionMilliseconds/1000).toInt()));
}
})
}
}} else null,
RoundButton(context, R.drawable.ic_refresh, context.getString(R.string.reload), "Reload") {
reloadVideo();
_slideUpOverlay?.hide();
@@ -947,7 +887,7 @@ class VideoDetailView : ConstraintLayout {
else {
val selectedButtons = _buttonPinStore.getAllValues()
.map { x-> buttons.find { it.tagRef == x } }
.filterNotNull()
.filter { it != null }
.map { it!! };
_buttonPins.setButtons(*(selectedButtons +
buttons.filter { !selectedButtons.contains(it) } +
@@ -1085,8 +1025,6 @@ class VideoDetailView : ConstraintLayout {
StateApp.instance.preventPictureInPicture.remove(this);
StatePlayer.instance.onQueueChanged.remove(this);
StatePlayer.instance.onVideoChanging.remove(this);
StateSync.instance.deviceUpdatedOrAdded.remove(this);
StateSync.instance.deviceRemoved.remove(this);
MediaControlReceiver.onLowerVolumeReceived.remove(this);
MediaControlReceiver.onPlayReceived.remove(this);
MediaControlReceiver.onPauseReceived.remove(this);
@@ -1263,8 +1201,7 @@ class VideoDetailView : ConstraintLayout {
switchContentView(_container_content_main);
}
@OptIn(ExperimentalCoroutinesApi::class)
//@OptIn(ExperimentalCoroutinesApi::class)
fun setVideoDetails(videoDetail: IPlatformVideoDetails, newVideo: Boolean = false) {
Logger.i(TAG, "setVideoDetails (${videoDetail.name})")
_didTriggerDatasourceErrroCount = 0;
@@ -1272,7 +1209,7 @@ class VideoDetailView : ConstraintLayout {
_autoplayVideo = null
Logger.i(TAG, "Autoplay video cleared (setVideoDetails)")
if (newVideo && this.video?.url == videoDetail.url)
if(newVideo && this.video?.url == videoDetail.url)
return;
if (newVideo) {
@@ -1281,13 +1218,8 @@ class VideoDetailView : ConstraintLayout {
_lastSubtitleSource = null;
}
if (videoDetail.datetime != null && videoDetail.datetime!! > OffsetDateTime.now())
UIDialogs.toast(
context,
context.getString(R.string.planned_in) + " ${
videoDetail.datetime?.toHumanNowDiffString(true)
}"
)
if(videoDetail.datetime != null && videoDetail.datetime!! > OffsetDateTime.now())
UIDialogs.toast(context, context.getString(R.string.planned_in) + " ${videoDetail.datetime?.toHumanNowDiffString(true)}")
if (!videoDetail.isLive) {
_player.setPlaybackRate(Settings.instance.playback.getDefaultPlaybackSpeed());
@@ -1296,25 +1228,26 @@ class VideoDetailView : ConstraintLayout {
val videoLocal: VideoLocal?;
val video: IPlatformVideoDetails?;
if (videoDetail is VideoLocal) {
if(videoDetail is VideoLocal) {
videoLocal = videoDetail;
video = videoDetail;
this.video = video;
val videoTask = StatePlatform.instance.getContentDetails(videoDetail.url);
videoTask.invokeOnCompletion { ex ->
if (ex != null) {
if(ex != null) {
Logger.e(TAG, "Failed to fetch live video for offline video", ex);
return@invokeOnCompletion;
}
val result = videoTask.getCompleted();
if (this.video == videoDetail && result is IPlatformVideoDetails) {
if(this.video == videoDetail && result is IPlatformVideoDetails) {
this.video = result;
fragment.lifecycleScope.launch(Dispatchers.Main) {
updateQualitySourcesOverlay(result, videoLocal);
}
}
};
} else { //TODO: Update cached video if it exists with video
}
else { //TODO: Update cached video if it exists with video
videoLocal = StateDownloads.instance.getCachedVideo(videoDetail.id);
video = videoDetail;
}
@@ -1322,16 +1255,7 @@ class VideoDetailView : ConstraintLayout {
this.video = video;
cleanupPlaybackTracker();
if (video.video.videoSources.isNotEmpty()) {
onVideoChanged.emit(
video.video.videoSources[0].width,
video.video.videoSources[0].height
)
} else {
onVideoChanged.emit(0, 0)
}
if (video is JSVideoDetails) {
if(video is JSVideoDetails) {
val me = this;
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
@@ -1339,7 +1263,8 @@ class VideoDetailView : ConstraintLayout {
val chapters = null ?: StatePlatform.instance.getContentChapters(video.url);
_player.setChapters(chapters);
_cast.setChapters(chapters);
} catch (ex: Throwable) {
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to get chapters", ex);
_player.setChapters(null);
_cast.setChapters(null);
@@ -1349,7 +1274,7 @@ class VideoDetailView : ConstraintLayout {
}*/
}
try {
if (!StateApp.instance.privateMode) {
if(!StateApp.instance.privateMode) {
val stopwatch = com.futo.platformplayer.debug.Stopwatch()
var tracker = video.getPlaybackTracker()
Logger.i(TAG, "video.getPlaybackTracker took ${stopwatch.elapsedMs}ms")
@@ -1365,20 +1290,17 @@ class VideoDetailView : ConstraintLayout {
if (me.video == video)
me._playbackTracker = tracker;
} else if (me.video == video)
}
else if(me.video == video)
me._playbackTracker = null;
} catch (ex: Throwable) {
}
catch(ex: Throwable) {
Logger.e(TAG, "Playback tracker failed", ex);
if(me.video?.isLive == true || ex.message?.contains("Unable to resolve host") == true) withContext(Dispatchers.Main) {
if(me.video?.isLive == true) withContext(Dispatchers.Main) {
UIDialogs.toast(context, context.getString(R.string.failed_to_get_playback_tracker));
};
else withContext(Dispatchers.Main) {
UIDialogs.showGeneralErrorDialog(
context,
context.getString(R.string.failed_to_get_playback_tracker),
ex
);
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_get_playback_tracker), ex);
}
}
};
@@ -1395,11 +1317,8 @@ class VideoDetailView : ConstraintLayout {
if (Settings.instance.comments.recommendationsDefault && !Settings.instance.comments.hideRecommendations) {
setTabIndex(2, true)
} else {
when (Settings.instance.comments.defaultCommentSection) {
0 -> if (Settings.instance.other.polycentricEnabled) setTabIndex(
0,
true
) else setTabIndex(1, true);
when(Settings.instance.comments.defaultCommentSection) {
0 -> if(Settings.instance.other.polycentricEnabled) setTabIndex(0, true) else setTabIndex(1, true);
1 -> setTabIndex(1, true);
2 -> setTabIndex(StateMeta.instance.getLastCommentSection(), true)
}
@@ -1409,16 +1328,9 @@ class VideoDetailView : ConstraintLayout {
//UI
_title.text = video.name;
_channelName.text = video.author.name;
if (video.author.subscribers != null) {
_channelMeta.text = if ((video.author.subscribers
?: 0) > 0
) video.author.subscribers!!.toHumanNumber() + " " + context.getString(R.string.subscribers) else "";
(_channelName.layoutParams as MarginLayoutParams).setMargins(
0,
(DP_5 * -1).toInt(),
0,
0
);
if(video.author.subscribers != null) {
_channelMeta.text = if((video.author.subscribers ?: 0) > 0) video.author.subscribers!!.toHumanNumber() + " " + context.getString(R.string.subscribers) else "";
(_channelName.layoutParams as MarginLayoutParams).setMargins(0, (DP_5 * -1).toInt(), 0, 0);
} else {
_channelMeta.text = "";
(_channelName.layoutParams as MarginLayoutParams).setMargins(0, (DP_2).toInt(), 0, 0);
@@ -1426,7 +1338,7 @@ class VideoDetailView : ConstraintLayout {
video.author.let {
if (it is PlatformAuthorMembershipLink && !it.membershipUrl.isNullOrEmpty())
if(it is PlatformAuthorMembershipLink && !it.membershipUrl.isNullOrEmpty())
_monetization.setPlatformMembership(video.id.pluginId, it.membershipUrl);
else
_monetization.setPlatformMembership(null, null);
@@ -1440,8 +1352,7 @@ class VideoDetailView : ConstraintLayout {
_creatorThumbnail.setThumbnail(video.author.thumbnail, false);
val cachedPolycentricProfile =
PolycentricCache.instance.getCachedProfile(video.author.url, true);
val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(video.author.url, true);
if (cachedPolycentricProfile != null) {
setPolycentricProfile(cachedPolycentricProfile, animate = false);
} else {
@@ -1450,19 +1361,13 @@ class VideoDetailView : ConstraintLayout {
}
_platform.setPlatformFromClientID(video.id.pluginId);
val subTitleSegments: ArrayList<String> = ArrayList();
if (video.viewCount > 0)
subTitleSegments.add(
"${video.viewCount.toHumanNumber()} ${
if (video.isLive) context.getString(
R.string.watching_now
) else context.getString(R.string.views)
}"
);
if (video.datetime != null) {
val subTitleSegments : ArrayList<String> = ArrayList();
if(video.viewCount > 0)
subTitleSegments.add("${video.viewCount.toHumanNumber()} ${if(video.isLive) context.getString(R.string.watching_now) else context.getString(R.string.views)}");
if(video.datetime != null) {
val diff = video.datetime?.getNowDiffSeconds() ?: 0;
val ago = video.datetime?.toHumanNowDiffString(true)
if (diff >= 0)
if(diff >= 0)
subTitleSegments.add("${ago} ago");
else
subTitleSegments.add("available in ${ago}");
@@ -1475,27 +1380,20 @@ class VideoDetailView : ConstraintLayout {
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
val queryReferencesResponse = ApiMethods.getQueryReferences(
PolycentricCache.SERVER, ref, null, null,
val queryReferencesResponse = ApiMethods.getQueryReferences(PolycentricCache.SERVER, ref, null,null,
arrayListOf(
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
.setFromType(ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.like.data)
).build(),
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
.setFromType(ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.dislike.data)
).build()
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.like.data)).build(),
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.dislike.data)).build()
),
extraByteReferences = listOfNotNull(extraBytesRef)
);
val likes = queryReferencesResponse.countsList[0];
val dislikes = queryReferencesResponse.countsList[1];
val hasLiked =
StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
val hasDisliked =
StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
withContext(Dispatchers.Main) {
_rating.visibility = View.VISIBLE;
@@ -1519,11 +1417,7 @@ class VideoDetailView : ConstraintLayout {
}
}
StatePolycentric.instance.updateLikeMap(
ref,
args.hasLiked,
args.hasDisliked
)
StatePolycentric.instance.updateLikeMap(ref, args.hasLiked, args.hasDisliked)
};
}
} catch (e: Throwable) {
@@ -1545,7 +1439,6 @@ class VideoDetailView : ConstraintLayout {
_textDislikes.visibility = View.VISIBLE;
_textDislikes.text = r.dislikes.toHumanNumber();
}
is RatingLikes -> {
val r = video.rating as RatingLikes;
_layoutRating.visibility = View.VISIBLE;
@@ -1557,7 +1450,6 @@ class VideoDetailView : ConstraintLayout {
_imageDislikeIcon.visibility = View.GONE;
_textDislikes.visibility = View.GONE;
}
else -> {
_layoutRating.visibility = View.GONE;
}
@@ -1569,7 +1461,6 @@ class VideoDetailView : ConstraintLayout {
setLoading(false);
//Set Mediasource
val toResume = _videoResumePositionMilliseconds;
@@ -1586,22 +1477,9 @@ class VideoDetailView : ConstraintLayout {
val historyItem = getHistoryIndex(videoDetail) ?: return@launch;
withContext(Dispatchers.Main) {
_historicalPosition = StateHistory.instance.updateHistoryPosition(
video,
historyItem,
false,
(toResume.toFloat() / 1000.0f).toLong(),
null,
true
);
Logger.i(
TAG,
"Historical position: $_historicalPosition, last position: $lastPositionMilliseconds"
);
if (_historicalPosition > 60 && video.duration - _historicalPosition > 5 && Math.abs(
_historicalPosition - lastPositionMilliseconds / 1000
) > 5.0
) {
_historicalPosition = StateHistory.instance.updateHistoryPosition(video, historyItem,false, (toResume.toFloat() / 1000.0f).toLong(), null, true);
Logger.i(TAG, "Historical position: $_historicalPosition, last position: $lastPositionMilliseconds");
if (_historicalPosition > 60 && video.duration - _historicalPosition > 5 && Math.abs(_historicalPosition - lastPositionMilliseconds / 1000) > 5.0) {
_layoutResume.visibility = View.VISIBLE;
_textResume.text = "Resume at ${_historicalPosition.toHumanTime(false)}";
@@ -1627,10 +1505,10 @@ class VideoDetailView : ConstraintLayout {
_liveChat?.stop();
_liveChat = null;
if (video.isLive && video.live != null) {
if(video.isLive && video.live != null) {
loadLiveChat(video);
}
if (video.isLive && video.live == null && !video.video.videoSources.any())
if(video.isLive && video.live == null && !video.video.videoSources.any())
startLiveTry(video);
@@ -1764,7 +1642,7 @@ class VideoDetailView : ConstraintLayout {
});
else
_player.setArtwork(null);
_player.setSource(videoSource, audioSource, _playWhenReady, false, resume = resumePositionMs > 0);
_player.setSource(videoSource, audioSource, _playWhenReady, false);
if(subtitleSource != null)
_player.swapSubtitles(fragment.lifecycleScope, subtitleSource);
_player.seekTo(resumePositionMs);
@@ -1799,13 +1677,8 @@ class VideoDetailView : ConstraintLayout {
private fun onSourceChanged(videoSource: IVideoSource?, audioSource: IAudioSource?, resume: Boolean){
Logger.i(TAG, "onSourceChanged(videoSource=$videoSource, audioSource=$audioSource, resume=$resume)")
if((videoSource == null || videoSource is LocalVideoSource) && (audioSource == null || audioSource is LocalAudioSource)) {
Logger.i(TAG, "Time since last offline playback toast: " + (System.currentTimeMillis() - _lastOfflinePlaybackToastTime).toString())
if (System.currentTimeMillis() - _lastOfflinePlaybackToastTime > 5000) {
UIDialogs.toast(context, context.getString(R.string.offline_playback), false);
_lastOfflinePlaybackToastTime = System.currentTimeMillis()
}
}
if((videoSource == null || videoSource is LocalVideoSource) && (audioSource == null || audioSource is LocalAudioSource))
UIDialogs.toast(context, context.getString(R.string.offline_playback), false);
//If LiveStream, set to end
if(videoSource is IDashManifestSource || videoSource is IHLSManifestSource) {
if (video?.isLive == true) {
@@ -1919,8 +1792,6 @@ class VideoDetailView : ConstraintLayout {
fun prevVideo(withoutRemoval: Boolean = false) {
Logger.i(TAG, "prevVideo")
checkAndRemoveWatchLater();
val next = StatePlayer.instance.prevQueueItem(withoutRemoval || _player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9);
if(next != null) {
setVideoOverview(next, true, 0, true);
@@ -1929,8 +1800,6 @@ class VideoDetailView : ConstraintLayout {
fun nextVideo(forceLoop: Boolean = false, withoutRemoval: Boolean = false, bypassVideoLoop: Boolean = false): Boolean {
Logger.i(TAG, "nextVideo")
checkAndRemoveWatchLater();
var next = StatePlayer.instance.nextQueueItem(withoutRemoval || _player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9, bypassVideoLoop);
val autoplayVideo = _autoplayVideo
if (next == null && autoplayVideo != null && StatePlayer.instance.autoplay) {
@@ -1939,8 +1808,7 @@ class VideoDetailView : ConstraintLayout {
next = autoplayVideo
}
_autoplayVideo = null
Logger.i(TAG, "Autoplay video cleared (nextVideo)");
Logger.i(TAG, "Autoplay video cleared (nextVideo)")
if(next == null && forceLoop)
next = StatePlayer.instance.restartQueue();
if(next != null) {
@@ -1952,20 +1820,6 @@ class VideoDetailView : ConstraintLayout {
return false;
}
fun checkAndRemoveWatchLater(){
val watchCurrent = video ?: videoLocal ?: _searchVideo;
if(Settings.instance.playback.deleteFromWatchLaterAuto) {
if(watchCurrent?.duration != null &&
watchCurrent.duration > 0 &&
(lastPositionMilliseconds / 1000) > watchCurrent.duration * 0.7) {
if(!watchCurrent.url.isNullOrEmpty()) {
StatePlaylists.instance.removeFromWatchLater(watchCurrent.url);
}
}
}
}
//Quality Selector data
private fun updateQualityFormatsOverlay(liveStreamVideoFormats : List<Format>?, liveStreamAudioFormats : List<Format>?) {
val v = video ?: return;
@@ -2016,7 +1870,7 @@ class VideoDetailView : ConstraintLayout {
?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource }))
?.distinct()
?.filterNotNull()
?.filter { it != null }
?.toList() ?: listOf() else videoSources?.toList() ?: listOf()
val bestAudioContainer = audioSources?.let { VideoHelper.selectBestAudioSource(it, FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS)?.container };
val bestAudioSources = if(doDedup) audioSources
@@ -2317,7 +2171,7 @@ class VideoDetailView : ConstraintLayout {
cleanupPlaybackTracker();
val url = _url;
if (!url.isNullOrBlank()) {
if (url != null && url.isNotBlank()) {
setLoading(true);
_taskLoadVideo.run(url);
}
@@ -2329,7 +2183,7 @@ class VideoDetailView : ConstraintLayout {
if(fullscreen) {
_layoutPlayerContainer.setPadding(0, 0, 0, 0);
val lp = _container_content.layoutParams as LayoutParams;
val lp = _container_content.layoutParams as ConstraintLayout.LayoutParams;
lp.topMargin = 0;
_container_content.layoutParams = lp;
@@ -2342,7 +2196,7 @@ class VideoDetailView : ConstraintLayout {
else {
_layoutPlayerContainer.setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, Resources.getSystem().displayMetrics).toInt());
val lp = _container_content.layoutParams as LayoutParams;
val lp = _container_content.layoutParams as ConstraintLayout.LayoutParams;
lp.topMargin = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, -18.0f, Resources.getSystem().displayMetrics).toInt();
_container_content.layoutParams = lp;
@@ -2383,20 +2237,9 @@ class VideoDetailView : ConstraintLayout {
}
}
fun isLandscapeVideo(): Boolean? {
val videoSourceWidth = _player.exoPlayer?.player?.videoSize?.width
val videoSourceHeight = _player.exoPlayer?.player?.videoSize?.height
return if (videoSourceWidth == null || videoSourceHeight == null || videoSourceWidth == 0 || videoSourceHeight == 0){
null
} else{
videoSourceWidth >= videoSourceHeight
}
}
fun setFullscreen(fullscreen : Boolean) {
Logger.i(TAG, "setFullscreen(fullscreen=$fullscreen)")
_player.setFullScreen(fullscreen)
_player.setFullScreen(fullscreen);
}
private fun setLoading(isLoading : Boolean) {
if(isLoading){
@@ -2562,7 +2405,7 @@ class VideoDetailView : ConstraintLayout {
onAddToWatchLaterClicked.subscribe(this) {
if(it is IPlatformVideo) {
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true);
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it));
UIDialogs.toast("Added to watch later\n[${it.name}]");
}
}
@@ -2587,7 +2430,7 @@ class VideoDetailView : ConstraintLayout {
_overlayContainer.removeAllViews();
_overlay_quality_selector?.hide();
_player.fillHeight(false)
_player.fillHeight();
_layoutPlayerContainer.setPadding(0, 0, 0, 0);
}
fun handleLeavePictureInPicture() {
@@ -2723,7 +2566,7 @@ class VideoDetailView : ConstraintLayout {
else {
if(_player.layoutParams.height == WRAP_CONTENT) {
_player.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT);
_player.fillHeight(true)
_player.fillHeight();
_cast.layoutParams = _cast.layoutParams.apply {
(this as MarginLayoutParams).bottomMargin = 0;
};
@@ -2796,24 +2639,13 @@ class VideoDetailView : ConstraintLayout {
if(_minimize_controls.isClickable != clickable)
_minimize_controls.isClickable = clickable;
}
override fun onConfigurationChanged(newConfig: Configuration?) {
super.onConfigurationChanged(newConfig)
if (fragment.state == VideoDetailFragment.State.MINIMIZED) {
_player.fillHeight(true)
} else if (!fragment.isFullscreen && !fragment.isInPictureInPicture) {
_player.fitHeight()
}
}
fun setVideoMinimize(value : Float) {
val padRight = (resources.displayMetrics.widthPixels * 0.70 * value).toInt()
_player.setPadding(0, _player.paddingTop, padRight, 0)
_cast.setPadding(0, _cast.paddingTop, padRight, 0)
val padRight = (resources.displayMetrics.widthPixels * 0.70 * value).toInt();
_player.setPadding(0, _player.paddingTop, padRight, 0);
_cast.setPadding(0, _cast.paddingTop, padRight, 0);
}
fun setTopPadding(value: Float) {
_player.setPadding(_player.paddingLeft, value.toInt(), _player.paddingRight, 0)
fun setTopPadding(value : Float) {
_player.setPadding(0, value.toInt(), _player.paddingRight, 0);
}
//Tasks
@@ -2916,15 +2748,13 @@ class VideoDetailView : ConstraintLayout {
.exception<Throwable> {
Logger.w(ChannelFragment.TAG, "Failed to load video.", it);
if(!(it.message?.contains("Unable to resolve host") ?: false && nextVideo())){
handleErrorOrCall {
_retryCount = 0;
_retryJob?.cancel();
_retryJob = null;
_liveTryJob?.cancel();
_liveTryJob = null;
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video), it, ::fetchVideo, null, fragment);
}
handleErrorOrCall {
_retryCount = 0;
_retryJob?.cancel();
_retryJob = null;
_liveTryJob?.cancel();
_liveTryJob = null;
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video), it, ::fetchVideo, null, fragment);
}
} else TaskHandler(IPlatformVideoDetails::class.java, {fragment.lifecycleScope});
@@ -3030,10 +2860,11 @@ class VideoDetailView : ConstraintLayout {
const val TAG_OVERLAY = "overlay";
const val TAG_LIVECHAT = "livechat";
const val TAG_OPEN = "open";
const val TAG_SEND_TO_DEVICE = "send_to_device";
const val TAG_MORE = "MORE";
private val _buttonPinStore = FragmentedStorage.get<StringArrayStorage>("videoPinnedButtons");
private var _lastOfflinePlaybackToastTime: Long = 0
}
}
@@ -96,11 +96,11 @@ class WatchLaterFragment : MainFragment() {
}
override fun onVideoOrderChanged(videos: List<IPlatformVideo>) {
StatePlaylists.instance.updateWatchLater(ArrayList(videos.map { it as SerializedPlatformVideo }), true);
StatePlaylists.instance.updateWatchLater(ArrayList(videos.map { it as SerializedPlatformVideo }));
}
override fun onVideoRemoved(video: IPlatformVideo) {
if (video is SerializedPlatformVideo) {
StatePlaylists.instance.removeFromWatchLater(video, true);
StatePlaylists.instance.removeFromWatchLater(video);
}
}
@@ -26,7 +26,6 @@ import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.SearchHistoryStorage
class SearchTopBarFragment : TopFragment() {
@Suppress("PrivatePropertyName")
private val TAG = "SearchTopBarFragment"
private var _editSearch: EditText? = null;
@@ -192,32 +191,29 @@ class SearchTopBarFragment : TopFragment() {
}
private fun onDone() {
val editSearch = _editSearch
val editSearch = _editSearch;
if (editSearch != null) {
val text = editSearch.text.toString()
if (text.isEmpty()) {
UIDialogs.toast(getString(R.string.please_use_at_least_1_character))
return
val text = editSearch.text.toString();
if (text.length < 3) {
UIDialogs.toast(getString(R.string.please_use_at_least_3_characters));
return;
}
editSearch.clearFocus()
_inputMethodManager?.hideSoftInputFromWindow(editSearch.windowToken, 0)
editSearch.clearFocus();
_inputMethodManager?.hideSoftInputFromWindow(editSearch.windowToken, 0);
if (Settings.instance.search.searchHistory) {
val storage = FragmentedStorage.get<SearchHistoryStorage>()
storage.add(text)
val storage = FragmentedStorage.get<SearchHistoryStorage>();
storage.add(text);
}
if (_searchType == SearchType.CREATOR) {
onSearch.emit(text)
onSearch.emit(text);
} else {
onSearch.emit(text)
onSearch.emit(text);
}
} else {
Logger.w(
TAG,
"Unexpected condition happened where done is edit search is null but done is triggered."
)
Logger.w(TAG, "Unexpected condition happened where done is edit search is null but done is triggered.");
}
}
@@ -191,21 +191,21 @@ class VideoHelper {
}
fun estimateSourceSize(source: IVideoSource?): Long {
fun estimateSourceSize(source: IVideoSource?): Int {
if(source == null) return 0;
if(source is IVideoSource) {
if(source.bitrate ?: 0 <= 0 || source.duration.toInt() == 0)
return 0;
return (source.duration / 8) * source.bitrate!!;
return (source.duration / 8).toInt() * source.bitrate!!;
}
else return 0;
}
fun estimateSourceSize(source: IAudioSource?): Long {
fun estimateSourceSize(source: IAudioSource?): Int {
if(source == null) return 0;
if(source is IAudioSource) {
if(source.bitrate <= 0 || source.duration?.toInt() ?: 0 == 0)
return 0;
return (source.duration!! / 8) * source.bitrate;
return (source.duration!! / 8).toInt() * source.bitrate;
}
else return 0;
}
@@ -0,0 +1,42 @@
package com.futo.platformplayer.listeners
import android.content.Context
import android.database.ContentObserver
import android.os.Handler
import android.provider.Settings
class AutoRotateObserver(handler: Handler, private val onChangeCallback: () -> Unit) : ContentObserver(handler) {
override fun onChange(selfChange: Boolean) {
super.onChange(selfChange)
onChangeCallback()
}
}
class AutoRotateChangeListener(context: Context, handler: Handler, private val onAutoRotateChanged: (Boolean) -> Unit) {
private val contentResolver = context.contentResolver
private val autoRotateObserver = AutoRotateObserver(handler) {
val isAutoRotateEnabled = isAutoRotateEnabled()
onAutoRotateChanged(isAutoRotateEnabled)
}
init {
contentResolver.registerContentObserver(
Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION),
false,
autoRotateObserver
)
}
fun unregister() {
contentResolver.unregisterContentObserver(autoRotateObserver)
}
private fun isAutoRotateEnabled(): Boolean {
return Settings.System.getInt(
contentResolver,
Settings.System.ACCELEROMETER_ROTATION,
0
) == 1
}
}
@@ -46,10 +46,7 @@ class MDNSListener {
}
fun start() {
if (_started) {
Logger.i(TAG, "Already started.")
return
}
if (_started) throw Exception("Already running.")
_started = true
_scope = CoroutineScope(Dispatchers.IO);
@@ -37,10 +37,7 @@ class ServiceDiscoverer(names: Array<String>, private val _onServicesUpdated: (L
}
fun start() {
if (_started) {
Logger.i(TAG, "Already started.")
return
}
if (_started) throw Exception("Already running.")
_started = true
val listener = MDNSListener()
@@ -55,25 +55,21 @@ class ServiceRecordAggregator {
if (_cts != null) throw Exception("Already started.")
_cts = CoroutineScope(Dispatchers.Default).launch {
try {
while (isActive) {
val now = Date()
synchronized(_currentServices) {
_cachedAddressRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } }
_cachedTxtRecords.entries.removeIf { now.after(it.value.expirationTime) }
_cachedSrvRecords.entries.removeIf { now.after(it.value.expirationTime) }
_cachedPtrRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } }
while (isActive) {
val now = Date()
synchronized(_currentServices) {
_cachedAddressRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } }
_cachedTxtRecords.entries.removeIf { now.after(it.value.expirationTime) }
_cachedSrvRecords.entries.removeIf { now.after(it.value.expirationTime) }
_cachedPtrRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } }
val newServices = getCurrentServices()
_currentServices.clear()
_currentServices.addAll(newServices)
}
onServicesUpdated?.invoke(_currentServices.toList())
delay(5000)
val newServices = getCurrentServices()
_currentServices.clear()
_currentServices.addAll(newServices)
}
} catch (e: Throwable) {
Logger.e(TAG, "Unexpected failure in MDNS loop", e)
onServicesUpdated?.invoke(_currentServices.toList())
delay(5000)
}
}
}
@@ -87,7 +83,6 @@ class ServiceRecordAggregator {
}
fun add(packet: DnsPacket) {
val currentServices: List<DnsService>
val dnsResourceRecords = packet.answers + packet.additionals + packet.authorities
val txtRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.TXT.value.toInt() }.map { it to it.getDataReader().readTXTRecord() }
val aRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.A.value.toInt() }.map { it to it.getDataReader().readARecord() }
@@ -104,6 +99,7 @@ class ServiceRecordAggregator {
aaaaRecords.forEach { builder.appendLine("AAAA ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.address}") }
Logger.i(TAG, "$builder")*/
val currentServices: MutableList<DnsService>
synchronized(this._currentServices) {
ptrRecords.forEach { record ->
val cachedPtrRecord = _cachedPtrRecords.getOrPut(record.first.name) { mutableListOf() }
@@ -1,7 +1,5 @@
package com.futo.platformplayer.models
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import java.time.OffsetDateTime
import java.util.UUID
@kotlinx.serialization.Serializable
@@ -12,11 +10,6 @@ open class SubscriptionGroup {
var urls: MutableList<String> = mutableListOf();
var priority: Int = 99;
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
var lastChange : OffsetDateTime = OffsetDateTime.MIN;
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
var creationTime : OffsetDateTime = OffsetDateTime.now();
constructor(name: String) {
this.name = name;
}
@@ -26,8 +19,6 @@ open class SubscriptionGroup {
this.image = parent.image;
this.urls = parent.urls;
this.priority = parent.priority;
this.lastChange = parent.lastChange;
this.creationTime = parent.creationTime;
}
class Selectable(parent: SubscriptionGroup, isSelected: Boolean = false): SubscriptionGroup(parent) {
@@ -14,111 +14,65 @@ import com.futo.platformplayer.receivers.MediaControlReceiver
import com.futo.platformplayer.timestampRegex
import kotlinx.coroutines.runBlocking
class PlatformLinkMovementMethod(private val _context: Context) : LinkMovementMethod() {
class PlatformLinkMovementMethod : LinkMovementMethod {
private val _context: Context;
private var pressedLinks: Array<URLSpan>? = null
private var linkPressed = false
private var downX = 0f
private var downY = 0f
private val touchSlop = 20
constructor(context: Context) : super() {
_context = context;
}
override fun onTouchEvent(widget: TextView, buffer: Spannable, event: MotionEvent): Boolean {
val action = event.actionMasked
val action = event.action;
if (action == MotionEvent.ACTION_UP) {
val x = event.x.toInt() - widget.totalPaddingLeft + widget.scrollX;
val y = event.y.toInt() - widget.totalPaddingTop + widget.scrollY;
when (action) {
MotionEvent.ACTION_DOWN -> {
val links = findLinksAtTouchPosition(widget, buffer, event)
if (links.isNotEmpty()) {
pressedLinks = links
linkPressed = true
downX = event.x
downY = event.y
widget.parent?.requestDisallowInterceptTouchEvent(true)
return true
} else {
linkPressed = false
pressedLinks = null
}
}
val layout = widget.layout;
val line = layout.getLineForVertical(y);
val off = layout.getOffsetForHorizontal(line, x.toFloat());
val links = buffer.getSpans(off, off, URLSpan::class.java);
MotionEvent.ACTION_MOVE -> {
if (linkPressed) {
val dx = event.x - downX
val dy = event.y - downY
if (Math.abs(dx) > touchSlop || Math.abs(dy) > touchSlop) {
linkPressed = false
pressedLinks = null
widget.parent?.requestDisallowInterceptTouchEvent(false)
return false
}
return true
}
}
if (links.isNotEmpty()) {
runBlocking {
for (link in links) {
Logger.i(TAG) { "Link clicked '${link.url}'." };
MotionEvent.ACTION_UP -> {
if (linkPressed && pressedLinks != null) {
val dx = event.x - downX
val dy = event.y - downY
if (Math.abs(dx) <= touchSlop && Math.abs(dy) <= touchSlop && isTouchInside(widget, event)) {
runBlocking {
for (link in pressedLinks!!) {
Logger.i(TAG) { "Link clicked '${link.url}'." }
if (_context is MainActivity) {
if (_context.handleUrl(link.url)) {
continue;
}
if (_context is MainActivity) {
if (_context.handleUrl(link.url)) continue
if (timestampRegex.matches(link.url)) {
val tokens = link.url.split(':')
var time_s = -1L
when (tokens.size) {
2 -> time_s = tokens[0].toLong() * 60 + tokens[1].toLong()
3 -> time_s = tokens[0].toLong() * 3600 +
tokens[1].toLong() * 60 +
tokens[2].toLong()
}
if (timestampRegex.matches(link.url)) {
val tokens = link.url.split(':');
if (time_s != -1L) {
MediaControlReceiver.onSeekToReceived.emit(time_s * 1000)
continue
}
}
var time_s = -1L;
if (tokens.size == 2) {
time_s = tokens[0].toLong() * 60 + tokens[1].toLong();
} else if (tokens.size == 3) {
time_s =
tokens[0].toLong() * 60 * 60 + tokens[1].toLong() * 60 + tokens[2].toLong();
}
if (time_s != -1L) {
MediaControlReceiver.onSeekToReceived.emit(time_s * 1000);
continue;
}
_context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
}
}
pressedLinks = null
linkPressed = false
return true
} else {
pressedLinks = null
linkPressed = false
_context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)));
}
}
}
MotionEvent.ACTION_CANCEL -> {
linkPressed = false
pressedLinks = null
return true;
}
}
return false
}
private fun findLinksAtTouchPosition(widget: TextView, buffer: Spannable, event: MotionEvent): Array<URLSpan> {
val x = (event.x - widget.totalPaddingLeft + widget.scrollX).toInt()
val y = (event.y - widget.totalPaddingTop + widget.scrollY).toInt()
val layout = widget.layout ?: return emptyArray()
val line = layout.getLineForVertical(y)
val off = layout.getOffsetForHorizontal(line, x.toFloat())
return buffer.getSpans(off, off, URLSpan::class.java)
}
private fun isTouchInside(widget: TextView, event: MotionEvent): Boolean {
return event.x >= 0 && event.x <= widget.width && event.y >= 0 && event.y <= widget.height
return super.onTouchEvent(widget, buffer, event);
}
companion object {
const val TAG = "PlatformLinkMovementMethod"
val TAG = "PlatformLinkMovementMethod";
}
}
}
@@ -182,14 +182,13 @@ class HLS {
private fun parseAttributes(content: String): Map<String, String> {
val attributes = mutableMapOf<String, String>()
val maybeAttributePairs = content.substringAfter(":").splitToSequence(',')
val attributePairs = content.substringAfter(":").splitToSequence(',')
var currentPair = StringBuilder()
for (pair in maybeAttributePairs) {
for (pair in attributePairs) {
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("=")
val (key, value) = currentPair.toString().split('=')
attributes[key.trim()] = value.trim().removeSurrounding("\"")
currentPair = StringBuilder() // Reset for the next attribute
} else {
@@ -23,6 +23,7 @@ import com.futo.polycentric.core.getClaimIfValid
import com.futo.polycentric.core.getValidClaims
import com.google.protobuf.ByteString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@@ -56,83 +57,102 @@ class PolycentricCache {
private val _taskGetProfile = BatchedTaskHandler<PublicKey, CachedPolycentricProfile>(_scope,
{ system ->
val signedEventsList = ApiMethods.getQueryLatest(
SERVER,
system.toProto(),
listOf(
ContentType.BANNER.value,
ContentType.AVATAR.value,
ContentType.USERNAME.value,
ContentType.DESCRIPTION.value,
ContentType.STORE.value,
ContentType.SERVER.value,
ContentType.STORE_DATA.value,
ContentType.PROMOTION_BANNER.value,
ContentType.PROMOTION.value,
ContentType.MEMBERSHIP_URLS.value,
ContentType.DONATION_DESTINATIONS.value
)
).eventsList.map { e -> SignedEvent.fromProto(e) };
val signedProfileEvents = signedEventsList.groupBy { e -> e.event.contentType }
.map { (_, events) -> events.maxBy { it.event.unixMilliseconds ?: 0 } };
val storageSystemState = StorageTypeSystemState.create()
for (signedEvent in signedProfileEvents) {
storageSystemState.update(signedEvent.event)
}
val signedClaimEvents = ApiMethods.getQueryIndex(
SERVER,
system.toProto(),
ContentType.CLAIM.value,
limit = 200
).eventsList.map { e -> SignedEvent.fromProto(e) };
val ownedClaims: ArrayList<OwnedClaim> = arrayListOf()
for (signedEvent in signedClaimEvents) {
if (signedEvent.event.contentType != ContentType.CLAIM.value) {
continue;
coroutineScope {
// Launch concurrent requests for getQueryLatest and getQueryIndex
val signedEventsDeferred = async {
ApiMethods.getQueryLatest(
SERVER,
system.toProto(),
listOf(
ContentType.BANNER.value,
ContentType.AVATAR.value,
ContentType.USERNAME.value,
ContentType.DESCRIPTION.value,
ContentType.STORE.value,
ContentType.SERVER.value,
ContentType.STORE_DATA.value,
ContentType.PROMOTION_BANNER.value,
ContentType.PROMOTION.value,
ContentType.MEMBERSHIP_URLS.value,
ContentType.DONATION_DESTINATIONS.value
)
).eventsList.map { e -> SignedEvent.fromProto(e) }
}
val response = ApiMethods.getQueryReferences(
SERVER,
Protocol.Reference.newBuilder()
.setReference(signedEvent.toPointer().toProto().toByteString())
.setReferenceType(2)
.build(),
null,
Protocol.QueryReferencesRequestEvents.newBuilder()
.setFromType(ContentType.VOUCH.value)
.build()
);
val ownedClaim = response.itemsList.map { SignedEvent.fromProto(it.event) }.getClaimIfValid(signedEvent);
if (ownedClaim != null) {
ownedClaims.add(ownedClaim);
val signedClaimEventsDeferred = async {
ApiMethods.getQueryIndex(
SERVER,
system.toProto(),
ContentType.CLAIM.value,
limit = 200
).eventsList.map { e -> SignedEvent.fromProto(e) }
}
}
Logger.i(TAG, "Retrieved profile (ownedClaims = $ownedClaims)");
val systemState = SystemState.fromStorageTypeSystemState(storageSystemState);
return@BatchedTaskHandler CachedPolycentricProfile(PolycentricProfile(system, systemState, ownedClaims));
// Await both requests concurrently
val signedEventsList = signedEventsDeferred.await()
val signedClaimEvents = signedClaimEventsDeferred.await()
// Process profile events
val signedProfileEvents = signedEventsList
.groupBy { it.event.contentType }
.map { (_, events) -> events.maxByOrNull { it.event.unixMilliseconds ?: 0 } }
.filterNotNull()
val storageSystemState = StorageTypeSystemState.create().apply {
signedProfileEvents.forEach { update(it.event) }
}
// Launch concurrent requests for getQueryReferences
val ownedClaimsDeferred = signedClaimEvents.mapNotNull { signedEvent ->
if (signedEvent.event.contentType != ContentType.CLAIM.value) {
null
} else {
async {
try {
val response = ApiMethods.getQueryReferences(
SERVER,
Protocol.Reference.newBuilder()
.setReference(signedEvent.toPointer().toProto().toByteString())
.setReferenceType(2)
.build(),
null,
Protocol.QueryReferencesRequestEvents.newBuilder()
.setFromType(ContentType.VOUCH.value)
.build()
)
response.itemsList
.map { SignedEvent.fromProto(it.event) }
.getClaimIfValid(signedEvent)
} catch (e: Exception) {
Logger.e(TAG, "Failed to get query references for ${signedEvent.toPointer()}", e)
null
}
}
}
}.filterNotNull()
val ownedClaims = ownedClaimsDeferred.mapNotNull { it.await() }.toCollection(ArrayList())
Logger.i(TAG, "Retrieved profile (ownedClaims = $ownedClaims)")
val systemState = SystemState.fromStorageTypeSystemState(storageSystemState)
CachedPolycentricProfile(PolycentricProfile(system, systemState, ownedClaims))
}
},
{ system -> return@BatchedTaskHandler getCachedProfile(system); },
{ system, result ->
synchronized(_cache) {
_profileCache[system] = result;
_profileCache[system] = result
if (result.profile != null) {
for (claim in result.profile.ownedClaims) {
val urls = claim.claim.resolveChannelUrls();
for (url in urls)
_profileUrlCache.map[url] = result;
result.profile?.ownedClaims?.forEach { claim ->
claim.claim.resolveChannelUrls().forEach { url ->
_profileUrlCache.map[url] = result
}
}
_profileUrlCache.save();
_profileUrlCache.save()
}
});
})
private val _batchTaskGetClaims = BatchedTaskHandler<PlatformID, CachedOwnedClaims>(_scope,
{ id ->
@@ -1,35 +0,0 @@
package com.futo.platformplayer.receivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.view.KeyEvent
import com.futo.platformplayer.logging.Logger
class MediaButtonReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val keyEvent: KeyEvent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent?.getParcelableExtra(Intent.EXTRA_KEY_EVENT, KeyEvent::class.java)
} else {
@Suppress("DEPRECATION")
(intent?.getParcelableExtra(Intent.EXTRA_KEY_EVENT))
}
Logger.i(TAG, "Received media button intent, keyCode: " + keyEvent?.keyCode)
if (keyEvent != null && keyEvent.action == KeyEvent.ACTION_DOWN) {
when (keyEvent.keyCode) {
KeyEvent.KEYCODE_MEDIA_PLAY -> MediaControlReceiver.onPlayReceived.emit()
KeyEvent.KEYCODE_MEDIA_PAUSE -> MediaControlReceiver.onPauseReceived.emit()
KeyEvent.KEYCODE_MEDIA_NEXT -> MediaControlReceiver.onNextReceived.emit()
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> MediaControlReceiver.onPreviousReceived.emit()
KeyEvent.KEYCODE_MEDIA_STOP -> MediaControlReceiver.onCloseReceived.emit()
}
}
}
companion object {
private val TAG = "MediaButtonReceiver"
}
}
@@ -23,7 +23,6 @@ import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log
import android.view.KeyEvent
import androidx.core.app.NotificationCompat
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
@@ -33,7 +32,6 @@ import com.futo.platformplayer.Settings
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.receivers.MediaButtonReceiver
import com.futo.platformplayer.receivers.MediaControlReceiver
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer
@@ -93,7 +91,6 @@ class MediaPlaybackService : Service() {
return START_STICKY;
}
fun setupNotificationRequirements() {
_audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager;
_notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
@@ -104,7 +101,6 @@ class MediaPlaybackService : Service() {
_notificationManager!!.createNotificationChannel(_notificationChannel!!);
_mediaSession = MediaSessionCompat(this, "PlayerState");
_mediaSession?.isActive = true
_mediaSession?.setPlaybackState(PlaybackStateCompat.Builder()
.setState(PlaybackStateCompat.STATE_PLAYING, 0, 1f)
.build());
@@ -147,12 +143,6 @@ class MediaPlaybackService : Service() {
MediaControlReceiver.onNextReceived.emit();
}
});
_mediaSession?.setMediaButtonReceiver(PendingIntent.getBroadcast(
this@MediaPlaybackService,
0,
Intent(Intent.ACTION_MEDIA_BUTTON).setClass(this@MediaPlaybackService, MediaButtonReceiver::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
))
}
override fun onCreate() {
@@ -2,8 +2,6 @@ package com.futo.platformplayer.states
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
@@ -22,13 +20,9 @@ import androidx.lifecycle.lifecycleScope
import androidx.work.*
import com.futo.platformplayer.*
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs.Action
import com.futo.platformplayer.UIDialogs.ActionStyle
import com.futo.platformplayer.UIDialogs.Companion.showDialog
import com.futo.platformplayer.activities.CaptchaActivity
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.background.BackgroundWorker
@@ -47,7 +41,6 @@ import com.futo.platformplayer.services.DownloadService
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.views.ToastView
import com.futo.polycentric.core.ApiMethods
import kotlinx.coroutines.*
import java.io.File
import java.util.*
@@ -157,9 +150,12 @@ class StateApp {
//Files
private var _tempDirectory: File? = null;
private var _cacheDirectory: File? = null;
private var _persistentDirectory: File? = null;
//AutoRotate
var systemAutoRotate: Boolean = false;
//Network
private var _lastMeteredState: Boolean = false;
private var _connectivityManager: ConnectivityManager? = null;
@@ -197,6 +193,17 @@ class StateApp {
return File(_persistentDirectory, name);
}
fun getCurrentSystemAutoRotate(): Boolean {
_context?.let {
systemAutoRotate = android.provider.Settings.System.getInt(
it.contentResolver,
android.provider.Settings.System.ACCELEROMETER_ROTATION, 0
) == 1;
};
return systemAutoRotate;
}
fun isCurrentMetered(): Boolean {
ensureConnectivityManager();
return _connectivityManager?.isActiveNetworkMetered ?: throw IllegalStateException("Connectivity manager not available");
@@ -297,6 +304,9 @@ class StateApp {
fun setGlobalContext(context: Context, coroutineScope: CoroutineScope? = null) {
_context = context;
_scope = coroutineScope
//System checks
systemAutoRotate = getCurrentSystemAutoRotate();
}
fun initializeFiles(force: Boolean = false) {
@@ -308,9 +318,6 @@ class StateApp {
_tempDirectory?.deleteRecursively();
}
_tempDirectory?.mkdirs();
_cacheDirectory = File(context.filesDir, "cache");
if(_cacheDirectory?.exists() == false)
_cacheDirectory?.mkdirs();
_persistentDirectory = File(context.filesDir, "persist");
if(_persistentDirectory?.exists() == false) {
_persistentDirectory?.mkdirs();
@@ -370,11 +377,6 @@ class StateApp {
Logger.i(TAG, "MainApp Starting");
initializeFiles(true);
if(Settings.instance.other.polycentricLocalCache) {
Logger.i(TAG, "Initialize Polycentric Disk Cache")
_cacheDirectory?.let { ApiMethods.initCache(it) };
}
val logFile = File(context.filesDir, "log.txt");
if (Settings.instance.logging.logLevel > LogLevel.NONE.value) {
val fileLogConsumer = FileLogConsumer(logFile, LogLevel.fromInt(Settings.instance.logging.logLevel), false);
@@ -417,17 +419,8 @@ class StateApp {
Logger.onLogSubmitted.subscribe {
scopeOrNull?.launch(Dispatchers.Main) {
try {
if (!it.isNullOrEmpty()) {
(SettingsActivity.getActivity() ?: contextOrNull)?.let { c ->
val okButtonAction = Action(c.getString(R.string.ok), {}, ActionStyle.PRIMARY)
val copyButtonAction = Action(c.getString(R.string.copy), {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Log id", it)
clipboard.setPrimaryClip(clip)
}, ActionStyle.NONE)
showDialog(c, R.drawable.ic_error, "Uploaded $it", null, null, 0, copyButtonAction, okButtonAction)
}
if (it != null) {
UIDialogs.toast("Uploaded $it", true);
} else {
UIDialogs.toast("Failed to upload");
}
@@ -11,7 +11,6 @@ import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.copyTo
import com.futo.platformplayer.encryption.GPasswordEncryptionProvider
@@ -19,9 +18,7 @@ import com.futo.platformplayer.encryption.GPasswordEncryptionProviderV0
import com.futo.platformplayer.fragment.mainactivity.main.ImportSubscriptionsFragment
import com.futo.platformplayer.getNowDiffHours
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.HistoryVideo
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.readBytes
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.v2.ManagedStore
@@ -64,9 +61,9 @@ class StateBackup {
StatePlaylists.instance.toMigrateCheck()
).flatten();
fun getCache(additionalVideos: List<SerializedPlatformVideo> = listOf()): ImportCache {
fun getCache(): ImportCache {
val allPlaylists = StatePlaylists.instance.getPlaylists();
val videos = allPlaylists.flatMap { it.videos }.plus(additionalVideos).distinctBy { it.url };
val videos = allPlaylists.flatMap { it.videos }.distinctBy { it.url };
val allSubscriptions = StateSubscriptions.instance.getSubscriptions();
val channels = allSubscriptions.map { it.channel };
@@ -243,23 +240,6 @@ class StateBackup {
.associateBy { it.name }
.mapValues { it.value.getAllReconstructionStrings() }
.toMutableMap();
var historyVideos: List<SerializedPlatformVideo>? = null;
try {
storesToSave.set("subscription_groups", StateSubscriptionGroups.instance.getSubscriptionGroups().map { Json.encodeToString(it) });
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to serialize subscription groups");
}
try {
val history = StateHistory.instance.getRecentHistory(OffsetDateTime.MIN, 2000);
historyVideos = history.map { it.video };
storesToSave.set("history", history.map { it.toReconString() });
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to serialize history");
}
val settings = Settings.instance.encode();
val pluginSettings = StatePlugins.instance.getPlugins()
.associateBy { it.config.id }
@@ -269,7 +249,7 @@ class StateBackup {
.associateBy { it.config.id }
.mapValues { it.value.config.sourceUrl!! };
val cache = getCache(historyVideos ?: listOf());
val cache = getCache();
val export = ExportStructure(exportInfo, settings, storesToSave, pluginUrls, pluginSettings, cache);
@@ -353,64 +333,19 @@ class StateBackup {
if(doImportStores) {
for(store in export.stores) {
Logger.i(TAG, "Importing store [${store.key}]");
if(store.key == "history") {
withContext(Dispatchers.Main) {
UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import History", "Would you like to import history?", null, 0,
UIDialogs.Action("No", {
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Yes", {
for(historyStr in store.value) {
try {
val histObj = HistoryVideo.fromReconString(historyStr) { url ->
return@fromReconString export.cache?.videos?.firstOrNull { it.url == url };
}
val hist = StateHistory.instance.getHistoryByVideo(histObj.video, true, histObj.date);
if(hist != null)
StateHistory.instance.updateHistoryPosition(histObj.video, hist, true, histObj.position, histObj.date, false);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to import subscription group", ex);
}
}
}, UIDialogs.ActionStyle.PRIMARY))
}
val relevantStore = availableStores.find { it.name == store.key };
if(relevantStore == null) {
Logger.w(TAG, "Unknown store [${store.key}] import");
continue;
}
else if(store.key == "subscription_groups") {
withContext(Dispatchers.Main) {
UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import Subscription Groups", "Would you like to import subscription groups?\nExisting groups with the same id will be overridden!", null, 0,
UIDialogs.Action("No", {
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Yes", {
for(groupStr in store.value) {
try {
val group = Json.decodeFromString<SubscriptionGroup>(groupStr);
val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id);
if(existing != null)
StateSubscriptionGroups.instance.deleteSubscriptionGroup(existing.id, false);
StateSubscriptionGroups.instance.updateSubscriptionGroup(group);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to import subscription group", ex);
}
}
}, UIDialogs.ActionStyle.PRIMARY))
}
}
else {
val relevantStore = availableStores.find { it.name == store.key };
if (relevantStore == null) {
Logger.w(TAG, "Unknown store [${store.key}] import");
continue;
}
withContext(Dispatchers.Main) {
UIDialogs.showImportDialog(context, relevantStore, store.key, store.value, export.cache) {
synchronized(toAwait) {
toAwait.remove(store.key);
if(toAwait.isEmpty())
onConclusion();
}
};
}
withContext(Dispatchers.Main) {
UIDialogs.showImportDialog(context, relevantStore, store.key, store.value, export.cache) {
synchronized(toAwait) {
toAwait.remove(store.key);
if(toAwait.isEmpty())
onConclusion();
}
};
}
}
}
@@ -251,7 +251,7 @@ class StateDownloads {
}
else {
Logger.i(TAG, "New watchlater video ${item.name}");
download(VideoDownload(item, playlistDownload.targetPxCount, playlistDownload.targetBitrate, true)
download(VideoDownload(item, playlistDownload.targetPxCount, playlistDownload.targetBitrate)
.withGroup(VideoDownload.GROUP_WATCHLATER, VideoDownload.GROUP_WATCHLATER), false);
hasNew = true;
}
@@ -296,7 +296,7 @@ class StateDownloads {
}
else {
Logger.i(TAG, "New playlist video ${item.name}");
download(VideoDownload(item, playlistDownload.targetPxCount, playlistDownload.targetBitrate, true)
download(VideoDownload(item, playlistDownload.targetPxCount, playlistDownload.targetBitrate)
.withGroup(VideoDownload.GROUP_PLAYLIST, playlist.id), false);
hasNew = true;
}
@@ -59,7 +59,7 @@ class StateHistory {
return getHistoryPosition(url) > duration * 0.7;
}
private var _lastHistoryBroadcast = "";
fun updateHistoryPosition(liveObj: IPlatformVideo, index: DBHistory.Index, updateExisting: Boolean, position: Long = -1L, date: OffsetDateTime? = null, isUserAction: Boolean = false): Long {
val pos = if(position < 0) 0 else position;
val historyVideo = index.obj;
@@ -83,21 +83,19 @@ class StateHistory {
historyVideo.date = date ?: OffsetDateTime.now();
_historyDBStore.update(index.id!!, historyVideo);
onHistoricVideoChanged.emit(liveObj, pos);
}
val historyBroadcastSig = "${historyVideo.position}${historyVideo.video.id.value ?: historyVideo.video.url}"
if(isUserAction && _lastHistoryBroadcast != historyBroadcastSig) {
_lastHistoryBroadcast = historyBroadcastSig;
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
if(StateSync.instance.hasAtLeastOneOnlineDevice()) {
Logger.i(TAG, "SyncHistory playback broadcasted (${liveObj.name}: ${position})");
StateSync.instance.broadcastJsonData(
GJSyncOpcodes.syncHistory,
listOf(historyVideo)
);
}
};
}
if(isUserAction) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
if(StateSync.instance.hasAtLeastOneOnlineDevice()) {
Logger.i(TAG, "SyncHistory playback broadcasted (${liveObj.name}: ${position})");
StateSync.instance.broadcastJson(
GJSyncOpcodes.syncHistory,
listOf(historyVideo)
);
}
};
}
return positionBefore;
}
@@ -537,7 +537,7 @@ class StatePlatform {
else getSortedEnabledClient().filter { if (it is JSClient) it.enableInSearch else true };
clients.parallelStream().forEach {
val searchCapabilities = it.getSearchChannelContentsCapabilities();
val searchCapabilities = it.getSearchCapabilities();
val mappedFilters = filters.map { pair -> Pair(pair.key, pair.value.map { v -> searchCapabilities.filters.first { g -> g.idOrName == pair.key }.filters.first { f -> f.idOrName == v }.value }) }.toMap();
if (it.isChannelUrl(channelUrl)) {
@@ -3,7 +3,6 @@ package com.futo.platformplayer.states
import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
@@ -18,27 +17,14 @@ import com.futo.platformplayer.exceptions.ReconstructionException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.smartMerge
import com.futo.platformplayer.states.StateSubscriptionGroups.Companion
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.platformplayer.stores.StringDateMapStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.stores.v2.ReconstructStore
import com.futo.platformplayer.sync.internal.GJSyncOpcodes
import com.futo.platformplayer.sync.models.SyncPlaylistsPackage
import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage
import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage
import com.futo.platformplayer.sync.models.SyncWatchLaterPackage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneOffset
/***
* Used to maintain playlists
@@ -56,52 +42,14 @@ class StatePlaylists {
.load();
private val _watchlistOrderStore = FragmentedStorage.get<StringArrayStorage>("watchListOrder"); //Temporary workaround to add order..
private val _watchLaterReorderTime = FragmentedStorage.get<StringStorage>("watchLaterReorderTime");
private val _watchLaterAdds = FragmentedStorage.get<StringDateMapStorage>("watchLaterAdds");
private val _watchLaterRemovals = FragmentedStorage.get<StringDateMapStorage>("watchLaterRemovals");
val playlistStore = FragmentedStorage.storeJson<Playlist>("playlists")
.withRestore(PlaylistBackup())
.load();
private val _playlistRemoved = FragmentedStorage.get<StringDateMapStorage>("playlist_removed");
val playlistShareDir = FragmentedStorage.getOrCreateDirectory("shares");
val onWatchLaterChanged = Event0();
fun getWatchLaterAddTime(url: String): OffsetDateTime? {
return _watchLaterAdds.get(url)
}
fun setWatchLaterAddTime(url: String, time: OffsetDateTime) {
_watchLaterAdds.setAndSave(url, time);
}
fun getWatchLaterRemovalTime(url: String): OffsetDateTime? {
return _watchLaterRemovals.get(url);
}
fun getWatchLaterLastReorderTime(): OffsetDateTime{
val value = _watchLaterReorderTime.value;
if(value.isEmpty())
return OffsetDateTime.MIN;
val tryParse = value.toLongOrNull() ?: 0;
return OffsetDateTime.ofInstant(Instant.ofEpochSecond(tryParse), ZoneOffset.UTC);
}
private fun setWatchLaterReorderTime() {
val now = OffsetDateTime.now(ZoneOffset.UTC);
val nowEpoch = now.toEpochSecond();
_watchLaterReorderTime.setAndSave(nowEpoch.toString());
}
fun getWatchLaterOrdering() = _watchlistOrderStore.getAllValues().toList();
fun updateWatchLaterOrdering(order: List<String>, notify: Boolean = false) {
_watchlistOrderStore.set(*smartMerge(order, getWatchLaterOrdering()).toTypedArray());
_watchlistOrderStore.save();
if(notify) {
onWatchLaterChanged.emit();
}
}
fun toMigrateCheck(): List<ManagedStore<*>> {
return listOf(playlistStore, _watchlistStore);
}
@@ -111,14 +59,12 @@ class StatePlaylists {
return _watchlistStore.getItems().sortedBy { order.indexOf(it.url) };
}
}
fun updateWatchLater(updated: List<SerializedPlatformVideo>, isUserInteraction: Boolean = false) {
var wasModified = false;
fun updateWatchLater(updated: List<SerializedPlatformVideo>) {
synchronized(_watchlistStore) {
//_watchlistStore.deleteAll();
val existing = _watchlistStore.getItems();
val toAdd = updated.filter { u -> !existing.any { u.url == it.url } };
val toRemove = existing.filter { u -> !updated.any { u.url == it.url } };
wasModified = toAdd.size > 0 || toRemove.size > 0;
Logger.i(TAG, "WatchLater changed:\nTo Add:\n" +
(if(toAdd.size == 0) "None" else toAdd.map { " + " + it.name }.joinToString("\n")) +
"\nTo Remove:\n" +
@@ -131,71 +77,30 @@ class StatePlaylists {
}
onWatchLaterChanged.emit();
if(isUserInteraction) {
setWatchLaterReorderTime();
broadcastWatchLater(!wasModified);
}
if(StateDownloads.instance.getWatchLaterDescriptor() != null) {
StateDownloads.instance.checkForOutdatedPlaylistVideos(VideoDownload.GROUP_WATCHLATER);
}
}
fun getWatchLaterFromUrl(url: String): SerializedPlatformVideo?{
synchronized(_watchlistStore) {
val order = _watchlistOrderStore.getAllValues();
return _watchlistStore.getItems().firstOrNull { it.url == url };
}
}
fun removeFromWatchLater(url: String, isUserInteraction: Boolean = false) {
val item = getWatchLaterFromUrl(url);
if(item != null){
removeFromWatchLater(item, isUserInteraction);
}
}
fun removeFromWatchLater(video: SerializedPlatformVideo, isUserInteraction: Boolean = false, time: OffsetDateTime? = null) {
fun removeFromWatchLater(video: SerializedPlatformVideo) {
synchronized(_watchlistStore) {
_watchlistStore.delete(video);
_watchlistOrderStore.set(*_watchlistOrderStore.values.filter { it != video.url }.toTypedArray());
_watchlistOrderStore.save();
if(time != null)
_watchLaterRemovals.setAndSave(video.url, time);
}
onWatchLaterChanged.emit();
if(isUserInteraction) {
val now = OffsetDateTime.now();
if(time == null) {
_watchLaterRemovals.setAndSave(video.url, now);
broadcastWatchLaterRemoval(video.url, now);
}
else
broadcastWatchLaterRemoval(video.url, time);
}
if(StateDownloads.instance.getWatchLaterDescriptor() != null) {
StateDownloads.instance.checkForOutdatedPlaylistVideos(VideoDownload.GROUP_WATCHLATER);
}
}
fun addToWatchLater(video: SerializedPlatformVideo, isUserInteraction: Boolean = false, orderPosition: Int = -1) {
fun addToWatchLater(video: SerializedPlatformVideo) {
synchronized(_watchlistStore) {
_watchlistStore.saveAsync(video);
if(orderPosition == -1)
_watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values) .toTypedArray());
else {
val existing = _watchlistOrderStore.getAllValues().toMutableList();
existing.add(orderPosition, video.url);
_watchlistOrderStore.set(*existing.toTypedArray());
}
_watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values) .toTypedArray());
_watchlistOrderStore.save();
}
onWatchLaterChanged.emit();
if(isUserInteraction) {
val now = OffsetDateTime.now();
_watchLaterAdds.setAndSave(video.url, now);
broadcastWatchLaterAddition(video, now);
}
StateDownloads.instance.checkForOutdatedPlaylists();
}
@@ -213,9 +118,6 @@ class StatePlaylists {
return playlistStore.findItem { it.id == id };
}
fun getPlaylistRemovals(): Map<String, Long> {
return _playlistRemoved.all();
}
fun didPlay(playlistId: String) {
val playlist = getPlaylist(playlistId);
@@ -225,36 +127,6 @@ class StatePlaylists {
}
}
private fun broadcastWatchLater(orderOnly: Boolean = false) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
StateSync.instance.broadcastJsonData(GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage(
if(orderOnly) listOf() else getWatchLater(),
if(orderOnly) mapOf() else _watchLaterAdds.all(),
if(orderOnly) mapOf() else _watchLaterRemovals.all(),
getWatchLaterLastReorderTime().toEpochSecond(),
_watchlistOrderStore.values.toList()));
};
}
private fun broadcastWatchLaterAddition(video: SerializedPlatformVideo, time: OffsetDateTime) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
StateSync.instance.broadcastJsonData(GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage(
listOf(video),
mapOf(Pair(video.url, time.toEpochSecond())),
mapOf(),
))
};
}
private fun broadcastWatchLaterRemoval(url: String, time: OffsetDateTime) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
StateSync.instance.broadcastJsonData(GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage(
listOf(),
mapOf(),
mapOf(Pair(url, time.toEpochSecond()))
))
};
}
suspend fun createPlaylistFromChannel(channelUrl: String, onPage: (Int) -> Unit): Playlist {
val channel = StatePlatform.instance.getChannel(channelUrl).await();
return createPlaylistFromChannel(channel, onPage);
@@ -276,15 +148,13 @@ class StatePlaylists {
createOrUpdatePlaylist(newPlaylist);
return newPlaylist;
}
fun createOrUpdatePlaylist(playlist: Playlist, isUserInteraction: Boolean = true) {
fun createOrUpdatePlaylist(playlist: Playlist) {
playlist.dateUpdate = OffsetDateTime.now();
playlistStore.saveAsync(playlist, true);
if(playlist.id.isNotEmpty()) {
if (StateDownloads.instance.isPlaylistCached(playlist.id)) {
StateDownloads.instance.checkForOutdatedPlaylistVideos(playlist.id);
}
if(isUserInteraction)
broadcastSyncPlaylist(playlist);
}
}
fun addToPlaylist(id: String, video: IPlatformVideo) {
@@ -293,41 +163,14 @@ class StatePlaylists {
playlist.videos.add(SerializedPlatformVideo.fromVideo(video));
playlist.dateUpdate = OffsetDateTime.now();
playlistStore.saveAsync(playlist, true);
broadcastSyncPlaylist(playlist);
}
}
private fun broadcastSyncPlaylist(playlist: Playlist){
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
if(StateSync.instance.hasAtLeastOneOnlineDevice()) {
Logger.i(StateSubscriptionGroups.TAG, "SyncPlaylist (${playlist.name})");
StateSync.instance.broadcastJsonData(
GJSyncOpcodes.syncPlaylists,
SyncPlaylistsPackage(listOf(playlist), mapOf())
);
}
};
}
fun removePlaylist(playlist: Playlist, isUserInteraction: Boolean = true) {
fun removePlaylist(playlist: Playlist) {
playlistStore.delete(playlist);
if(StateDownloads.instance.isPlaylistCached(playlist.id)) {
StateDownloads.instance.deleteCachedPlaylist(playlist.id);
}
if(isUserInteraction) {
_playlistRemoved.setAndSave(playlist.id, OffsetDateTime.now());
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
if(StateSync.instance.hasAtLeastOneOnlineDevice()) {
Logger.i(StateSubscriptionGroups.TAG, "SyncPlaylist (${playlist.name})");
StateSync.instance.broadcastJsonData(
GJSyncOpcodes.syncPlaylists,
SyncPlaylistsPackage(listOf(), mapOf(Pair(playlist.id, OffsetDateTime.now().toEpochSecond())))
);
}
};
}
}
fun createPlaylistShareUri(context: Context, playlist: Playlist): Uri {
@@ -351,16 +194,6 @@ class StatePlaylists {
return FileProvider.getUriForFile(context, context.resources.getString(R.string.authority), newFile);
}
fun getSyncPlaylistsPackageString(): String{
return Json.encodeToString(
SyncPlaylistsPackage(
getPlaylists(),
getPlaylistRemovals()
)
);
}
companion object {
val TAG = "StatePlaylists";
private var _instance : StatePlaylists? = null;
@@ -19,7 +19,6 @@ import com.futo.platformplayer.stores.PluginIconStorage
import com.futo.platformplayer.stores.PluginScriptsDirectory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
@@ -48,8 +47,6 @@ class StatePlugins {
private var _updatesAvailableMap: HashSet<String> = hashSetOf();
private val _isUpdating: HashSet<String> = hashSetOf();
fun getPluginIconOrNull(id: String): ImageVariable? {
if(iconsDir.hasIcon(id))
return iconsDir.getIconBinary(id);
@@ -61,38 +58,6 @@ class StatePlugins {
.load();
}
fun isUpdating(id: String): Boolean{
synchronized(_isUpdating){
return _isUpdating.contains(id);
}
}
fun setIsUpdating(id: String, value: Boolean){
synchronized(_isUpdating){
if(value && !_isUpdating.contains(id)) {
Logger.i(TAG, "PLUGIN [${id}] UPDATING");
_isUpdating.add(id);
}
if(!value && _isUpdating.contains(id)) {
Logger.i(TAG, "PLUGIN [${id}] NOT UPDATING");
_isUpdating.remove(id);
}
}
}
suspend fun whileUpdating(id: String, handle: suspend ()->Unit){
try {
setIsUpdating(id, true);
handle();
}
finally {
setIsUpdating(id, false);
}
}
fun clearUpdating(){
synchronized(_isUpdating) {
_isUpdating.clear();
}
}
suspend fun checkForUpdates(): List<Pair<SourcePluginConfig, SourcePluginConfig>> = withContext(Dispatchers.IO) {
var configs = mutableListOf<Pair<SourcePluginConfig, SourcePluginConfig>>()
@@ -465,49 +430,42 @@ class StatePlugins {
fun installPluginBackground(context: Context, scope: CoroutineScope, config: SourcePluginConfig, script: String, onProgress: (text: String, progress: Double)->Unit, onConcluded: (ex: Throwable?)->Unit) {
scope.launch(Dispatchers.IO) {
whileUpdating(config.id) {
val client = ManagedHttpClient();
try {
withContext(Dispatchers.Main) {
onProgress.invoke("Waiting for plugins to finish", 0.1);
onProgress.invoke("Validating script", 0.25);
}
delay(500);
val client = ManagedHttpClient();
try {
val tempDescriptor = SourcePluginDescriptor(config);
val plugin = JSClient(context, tempDescriptor, null, script);
plugin.validate();
withContext(Dispatchers.Main) {
onProgress.invoke("Downloading Icon", 0.5);
}
val icon = config.absoluteIconUrl?.let { absIconUrl ->
withContext(Dispatchers.Main) {
onProgress.invoke("Validating script", 0.25);
onProgress.invoke("Saving plugin", 0.75);
}
val iconResp = client.get(absIconUrl);
if(iconResp.isOk)
return@let iconResp.body?.byteStream()?.use { it.readBytes() };
return@let null;
}
val installEx = StatePlugins.instance.createPlugin(config, script, icon, true);
if(installEx != null)
throw installEx;
StatePlatform.instance.updateAvailableClients(context);
val tempDescriptor = SourcePluginDescriptor(config);
val plugin = JSClient(context, tempDescriptor, null, script);
plugin.validate();
withContext(Dispatchers.Main) {
onProgress.invoke("Downloading Icon", 0.5);
}
val icon = config.absoluteIconUrl?.let { absIconUrl ->
withContext(Dispatchers.Main) {
onProgress.invoke("Saving plugin", 0.75);
}
val iconResp = client.get(absIconUrl);
if (iconResp.isOk)
return@let iconResp.body?.byteStream()?.use { it.readBytes() };
return@let null;
}
val installEx = StatePlugins.instance.createPlugin(config, script, icon, true);
if (installEx != null)
throw installEx;
StatePlatform.instance.updateAvailableClients(context);
withContext(Dispatchers.Main) {
onProgress.invoke("Finished", 1.0)
onConcluded.invoke(null);
}
} catch (ex: Exception) {
Logger.e(TAG, ex.message ?: "null", ex);
withContext(Dispatchers.Main) {
onConcluded.invoke(ex);
}
withContext(Dispatchers.Main) {
onProgress.invoke("Finished", 1.0)
onConcluded.invoke(null);
}
} catch (ex: Exception) {
Logger.e(TAG, ex.message ?: "null", ex);
withContext(Dispatchers.Main) {
onConcluded.invoke(ex);
}
}
}
@@ -10,7 +10,6 @@ import com.futo.platformplayer.activities.PolycentricHomeActivity
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.comments.LazyComment
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
@@ -45,17 +44,17 @@ import com.futo.polycentric.core.toBase64
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.google.protobuf.ByteString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import userpackage.Protocol
import userpackage.Protocol.Reference
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.concurrent.ForkJoinPool
class StatePolycentric {
private data class LikeDislikeEntry(val unixMilliseconds: Long, val hasLiked: Boolean, val hasDisliked: Boolean);
@@ -66,9 +65,6 @@ class StatePolycentric {
private var _transientEnabled = true
val enabled get() = _transientEnabled && Settings.instance.other.polycentricEnabled
private val _commentPool = ForkJoinPool(2);
private val _commentPoolDispatcher = _commentPool.asCoroutineDispatcher();
fun load(context: Context) {
if (!enabled) {
return
@@ -516,23 +512,21 @@ class StatePolycentric {
};
}
private suspend fun mapQueryReferences(contextUrl: String, response: Protocol.QueryReferencesResponse): List<IPlatformComment> {
return response.itemsList.mapNotNull {
val sev = SignedEvent.fromProto(it.event);
val ev = sev.event;
if (ev.contentType != ContentType.POST.value) {
return@mapNotNull null;
}
private suspend fun mapQueryReferences(contextUrl: String, response: Protocol.QueryReferencesResponse): List<PolycentricPlatformComment> = coroutineScope {
response.itemsList.map { item ->
async {
val sev = SignedEvent.fromProto(item.event)
val ev = sev.event
if (ev.contentType != ContentType.POST.value) {
return@async null
}
try {
val post = Protocol.Post.parseFrom(ev.content);
val likes = it.countsList[0];
val dislikes = it.countsList[1];
val replies = it.countsList[2];
try {
val post = Protocol.Post.parseFrom(ev.content)
val likes = item.countsList.getOrNull(0)
val dislikes = item.countsList.getOrNull(1)
val replies = item.countsList.getOrNull(2)
val scope = StateApp.instance.scopeOrNull ?: return@mapNotNull null;
return@mapNotNull LazyComment(scope.async(_commentPoolDispatcher){
Logger.i(TAG, "Fetching comment data for [" + ev.system.key.toBase64() + "]");
val profileEvents = ApiMethods.getQueryLatest(
PolycentricCache.SERVER,
ev.system.toProto(),
@@ -556,31 +550,50 @@ class StatePolycentric {
null
}
val unixMilliseconds = ev.unixMilliseconds
//TODO: Don't use single hardcoded sderver here
val systemLinkUrl = ev.system.systemToURLInfoSystemLinkUrl(listOf(PolycentricCache.SERVER));
//TODO: Don't use single hardcoded server here
val systemLinkUrl = ev.system.systemToURLInfoSystemLinkUrl(listOf(PolycentricCache.SERVER))
val dp_25 = 25.dp(StateApp.instance.context.resources)
return@async PolycentricPlatformComment(
PolycentricPlatformComment(
contextUrl = contextUrl,
author = PlatformAuthorLink(
id = PlatformID("polycentric", systemLinkUrl, null, ClaimType.POLYCENTRIC.value.toInt()),
id = PlatformID(
"polycentric",
systemLinkUrl,
null,
ClaimType.POLYCENTRIC.value.toInt()
),
name = nameEvent?.event?.lwwElement?.value?.decodeToString() ?: "Unknown",
url = systemLinkUrl,
thumbnail = imageBundle?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(ev.system.toProto(), img.process, listOf(PolycentricCache.SERVER)) },
thumbnail = imageBundle?.selectBestImage(dp_25 * dp_25)?.let { img ->
img.toURLInfoSystemLinkUrl(
ev.system.toProto(),
img.process,
listOf(PolycentricCache.SERVER)
)
},
subscribers = null
),
msg = if (post.content.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) post.content.substring(0, PolycentricPlatformComment.MAX_COMMENT_SIZE) else post.content,
rating = RatingLikeDislikes(likes, dislikes),
date = if (unixMilliseconds != null) Instant.ofEpochMilli(unixMilliseconds).atOffset(ZoneOffset.UTC) else OffsetDateTime.MIN,
replyCount = replies.toInt(),
msg = if (post.content.length > PolycentricPlatformComment.MAX_COMMENT_SIZE) {
post.content.substring(0, PolycentricPlatformComment.MAX_COMMENT_SIZE)
} else {
post.content
},
rating = RatingLikeDislikes(likes ?: 0, dislikes ?: 0),
date = unixMilliseconds?.let {
Instant.ofEpochMilli(it).atOffset(ZoneOffset.UTC)
} ?: OffsetDateTime.MIN,
replyCount = replies?.toInt() ?: 0,
eventPointer = sev.toPointer(),
parentReference = sev.event.references.getOrNull(0)
);
});
} catch (e: Throwable) {
return@mapNotNull null;
)
} catch (e: Throwable) {
null
}
}
};
}.awaitAll().filterNotNull()
}
companion object {
@@ -25,20 +25,13 @@ import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.states.StateHistory.Companion
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringDateMapStorage
import com.futo.platformplayer.stores.SubscriptionStorage
import com.futo.platformplayer.stores.v2.ReconstructStore
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithm
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithms
import com.futo.platformplayer.sync.internal.GJSyncOpcodes
import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage
import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage
import kotlinx.coroutines.*
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.OffsetDateTime
import java.util.concurrent.ExecutionException
import java.util.concurrent.ForkJoinPool
@@ -58,9 +51,6 @@ class StateSubscriptionGroups {
.withUnique { it.id }
.load();
private val _groupsRemoved = FragmentedStorage.get<StringDateMapStorage>("group_removed");
val onGroupsChanged = Event0();
fun getSubscriptionGroup(id: String): SubscriptionGroup? {
@@ -69,66 +59,19 @@ class StateSubscriptionGroups {
fun getSubscriptionGroups(): List<SubscriptionGroup> {
return _subGroups.getItems();
}
fun getSubscriptionGroupsRemovals(): Map<String, Long> {
return _groupsRemoved.all();
}
fun updateSubscriptionGroup(subGroup: SubscriptionGroup, preventNotify: Boolean = false, preventSync: Boolean = false) {
subGroup.lastChange = OffsetDateTime.now();
fun updateSubscriptionGroup(subGroup: SubscriptionGroup, preventNotify: Boolean = false) {
_subGroups.save(subGroup);
if(!preventNotify)
onGroupsChanged.emit();
if(!preventSync) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
if(StateSync.instance.hasAtLeastOneOnlineDevice()) {
Logger.i(TAG, "SyncSubscriptionGroup (${subGroup.name})");
StateSync.instance.broadcastJsonData(
GJSyncOpcodes.syncSubscriptionGroups,
SyncSubscriptionGroupsPackage(listOf(subGroup), mapOf())
);
}
};
}
}
fun deleteSubscriptionGroup(id: String, isUserInteraction: Boolean = true){
fun deleteSubscriptionGroup(id: String){
val group = getSubscriptionGroup(id);
if(group != null) {
_subGroups.delete(group);
onGroupsChanged.emit();
if(isUserInteraction) {
_groupsRemoved.setAndSave(id, OffsetDateTime.now());
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
if(StateSync.instance.hasAtLeastOneOnlineDevice()) {
Logger.i(TAG, "SyncSubscriptionGroup delete (${group.name})");
StateSync.instance.broadcastJsonData(
GJSyncOpcodes.syncSubscriptionGroups,
SyncSubscriptionGroupsPackage(listOf(), mapOf(Pair(id, OffsetDateTime.now().toEpochSecond())))
);
}
};
}
}
}
fun hasSubscriptionGroup(url: String): Boolean {
val groups = getSubscriptionGroups();
for(group in groups){
if(group.urls.contains(url))
return true;
}
return false;
}
fun getSyncSubscriptionGroupsPackageString(): String{
return Json.encodeToString(
SyncSubscriptionGroupsPackage(
getSubscriptionGroups(),
getSubscriptionGroupsRemovals()
)
);
}
companion object {
const val TAG = "StateSubscriptionGroups";
@@ -202,13 +202,13 @@ class StateSubscriptions {
return _subscriptionOthers.findItem { it.isChannel(url)};
}
}
fun getSubscriptionOtherOrCreate(url: String, name: String? = null, thumbnail: String? = null) : Subscription {
fun getSubscriptionOtherOrCreate(url: String) : Subscription {
synchronized(_subscriptionOthers) {
val sub = getSubscriptionOther(url);
if(sub == null) {
val newSub = Subscription(SerializedChannel(PlatformID.NONE, name ?: url, thumbnail, null, 0, null, url, mapOf()));
val newSub = Subscription(SerializedChannel(PlatformID.NONE, url, null, null, 0, null, url, mapOf()));
newSub.isOther = true;
_subscriptionOthers.save(newSub);
_subscriptions.save(newSub);
return newSub;
}
else return sub;
@@ -250,7 +250,7 @@ class StateSubscriptions {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
StateSync.instance.broadcastData(
StateSync.instance.broadcast(
GJSyncOpcodes.syncSubscriptions, Json.encodeToString(
SyncSubscriptionsPackage(
listOf(subObj),
@@ -293,29 +293,8 @@ class StateSubscriptions {
if(sub != null) {
_subscriptions.delete(sub);
onSubscriptionsChanged.emit(getSubscriptions(), false);
if(isUserAction) {
val removalTime = OffsetDateTime.now();
_subscriptionsRemoved.setAndSave(sub.channel.url, removalTime);
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
StateSync.instance.broadcastData(
GJSyncOpcodes.syncSubscriptions, Json.encodeToString(
SyncSubscriptionsPackage(
listOf(),
mapOf(Pair(sub.channel.url, removalTime.toEpochSecond()))
)
)
);
}
catch(ex: Exception) {
Logger.w(TAG, "Failed to send subs changes to sync clients", ex);
}
}
}
if(StateSubscriptionGroups.instance.hasSubscriptionGroup(sub.channel.url))
getSubscriptionOtherOrCreate(sub.channel.url, sub.channel.name, sub.channel.thumbnail);
if(isUserAction)
_subscriptionsRemoved.setAndSave(sub.channel.url, OffsetDateTime.now());
}
return sub;
}
@@ -66,10 +66,6 @@ class StateSync {
val deviceUpdatedOrAdded: Event2<String, SyncSession> = Event2()
fun start() {
if (_started) {
Logger.i(TAG, "Already started.")
return
}
_started = true
if (Settings.instance.synchronization.broadcast || Settings.instance.synchronization.connectDiscovered) {
@@ -112,23 +108,18 @@ class StateSync {
Logger.i(TAG, "Sync key pair initialized (public key = ${publicKey})")
_thread = Thread {
try {
val serverSocket = ServerSocket(PORT)
_serverSocket = serverSocket
val serverSocket = ServerSocket(PORT)
_serverSocket = serverSocket
Log.i(TAG, "Running on port ${PORT} (TCP)")
Log.i(TAG, "Running on port ${PORT} (TCP)")
while (_started) {
val socket = serverSocket.accept()
val session = createSocketSession(socket, true) { session, socketSession ->
while (_started) {
val socket = serverSocket.accept()
val session = createSocketSession(socket, true) { session, socketSession ->
}
session.startAsResponder()
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to bind server socket to port ${PORT}", e)
UIDialogs.toast("Failed to start sync, port in use")
session.startAsResponder()
}
}.apply { start() }
@@ -289,16 +280,12 @@ class StateSync {
return@SyncSocketSession
}
Logger.i(TAG, "Handshake complete with (LocalPublicKey = ${s.localPublicKey}, RemotePublicKey = ${s.remotePublicKey})")
Logger.i(TAG, "Handshake complete with ${s.remotePublicKey}")
synchronized(_sessions) {
session = _sessions[s.remotePublicKey]
if (session == null) {
session = SyncSession(remotePublicKey, onAuthorized = { it, isNewlyAuthorized, isNewSession ->
if (!isNewSession) {
return@SyncSession
}
session = SyncSession(remotePublicKey, onAuthorized = {
Logger.i(TAG, "${s.remotePublicKey} authorized")
synchronized(_lastAddressStorage) {
_lastAddressStorage.setAndSave(remotePublicKey, s.remoteAddress)
@@ -367,16 +354,6 @@ class StateSync {
}
})
}
} else {
val publicKey = session!!.remotePublicKey
session!!.unauthorize(s)
session!!.close()
synchronized(_sessions) {
_sessions.remove(publicKey)
}
Logger.i(TAG, "Connection unauthorized for ${remotePublicKey} because not authorized and not on pairing activity to ask")
}
} else {
//Responder does not need to check because already approved
@@ -389,29 +366,26 @@ class StateSync {
Logger.i(TAG, "Connection authorized for ${remotePublicKey} because initiator")
}
},
onData = { s, opcode, subOpcode, data ->
session?.handlePacket(s, opcode, subOpcode, data)
onData = { s, opcode, data ->
session?.handlePacket(s, opcode, data)
})
}
inline fun <reified T> broadcastJsonData(subOpcode: UByte, data: T) {
broadcast(SyncSocketSession.Opcode.DATA.value, subOpcode, Json.encodeToString(data));
inline fun <reified T> broadcastJson(opcode: UByte, data: T) {
broadcast(opcode, Json.encodeToString(data));
}
fun broadcastData(subOpcode: UByte, data: String) {
broadcast(SyncSocketSession.Opcode.DATA.value, subOpcode, data.toByteArray(Charsets.UTF_8));
fun broadcast(opcode: UByte, data: String) {
broadcast(opcode, data.toByteArray(Charsets.UTF_8));
}
fun broadcast(opcode: UByte, subOpcode: UByte, data: String) {
broadcast(opcode, subOpcode, data.toByteArray(Charsets.UTF_8));
}
fun broadcast(opcode: UByte, subOpcode: UByte, data: ByteArray) {
fun broadcast(opcode: UByte, data: ByteArray) {
for(session in getSessions()) {
try {
if (session.isAuthorized && session.connected) {
session.send(opcode, subOpcode, data);
session.send(opcode, data);
}
}
catch(ex: Exception) {
Logger.w(TAG, "Failed to broadcast (opcode = ${opcode}, subOpcode = ${subOpcode}) to ${session.remotePublicKey}: ${ex.message}}", ex);
Logger.w(TAG, "Failed to broadcast ${opcode} to ${session.remotePublicKey}: ${ex.message}}", ex);
}
}
}
@@ -420,7 +394,7 @@ class StateSync {
val time = measureTimeMillis {
//val export = StateBackup.export();
//session.send(GJSyncOpcodes.syncExport, export.asZip());
session.sendData(GJSyncOpcodes.syncStateExchange, getSyncSessionDataString(session.remotePublicKey));
session.send(GJSyncOpcodes.syncStateExchange, getSyncSessionDataString(session.remotePublicKey));
}
Logger.i(TAG, "Generated and sent sync export in ${time}ms");
}
@@ -22,7 +22,6 @@ import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.states.StateSubscriptions
import kotlinx.coroutines.CoroutineScope
import java.time.OffsetDateTime
@@ -139,18 +138,6 @@ abstract class SubscriptionsTaskFetchAlgorithm(
for(task in tasks) {
val forkTask = threadPool.submit<SubscriptionTaskResult> {
if(StatePlugins.instance.isUpdating(task.client.id)){
val isUpdatingException = ScriptCriticalException(task.client.config, "Plugin is updating");
synchronized(failedPlugins) {
//Fail all subscription calls to plugin if it has a critical issue
if(isUpdatingException.config is SourcePluginConfig && !failedPlugins.contains(isUpdatingException.config.id)) {
Logger.w(StateSubscriptions.TAG, "Subscriptions ignoring plugin [${isUpdatingException.config.name}] due to critical exception:\n" + isUpdatingException.message);
failedPlugins.add(isUpdatingException.config.id);
}
}
return@submit SubscriptionTaskResult(task, StateCache.instance.getChannelCachePager(task.sub.channel.url), isUpdatingException);
}
if(task.fromPeek) {
try {
@@ -11,8 +11,5 @@ class GJSyncOpcodes {
val syncSubscriptions: UByte = 202.toUByte();
val syncHistory: UByte = 203.toUByte();
val syncSubscriptionGroups: UByte = 204.toUByte();
val syncPlaylists: UByte = 205.toUByte();
val syncWatchLater: UByte = 206.toUByte();
}
}
@@ -6,34 +6,23 @@ import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.HistoryVideo
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.smartMerge
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.states.StateSubscriptionGroups
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.states.StateSync
import com.futo.platformplayer.sync.SyncSessionData
import com.futo.platformplayer.sync.internal.SyncSocketSession.Opcode
import com.futo.platformplayer.sync.models.SendToDevicePackage
import com.futo.platformplayer.sync.models.SyncPlaylistsPackage
import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage
import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage
import com.futo.platformplayer.sync.models.SyncWatchLaterPackage
import com.futo.platformplayer.toUtf8String
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream
import java.nio.ByteBuffer
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID
interface IAuthorizable {
val isAuthorized: Boolean
@@ -43,16 +32,12 @@ class SyncSession : IAuthorizable {
private val _socketSessions: MutableList<SyncSocketSession> = mutableListOf()
private var _authorized: Boolean = false
private var _remoteAuthorized: Boolean = false
private val _onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit
private val _onAuthorized: (session: SyncSession) -> Unit
private val _onUnauthorized: (session: SyncSession) -> Unit
private val _onClose: (session: SyncSession) -> Unit
private val _onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit
val remotePublicKey: String
override val isAuthorized get() = _authorized && _remoteAuthorized
private var _wasAuthorized = false
private val _id = UUID.randomUUID()
private var _remoteId: UUID? = null
private var _lastAuthorizedRemoteId: UUID? = null
var connected: Boolean = false
private set(v) {
@@ -62,7 +47,7 @@ class SyncSession : IAuthorizable {
}
}
constructor(remotePublicKey: String, onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit, onUnauthorized: (session: SyncSession) -> Unit, onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit, onClose: (session: SyncSession) -> Unit) {
constructor(remotePublicKey: String, onAuthorized: (session: SyncSession) -> Unit, onUnauthorized: (session: SyncSession) -> Unit, onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit, onClose: (session: SyncSession) -> Unit) {
this.remotePublicKey = remotePublicKey
_onAuthorized = onAuthorized
_onUnauthorized = onUnauthorized
@@ -84,8 +69,7 @@ class SyncSession : IAuthorizable {
}
fun authorize(socketSession: SyncSocketSession) {
Logger.i(TAG, "Sent AUTHORIZED with session id $_id")
socketSession.send(Opcode.NOTIFY_AUTHORIZED.value, 0u, ByteBuffer.wrap(_id.toString().toByteArray()))
socketSession.send(Opcode.NOTIFY_AUTHORIZED.value)
_authorized = true
checkAuthorized()
}
@@ -103,14 +87,8 @@ class SyncSession : IAuthorizable {
}
private fun checkAuthorized() {
if (isAuthorized) {
val isNewlyAuthorized = !_wasAuthorized;
val isNewSession = _lastAuthorizedRemoteId != _remoteId;
Logger.i(TAG, "onAuthorized (isNewlyAuthorized = $isNewlyAuthorized, isNewSession = $isNewSession)");
_onAuthorized.invoke(this, !_wasAuthorized, _lastAuthorizedRemoteId != _remoteId)
_wasAuthorized = true
_lastAuthorizedRemoteId = _remoteId
}
if (isAuthorized)
_onAuthorized.invoke(this)
}
fun removeSocketSession(socketSession: SyncSocketSession) {
@@ -132,41 +110,29 @@ class SyncSession : IAuthorizable {
_onClose.invoke(this)
}
fun handlePacket(socketSession: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
fun handlePacket(socketSession: SyncSocketSession, opcode: UByte, data: ByteBuffer) {
Logger.i(TAG, "Handle packet (opcode: ${opcode}, data.length: ${data.remaining()})")
when (opcode) {
Opcode.NOTIFY_AUTHORIZED.value -> {
_remoteAuthorized = true
checkAuthorized()
}
Opcode.NOTIFY_UNAUTHORIZED.value -> {
_remoteAuthorized = false
_onUnauthorized(this)
}
//TODO: Handle any kind of packet (that is not necessarily authorized)
}
if (!isAuthorized) {
return
}
Logger.i(TAG, "Received ${opcode} (${data.remaining()} bytes)")
//TODO: Abstract this out
try {
Logger.i(TAG, "Handle packet (opcode: ${opcode}, subOpcode: ${subOpcode}, data.length: ${data.remaining()})")
when (opcode) {
Opcode.NOTIFY_AUTHORIZED.value -> {
val str = data.toUtf8String()
_remoteId = if (data.remaining() >= 0) UUID.fromString(str) else UUID.fromString("00000000-0000-0000-0000-000000000000")
_remoteAuthorized = true
Logger.i(TAG, "Received AUTHORIZED with session id $_remoteId")
checkAuthorized()
return
}
Opcode.NOTIFY_UNAUTHORIZED.value -> {
_remoteId = null
_lastAuthorizedRemoteId = null
_remoteAuthorized = false
_onUnauthorized(this)
return
}
//TODO: Handle any kind of packet (that is not necessarily authorized)
}
if (!isAuthorized) {
return
}
if (opcode != Opcode.DATA.value) {
Logger.w(TAG, "Unknown opcode received: (opcode = ${opcode}, subOpcode = ${subOpcode})}")
return
}
Logger.i(TAG, "Received (opcode = ${opcode}, subOpcode = ${subOpcode}) (${data.remaining()} bytes)")
//TODO: Abstract this out
when (subOpcode) {
GJSyncOpcodes.sendToDevices -> {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
val context = StateApp.instance.contextOrNull;
@@ -191,13 +157,11 @@ class SyncSession : IAuthorizable {
Logger.i(TAG, "Received SyncSessionData from " + remotePublicKey);
sendData(GJSyncOpcodes.syncSubscriptions, StateSubscriptions.instance.getSyncSubscriptionsPackageString());
sendData(GJSyncOpcodes.syncSubscriptionGroups, StateSubscriptionGroups.instance.getSyncSubscriptionGroupsPackageString());
sendData(GJSyncOpcodes.syncPlaylists, StatePlaylists.instance.getSyncPlaylistsPackageString())
send(GJSyncOpcodes.syncSubscriptions, StateSubscriptions.instance.getSyncSubscriptionsPackageString());
val recentHistory = StateHistory.instance.getRecentHistory(syncSessionData.lastHistory);
if(recentHistory.size > 0)
sendJsonData(GJSyncOpcodes.syncHistory, recentHistory);
sendJson(GJSyncOpcodes.syncHistory, recentHistory);
}
GJSyncOpcodes.syncExport -> {
@@ -241,101 +205,6 @@ class SyncSession : IAuthorizable {
}
}
GJSyncOpcodes.syncSubscriptionGroups -> {
val dataBody = ByteArray(data.remaining());
data.get(dataBody);
val json = String(dataBody, Charsets.UTF_8);
val pack = Serializer.json.decodeFromString<SyncSubscriptionGroupsPackage>(json);
var lastSubgroupChange = OffsetDateTime.MIN;
for(group in pack.groups){
if(group.lastChange > lastSubgroupChange)
lastSubgroupChange = group.lastChange;
val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id);
if(existing == null)
StateSubscriptionGroups.instance.updateSubscriptionGroup(group, false, true);
else if(existing.lastChange < group.lastChange) {
existing.name = group.name;
existing.urls = group.urls;
existing.image = group.image;
existing.priority = group.priority;
existing.lastChange = group.lastChange;
StateSubscriptionGroups.instance.updateSubscriptionGroup(existing, false, true);
}
}
for(removal in pack.groupRemovals) {
val creation = StateSubscriptionGroups.instance.getSubscriptionGroup(removal.key);
val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC);
if(creation != null && creation.creationTime < removalTime)
StateSubscriptionGroups.instance.deleteSubscriptionGroup(removal.key, false);
}
}
GJSyncOpcodes.syncPlaylists -> {
val dataBody = ByteArray(data.remaining());
data.get(dataBody);
val json = String(dataBody, Charsets.UTF_8);
val pack = Serializer.json.decodeFromString<SyncPlaylistsPackage>(json);
for(playlist in pack.playlists) {
val existing = StatePlaylists.instance.getPlaylist(playlist.id);
if(existing == null)
StatePlaylists.instance.createOrUpdatePlaylist(playlist, false);
else if(existing.dateUpdate.toLocalDateTime() < playlist.dateUpdate.toLocalDateTime()) {
existing.dateUpdate = playlist.dateUpdate;
existing.name = playlist.name;
existing.videos = playlist.videos;
existing.dateCreation = playlist.dateCreation;
existing.datePlayed = playlist.datePlayed;
StatePlaylists.instance.createOrUpdatePlaylist(existing, false);
}
}
for(removal in pack.playlistRemovals) {
val creation = StatePlaylists.instance.getPlaylist(removal.key);
val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC);
if(creation != null && creation.dateCreation < removalTime)
StatePlaylists.instance.removePlaylist(creation, false);
}
}
GJSyncOpcodes.syncWatchLater -> {
val dataBody = ByteArray(data.remaining());
data.get(dataBody);
val json = String(dataBody, Charsets.UTF_8);
val pack = Serializer.json.decodeFromString<SyncWatchLaterPackage>(json);
Logger.i(TAG, "SyncWatchLater received ${pack.videos.size} (${pack.videoAdds?.size}, ${pack.videoRemovals?.size})");
val allExisting = StatePlaylists.instance.getWatchLater();
for(video in pack.videos) {
val existing = allExisting.firstOrNull { it.url == video.url };
val time = if(pack.videoAdds != null && pack.videoAdds.containsKey(video.url)) OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.videoAdds[video.url] ?: 0), ZoneOffset.UTC) else OffsetDateTime.MIN;
if(existing == null) {
StatePlaylists.instance.addToWatchLater(video, false);
if(time > OffsetDateTime.MIN)
StatePlaylists.instance.setWatchLaterAddTime(video.url, time);
}
}
for(removal in pack.videoRemovals) {
val watchLater = allExisting.firstOrNull { it.url == removal.key } ?: continue;
val creation = StatePlaylists.instance.getWatchLaterRemovalTime(watchLater.url) ?: OffsetDateTime.MIN;
val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value), ZoneOffset.UTC);
if(creation < removalTime)
StatePlaylists.instance.removeFromWatchLater(watchLater, false, removalTime);
}
val packReorderTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.reorderTime), ZoneOffset.UTC);
val localReorderTime = StatePlaylists.instance.getWatchLaterLastReorderTime();
if(localReorderTime < packReorderTime && pack.ordering != null) {
StatePlaylists.instance.updateWatchLaterOrdering(smartMerge(pack.ordering!!, StatePlaylists.instance.getWatchLaterOrdering()), true);
}
}
GJSyncOpcodes.syncHistory -> {
val dataBody = ByteArray(data.remaining());
data.get(dataBody);
@@ -373,7 +242,8 @@ class SyncSession : IAuthorizable {
if(!StateSubscriptions.instance.isSubscribed(sub.channel)) {
val removalTime = StateSubscriptions.instance.getSubscriptionRemovalTime(sub.channel.url);
if(sub.creationTime > removalTime) {
val newSub = StateSubscriptions.instance.addSubscription(sub.channel, sub.creationTime);
val newSub =
StateSubscriptions.instance.addSubscription(sub.channel, sub.creationTime);
added.add(newSub);
}
}
@@ -399,19 +269,16 @@ class SyncSession : IAuthorizable {
}
inline fun <reified T> sendJsonData(subOpcode: UByte, data: T) {
send(Opcode.DATA.value, subOpcode, Json.encodeToString<T>(data));
inline fun <reified T> sendJson(opcode: UByte, data: T) {
send(opcode, Json.encodeToString<T>(data));
}
fun sendData(subOpcode: UByte, data: String) {
send(Opcode.DATA.value, subOpcode, data.toByteArray(Charsets.UTF_8));
fun send(opcode: UByte, data: String) {
send(opcode, data.toByteArray(Charsets.UTF_8));
}
fun send(opcode: UByte, subOpcode: UByte, data: String) {
send(opcode, subOpcode, data.toByteArray(Charsets.UTF_8));
}
fun send(opcode: UByte, subOpcode: UByte, data: ByteArray) {
fun send(opcode: UByte, data: ByteArray) {
val sock = _socketSessions.firstOrNull();
if(sock != null){
sock.send(opcode, subOpcode, ByteBuffer.wrap(data));
sock.send(opcode, ByteBuffer.wrap(data));
}
else
throw IllegalStateException("Session has no active sockets");
@@ -2,7 +2,6 @@ package com.futo.platformplayer.sync.internal
import com.futo.platformplayer.LittleEndianDataInputStream
import com.futo.platformplayer.LittleEndianDataOutputStream
import com.futo.platformplayer.ensureNotMainThread
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.noise.protocol.CipherStatePair
import com.futo.platformplayer.noise.protocol.DHState
@@ -10,7 +9,6 @@ import com.futo.platformplayer.noise.protocol.HandshakeState
import com.futo.platformplayer.states.StateSync
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.UUID
class SyncSocketSession {
enum class Opcode(val value: UByte) {
@@ -20,8 +18,7 @@ class SyncSocketSession {
NOTIFY_UNAUTHORIZED(3u),
STREAM_START(4u),
STREAM_DATA(5u),
STREAM_END(6u),
DATA(7u)
STREAM_END(6u)
}
private val _inputStream: LittleEndianDataInputStream
@@ -44,12 +41,12 @@ class SyncSocketSession {
private val _localKeyPair: DHState
private var _localPublicKey: String
val localPublicKey: String get() = _localPublicKey
private val _onData: (session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit
private val _onData: (session: SyncSocketSession, opcode: UByte, data: ByteBuffer) -> Unit
var authorizable: IAuthorizable? = null
val remoteAddress: String
constructor(remoteAddress: String, localKeyPair: DHState, inputStream: LittleEndianDataInputStream, outputStream: LittleEndianDataOutputStream, onClose: (session: SyncSocketSession) -> Unit, onHandshakeComplete: (session: SyncSocketSession) -> Unit, onData: (session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit) {
constructor(remoteAddress: String, localKeyPair: DHState, inputStream: LittleEndianDataInputStream, outputStream: LittleEndianDataOutputStream, onClose: (session: SyncSocketSession) -> Unit, onHandshakeComplete: (session: SyncSocketSession) -> Unit, onData: (session: SyncSocketSession, opcode: UByte, data: ByteBuffer) -> Unit) {
_inputStream = inputStream
_outputStream = outputStream
_onClose = onClose
@@ -162,11 +159,10 @@ class SyncSocketSession {
}
private fun performVersionCheck() {
val CURRENT_VERSION = 2
_outputStream.writeInt(CURRENT_VERSION)
_outputStream.writeInt(1)
val version = _inputStream.readInt()
Logger.i(TAG, "performVersionCheck (version = $version)")
if (version != CURRENT_VERSION)
if (version != 1)
throw Exception("Invalid version")
}
@@ -209,9 +205,8 @@ class SyncSocketSession {
throw Exception("Handshake finished without completing")
}
fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
ensureNotMainThread()
fun send(opcode: UByte, data: ByteBuffer) {
if (data.remaining() + HEADER_SIZE > MAXIMUM_PACKET_SIZE) {
val segmentSize = MAXIMUM_PACKET_SIZE - HEADER_SIZE
val segmentData = ByteArray(segmentSize)
@@ -228,8 +223,8 @@ class SyncSocketSession {
if (sendOffset == 0) {
segmentOpcode = Opcode.STREAM_START.value
bytesToSend = segmentSize - 4 - 4 - 1 - 1
segmentPacketSize = bytesToSend + 4 + 4 + 1 + 1
bytesToSend = segmentSize - 4 - 4 - 1
segmentPacketSize = bytesToSend + 4 + 4 + 1
} else {
bytesToSend = minOf(segmentSize - 4 - 4, bytesRemaining)
segmentOpcode = if (bytesToSend >= bytesRemaining) Opcode.STREAM_END.value else Opcode.STREAM_DATA.value
@@ -241,20 +236,18 @@ class SyncSocketSession {
putInt(if (segmentOpcode == Opcode.STREAM_START.value) data.remaining() else sendOffset)
if (segmentOpcode == Opcode.STREAM_START.value) {
put(opcode.toByte())
put(subOpcode.toByte())
}
put(data.array(), data.position() + sendOffset, bytesToSend)
}
send(segmentOpcode, 0u, ByteBuffer.wrap(segmentData, 0, segmentPacketSize))
send(segmentOpcode, ByteBuffer.wrap(segmentData, 0, segmentPacketSize))
sendOffset += bytesToSend
}
} else {
synchronized(_sendLockObject) {
ByteBuffer.wrap(_sendBuffer).order(ByteOrder.LITTLE_ENDIAN).apply {
putInt(data.remaining() + 2)
putInt(data.remaining() + 1)
put(opcode.toByte())
put(subOpcode.toByte())
put(data.array(), data.position(), data.remaining())
}
@@ -267,15 +260,12 @@ class SyncSocketSession {
}
}
fun send(opcode: UByte, subOpcode: UByte = 0u) {
ensureNotMainThread()
fun send(opcode: UByte) {
synchronized(_sendLockObject) {
ByteBuffer.wrap(_sendBuffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(2)
ByteBuffer.wrap(_sendBuffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(1)
_sendBuffer.asUByteArray()[4] = opcode
_sendBuffer.asUByteArray()[5] = subOpcode
//Logger.i(TAG, "Encrypting message (opcode = ${opcode}, subOpcode = ${subOpcode}, size = ${HEADER_SIZE})")
//Logger.i(TAG, "Encrypting message (size = ${HEADER_SIZE})")
val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 0, HEADER_SIZE)
//Logger.i(TAG, "Sending encrypted message (size = ${len})")
@@ -287,19 +277,19 @@ class SyncSocketSession {
private fun handleData(data: ByteArray, length: Int) {
if (length < HEADER_SIZE)
throw Exception("Packet must be at least 6 bytes (header size)")
throw Exception("Packet must be at least 5 bytes (header size)")
val size = ByteBuffer.wrap(data, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int
if (size != length - 4)
throw Exception("Incomplete packet received")
val opcode = data.asUByteArray()[4]
val subOpcode = data.asUByteArray()[5]
val packetData = ByteBuffer.wrap(data, HEADER_SIZE, size - 2)
handlePacket(opcode, subOpcode, packetData.order(ByteOrder.LITTLE_ENDIAN))
val packetData = ByteBuffer.wrap(data, HEADER_SIZE, size - 1)
handlePacket(opcode, packetData.order(ByteOrder.LITTLE_ENDIAN))
}
private fun handlePacket(opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
private fun handlePacket(opcode: UByte, data: ByteBuffer) {
when (opcode) {
Opcode.PING.value -> {
send(Opcode.PONG.value)
@@ -312,7 +302,7 @@ class SyncSocketSession {
}
Opcode.NOTIFY_AUTHORIZED.value,
Opcode.NOTIFY_UNAUTHORIZED.value -> {
_onData.invoke(this, opcode, subOpcode, data)
_onData.invoke(this, opcode, data)
return
}
}
@@ -326,9 +316,8 @@ class SyncSocketSession {
val id = data.int
val expectedSize = data.int
val op = data.get().toUByte()
val subOp = data.get().toUByte()
val syncStream = SyncStream(expectedSize, op, subOp)
val syncStream = SyncStream(expectedSize, op)
if (data.remaining() > 0) {
syncStream.add(data.array(), data.position(), data.remaining())
}
@@ -373,13 +362,10 @@ class SyncSocketSession {
throw Exception("After sync stream end, the stream must be complete")
}
handlePacket(syncStream.opcode, syncStream.subOpcode, syncStream.getBytes().let { ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) })
}
Opcode.DATA.value -> {
_onData.invoke(this, opcode, subOpcode, data)
handlePacket(syncStream.opcode, syncStream.getBytes().let { ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) })
}
else -> {
Logger.w(TAG, "Unknown opcode received (opcode = ${opcode}, subOpcode = ${subOpcode})")
_onData.invoke(this, opcode, data)
}
}
}
@@ -388,6 +374,6 @@ class SyncSocketSession {
private const val TAG = "SyncSocketSession"
const val MAXIMUM_PACKET_SIZE = 65535 - 16
const val MAXIMUM_PACKET_SIZE_ENCRYPTED = MAXIMUM_PACKET_SIZE + 16
const val HEADER_SIZE = 6
const val HEADER_SIZE = 5
}
}
@@ -1,6 +1,6 @@
package com.futo.platformplayer.sync.internal
class SyncStream(expectedSize: Int, val opcode: UByte, val subOpcode: UByte) {
class SyncStream(expectedSize: Int, val opcode: UByte) {
companion object {
const val MAXIMUM_SIZE = 10_000_000
}
@@ -1,14 +0,0 @@
package com.futo.platformplayer.sync.models
import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.SubscriptionGroup
import kotlinx.serialization.Serializable
import java.time.OffsetDateTime
import java.util.Dictionary
@Serializable
class SyncPlaylistsPackage(
var playlists: List<Playlist>,
var playlistRemovals: Map<String, Long>
)
@@ -1,13 +0,0 @@
package com.futo.platformplayer.sync.models
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.SubscriptionGroup
import kotlinx.serialization.Serializable
import java.time.OffsetDateTime
import java.util.Dictionary
@Serializable
class SyncSubscriptionGroupsPackage(
var groups: List<SubscriptionGroup>,
var groupRemovals: Map<String, Long>
)
@@ -1,18 +0,0 @@
package com.futo.platformplayer.sync.models
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.SubscriptionGroup
import kotlinx.serialization.Serializable
import java.time.OffsetDateTime
import java.util.Dictionary
@Serializable
class SyncWatchLaterPackage(
var videos: List<SerializedPlatformVideo>,
var videoAdds: Map<String, Long>,
var videoRemovals: Map<String, Long>,
var reorderTime: Long = 0,
var ordering: List<String>? = null
)
@@ -12,7 +12,6 @@ import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
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.ratings.RatingLikeDislikes
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
@@ -25,7 +24,6 @@ import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.views.LoaderView
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.pills.PillButton
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
@@ -48,9 +46,6 @@ class CommentViewHolder : ViewHolder {
private val _layoutComment: ConstraintLayout;
private val _buttonDelete: FrameLayout;
private val _containerComments: ConstraintLayout;
private val _loader: LoaderView;
var onRepliesClick = Event1<IPlatformComment>();
var onDelete = Event1<IPlatformComment>();
var onAuthorClick = Event1<IPlatformComment>();
@@ -72,9 +67,6 @@ class CommentViewHolder : ViewHolder {
_pillRatingLikesDislikes = itemView.findViewById(R.id.rating);
_buttonDelete = itemView.findViewById(R.id.button_delete);
_containerComments = itemView.findViewById(R.id.comment_container);
_loader = itemView.findViewById(R.id.loader);
_pillRatingLikesDislikes.onLikeDislikeUpdated.subscribe { args ->
val c = comment
if (c !is PolycentricPlatformComment) {
@@ -131,33 +123,6 @@ class CommentViewHolder : ViewHolder {
}
fun bind(comment: IPlatformComment, readonly: Boolean) {
if(comment is LazyComment){
if(comment.isAvailable)
{
comment.getUnderlyingComment()?.let {
bind(it, readonly);
}
return;
}
else {
_loader.visibility = View.VISIBLE;
_loader.start();
_containerComments.visibility = View.GONE;
comment.setUIHandler {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
if (it.isAvailable && it == this@CommentViewHolder.comment)
bind(it, readonly);
}
}
}
}
else {
_loader.stop();
_loader.visibility = View.GONE;
_containerComments.visibility = View.VISIBLE;
}
_creatorThumbnail.setThumbnail(comment.author.thumbnail, false);
val polycentricComment = if (comment is PolycentricPlatformComment) comment else null
_creatorThumbnail.setHarborAvailable(polycentricComment != null,false, polycentricComment?.eventPointer?.system?.toProto());
@@ -10,7 +10,6 @@ import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.dp
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.Announcement
@@ -24,7 +23,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class AnnouncementView : LinearLayout {
private val _root: FrameLayout;
private val _root: ConstraintLayout;
private val _textTitle: TextView;
private val _textCounter: TextView;
private val _textBody: TextView;
@@ -36,8 +35,6 @@ class AnnouncementView : LinearLayout {
private val _category: String?;
private var _currentAnnouncement: Announcement? = null;
val onClose = Event0();
private val _scope: CoroutineScope?;
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
@@ -45,6 +42,9 @@ class AnnouncementView : LinearLayout {
_scope = findViewTreeLifecycleOwner()?.lifecycleScope ?: StateApp.instance.scopeOrNull; //TODO: Fetch correct scope
val dp10 = 10.dp(resources);
setPadding(dp10, dp10, dp10, dp10);
_root = findViewById(R.id.root);
_textTitle = findViewById(R.id.text_title);
_textCounter = findViewById(R.id.text_counter);
@@ -101,10 +101,6 @@ class AnnouncementView : LinearLayout {
setAnnouncement(announcements.firstOrNull(), announcements.size);
}
fun isClosed(): Boolean{
return _currentAnnouncement == null
}
private fun setAnnouncement(announcement: Announcement?, count: Int) {
if(count == 0 && announcement == null)
Logger.i(TAG, "setAnnouncement announcement=$announcement count=$count");
@@ -112,12 +108,11 @@ class AnnouncementView : LinearLayout {
_currentAnnouncement = announcement;
if (announcement == null) {
_root.visibility = View.GONE
onClose.emit()
_root.visibility = View.GONE;
return;
}
_root.visibility = View.VISIBLE
_root.visibility = View.VISIBLE;
_textTitle.text = announcement.title;
_textBody.text = announcement.msg;
@@ -16,117 +16,73 @@ import com.futo.platformplayer.timestampRegex
import kotlinx.coroutines.runBlocking
class NonScrollingTextView : androidx.appcompat.widget.AppCompatTextView {
private var _lastTouchedLinks: Array<URLSpan>? = null
private var downX = 0f
private var downY = 0f
private var linkPressed = false
private val touchSlop = 20
constructor(context: Context) : super(context) {}
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {}
override fun scrollTo(x: Int, y: Int) {
// do nothing
//do nothing
}
override fun onTouchEvent(event: MotionEvent?): Boolean {
val action = event?.actionMasked
if (event == null) return super.onTouchEvent(event)
val action = event?.action
Logger.i(TAG, "onTouchEvent (action = $action)");
when (action) {
MotionEvent.ACTION_DOWN -> {
val x = event.x.toInt()
val y = event.y.toInt()
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
val x = event.x.toInt()
val y = event.y.toInt()
val layout: Layout? = this.layout
if (layout != null && this.text is Spannable) {
val offset = layout.getOffsetForHorizontal(layout.getLineForVertical(y), x.toFloat())
val text = this.text as Spannable
val layout: Layout? = this.layout
if (layout != null) {
val line = layout.getLineForVertical(y)
val offset = layout.getOffsetForHorizontal(line, x.toFloat())
val text = this.text
if (text is Spannable) {
val links = text.getSpans(offset, offset, URLSpan::class.java)
if (links.isNotEmpty()) {
parent?.requestDisallowInterceptTouchEvent(true)
_lastTouchedLinks = links
downX = event.x
downY = event.y
linkPressed = true
return true
} else {
linkPressed = false
_lastTouchedLinks = null
}
}
}
MotionEvent.ACTION_MOVE -> {
if (linkPressed) {
val dx = event.x - downX
val dy = event.y - downY
if (Math.abs(dx) > touchSlop || Math.abs(dy) > touchSlop) {
linkPressed = false
_lastTouchedLinks = null
parent?.requestDisallowInterceptTouchEvent(false)
return false
}
return true
}
}
MotionEvent.ACTION_UP -> {
if (linkPressed && _lastTouchedLinks != null) {
val dx = event.x - downX
val dy = event.y - downY
if (Math.abs(dx) <= touchSlop && Math.abs(dy) <= touchSlop && isTouchInside(event)) {
runBlocking {
for (link in _lastTouchedLinks!!) {
Logger.i(PlatformLinkMovementMethod.TAG) { "Link clicked '${link.url}'." }
val c = context
for (link in links) {
Logger.i(PlatformLinkMovementMethod.TAG) { "Link clicked '${link.url}'." };
val c = context;
if (c is MainActivity) {
if (c.handleUrl(link.url)) continue
if (c.handleUrl(link.url)) {
continue;
}
if (timestampRegex.matches(link.url)) {
val tokens = link.url.split(':')
var time_s = -1L
when (tokens.size) {
2 -> time_s = tokens[0].toLong() * 60 + tokens[1].toLong()
3 -> time_s = tokens[0].toLong() * 3600 +
tokens[1].toLong() * 60 +
tokens[2].toLong()
val tokens = link.url.split(':');
var time_s = -1L;
if (tokens.size == 2) {
time_s = tokens[0].toLong() * 60 + tokens[1].toLong();
} else if (tokens.size == 3) {
time_s = tokens[0].toLong() * 60 * 60 + tokens[1].toLong() * 60 + tokens[2].toLong();
}
if (time_s != -1L) {
MediaControlReceiver.onSeekToReceived.emit(time_s * 1000)
continue
MediaControlReceiver.onSeekToReceived.emit(time_s * 1000);
continue;
}
}
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
} else {
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)));
}
}
}
_lastTouchedLinks = null
linkPressed = false
return true
} else {
linkPressed = false
_lastTouchedLinks = null
}
}
}
MotionEvent.ACTION_CANCEL -> {
linkPressed = false
_lastTouchedLinks = null
}
}
super.onTouchEvent(event)
return false
}
private fun isTouchInside(event: MotionEvent): Boolean {
return event.x >= 0 && event.x <= width && event.y >= 0 && event.y <= height
}
companion object {
private const val TAG = "NonScrollingTextView"
}
}
}
@@ -6,8 +6,6 @@ import android.widget.FrameLayout
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
@@ -55,30 +53,14 @@ class VideoListEditorView : FrameLayout {
};
adapterVideos.onRemove.subscribe { v ->
val executeDelete = {
synchronized(_videos) {
val index = _videos.indexOf(v);
if(index >= 0) {
_videos.removeAt(index);
onVideoRemoved.emit(v);
}
adapterVideos.notifyItemRemoved(index);
synchronized(_videos) {
val index = _videos.indexOf(v);
if(index >= 0) {
_videos.removeAt(index);
onVideoRemoved.emit(v);
}
adapterVideos.notifyItemRemoved(index);
}
if (Settings.instance.other.playlistDeleteConfirmation) {
UIDialogs.showConfirmationDialog(context, "Please confirm to delete", action = {
executeDelete()
}, cancelAction = {
}, doNotAskAgainAction = {
Settings.instance.other.playlistDeleteConfirmation = false
Settings.instance.save()
})
} else {
executeDelete()
}
};
adapterVideos.onClick.subscribe(onVideoClicked::emit);
@@ -90,7 +90,7 @@ class PillRatingLikesDislikes : LinearLayout {
setRating(rating, hasLiked, hasDisliked);
}
is RatingLikes -> {
setRating(rating, hasLiked);
setRating(rating, hasLiked, hasDisliked);
}
else -> {
throw Exception("Unknown rating type");
@@ -98,36 +98,6 @@ class PillRatingLikesDislikes : LinearLayout {
}
}
fun setRating(rating: RatingLikeDislikes, hasLiked: Boolean = false, hasDisliked: Boolean = false) {
setLoading(false)
_textLikes.text = rating.likes.toHumanNumber();
_textDislikes.text = rating.dislikes.toHumanNumber();
_textLikes.visibility = View.VISIBLE;
_textDislikes.visibility = View.VISIBLE;
_seperator.visibility = View.VISIBLE;
_iconDislikes.visibility = View.VISIBLE;
_likes = rating.likes;
_dislikes = rating.dislikes;
_hasLiked = hasLiked;
_hasDisliked = hasDisliked;
updateColors();
}
fun setRating(rating: RatingLikes, hasLiked: Boolean = false) {
setLoading(false)
_textLikes.text = rating.likes.toHumanNumber();
_textLikes.visibility = View.VISIBLE;
_textDislikes.visibility = View.GONE;
_seperator.visibility = View.GONE;
_iconDislikes.visibility = View.GONE;
_likes = rating.likes;
_dislikes = 0;
_hasLiked = hasLiked;
_hasDisliked = false;
updateColors();
}
fun like(processHandle: ProcessHandle) {
if (_hasDisliked) {
_dislikes--;
@@ -185,4 +155,34 @@ class PillRatingLikesDislikes : LinearLayout {
_iconDislikes.setColorFilter(ContextCompat.getColor(context, R.color.white));
}
}
fun setRating(rating: RatingLikeDislikes, hasLiked: Boolean = false, hasDisliked: Boolean = false) {
setLoading(false)
_textLikes.text = rating.likes.toHumanNumber();
_textDislikes.text = rating.dislikes.toHumanNumber();
_textLikes.visibility = View.VISIBLE;
_textDislikes.visibility = View.VISIBLE;
_seperator.visibility = View.VISIBLE;
_iconDislikes.visibility = View.VISIBLE;
_likes = rating.likes;
_dislikes = rating.dislikes;
_hasLiked = hasLiked;
_hasDisliked = hasDisliked;
updateColors();
}
fun setRating(rating: RatingLikes, hasLiked: Boolean = false) {
setLoading(false)
_textLikes.text = rating.likes.toHumanNumber();
_textLikes.visibility = View.VISIBLE;
_textDislikes.visibility = View.GONE;
_seperator.visibility = View.GONE;
_iconDislikes.visibility = View.GONE;
_likes = rating.likes;
_dislikes = 0;
_hasLiked = hasLiked;
_hasDisliked = false;
updateColors();
}
}
@@ -14,7 +14,6 @@ import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
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.IAsyncPager
@@ -268,13 +267,9 @@ class CommentsList : ConstraintLayout {
}
fun replaceComment(c: PolycentricPlatformComment, newComment: PolycentricPlatformComment) {
val index = _comments.indexOfFirst { it == c || (it is LazyComment && it.getUnderlyingComment() == c) };
if (index >= 0) {
_comments[index] = newComment;
_adapterComments.notifyItemChanged(_adapterComments.childToParentPosition(index));
} else {
Logger.w(TAG, "Parent comment not found")
}
val index = _comments.indexOf(c);
_comments[index] = newComment;
_adapterComments.notifyItemChanged(_adapterComments.childToParentPosition(index));
}
companion object {
@@ -20,6 +20,7 @@ import androidx.annotation.OptIn
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.core.view.setMargins
import androidx.media3.common.C
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.VideoSize
import androidx.media3.common.util.UnstableApi
@@ -110,9 +111,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
private val _author_fullscreen: TextView;
private var _shouldRestartHideJobOnPlaybackStateChange: Boolean = false;
private var _lastSourceFit: Float? = null;
private var _lastWindowWidth: Int = resources.configuration.screenWidthDp
private var _lastWindowHeight: Int = resources.configuration.screenHeightDp
private var _lastSourceFit: Int? = null;
private var _originalBottomMargin: Int = 0;
private var _isControlsLocked: Boolean = false;
@@ -592,11 +591,6 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
@OptIn(UnstableApi::class)
fun setFullScreen(fullScreen: Boolean) {
// prevent fullscreen before the video has loaded to make sure we know whether it's a vertical or horizontal video
if(exoPlayer?.player?.videoSize?.height ?: 0 == 0 && fullScreen){
return
}
updateRotateLock()
if (isFullScreen == fullScreen) {
@@ -607,7 +601,6 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
val lp = background.layoutParams as ConstraintLayout.LayoutParams;
lp.bottomMargin = 0;
background.layoutParams = lp;
_videoView.setBackgroundColor(Color.parseColor("#FF000000"))
gestureControl.hideControls();
//videoControlsBar.visibility = View.GONE;
@@ -621,7 +614,6 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
val lp = background.layoutParams as ConstraintLayout.LayoutParams;
lp.bottomMargin = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, Resources.getSystem().displayMetrics).toInt();
background.layoutParams = lp;
_videoView.setBackgroundColor(Color.parseColor("#00000000"))
gestureControl.hideControls();
//videoControlsBar.visibility = View.VISIBLE;
@@ -640,7 +632,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
private fun fitOrFill(fullScreen: Boolean) {
if (fullScreen) {
fillHeight(false);
fillHeight();
} else {
fitHeight();
}
@@ -663,7 +655,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
gestureControl.resetZoomPan()
_lastSourceFit = null;
if(isFullScreen)
fillHeight(false);
fillHeight();
else if(_root.layoutParams.height != MATCH_PARENT)
fitHeight(videoSize);
}
@@ -726,72 +718,58 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
//Sizing
@OptIn(UnstableApi::class)
fun fitHeight(videoSize: VideoSize? = null) {
Logger.i(TAG, "Video Fit Height")
if (_originalBottomMargin != 0) {
val layoutParams = _videoView.layoutParams as ConstraintLayout.LayoutParams
layoutParams.setMargins(0, 0, 0, _originalBottomMargin)
_videoView.layoutParams = layoutParams
fun fitHeight(videoSize : VideoSize? = null){
Logger.i(TAG, "Video Fit Height");
if(_originalBottomMargin != 0) {
val layoutParams = _videoView.layoutParams as ConstraintLayout.LayoutParams;
layoutParams.setMargins(0, 0, 0, _originalBottomMargin);
_videoView.layoutParams = layoutParams;
}
var h = videoSize?.height ?: lastVideoSource?.height ?: exoPlayer?.player?.videoSize?.height
?: 0
var w =
videoSize?.width ?: lastVideoSource?.width ?: exoPlayer?.player?.videoSize?.width ?: 0
var h = videoSize?.height ?: lastVideoSource?.height ?: exoPlayer?.player?.videoSize?.height ?: 0;
var w = videoSize?.width ?: lastVideoSource?.width ?: exoPlayer?.player?.videoSize?.width ?: 0;
if (h == 0 && w == 0) {
Logger.i(
TAG,
"UNKNOWN VIDEO FIT: (videoSize: ${videoSize != null}, player.videoSize: ${exoPlayer?.player?.videoSize != null})"
);
w = 1280
h = 720
if(h == 0 && w == 0) {
Logger.i(TAG, "UNKNOWN VIDEO FIT: (videoSize: ${videoSize != null}, player.videoSize: ${exoPlayer?.player?.videoSize != null})");
w = 1280;
h = 720;
}
val configuration = resources.configuration
val windowWidth = configuration.screenWidthDp
val windowHeight = configuration.screenHeightDp
if(_lastSourceFit == null){
val metrics = StateApp.instance.displayMetrics ?: resources.displayMetrics;
if (_lastSourceFit == null || windowWidth != _lastWindowWidth || windowHeight != _lastWindowHeight) {
val maxHeight = windowHeight * 0.4f
val minHeight = windowHeight * 0.1f
val viewWidth = Math.min(metrics.widthPixels, metrics.heightPixels); //TODO: Get parent width. was this.width
val deviceHeight = Math.max(metrics.widthPixels, metrics.heightPixels);
val maxHeight = deviceHeight * 0.4;
val determinedHeight = windowWidth / w.toFloat() * h.toFloat()
_lastSourceFit = determinedHeight
_lastSourceFit = _lastSourceFit!!.coerceAtLeast(minHeight)
_lastSourceFit = _lastSourceFit!!.coerceAtMost(maxHeight)
_desiredResizeModePortrait = if (_lastSourceFit != determinedHeight)
AspectRatioFrameLayout.RESIZE_MODE_FIT
val determinedHeight = if(w > h)
((h * (viewWidth.toDouble() / w)).toInt())
else
AspectRatioFrameLayout.RESIZE_MODE_ZOOM
_lastWindowWidth = windowWidth
_lastWindowHeight = windowHeight
((h * (viewWidth.toDouble() / w)).toInt());
_lastSourceFit = determinedHeight;
_lastSourceFit = Math.max(_lastSourceFit!!, 250);
_lastSourceFit = Math.min(_lastSourceFit!!, maxHeight.toInt());
if((_lastSourceFit ?: 0) < 300 || (_lastSourceFit ?: 0) > viewWidth) {
Log.d(TAG, "WEIRD HEIGHT DETECTED: ${_lastSourceFit}, Width: ${w}, Height: ${h}, VWidth: ${viewWidth}");
}
if(_lastSourceFit != determinedHeight)
_desiredResizeModePortrait = AspectRatioFrameLayout.RESIZE_MODE_FIT;
else
_desiredResizeModePortrait = AspectRatioFrameLayout.RESIZE_MODE_ZOOM;
_videoView.resizeMode = _desiredResizeModePortrait
}
_videoView.resizeMode = _desiredResizeModePortrait
val marginBottom =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 7f, resources.displayMetrics)
val height = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
_lastSourceFit!!,
resources.displayMetrics
)
val rootParams = LayoutParams(LayoutParams.MATCH_PARENT, (height + marginBottom).toInt())
rootParams.bottomMargin = marginBottom.toInt()
val marginBottom = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 7f, resources.displayMetrics).toInt();
val rootParams = LayoutParams(LayoutParams.MATCH_PARENT, _lastSourceFit!! + marginBottom)
rootParams.bottomMargin = marginBottom;
_root.layoutParams = rootParams
isFitMode = true
isFitMode = true;
}
@OptIn(UnstableApi::class)
fun fillHeight(isMiniPlayer: Boolean) {
fun fillHeight(){
Logger.i(TAG, "Video Fill Height");
val layoutParams = _videoView.layoutParams as ConstraintLayout.LayoutParams;
_originalBottomMargin =
if (layoutParams.bottomMargin > 0) layoutParams.bottomMargin else _originalBottomMargin;
_originalBottomMargin = if(layoutParams.bottomMargin > 0) layoutParams.bottomMargin else _originalBottomMargin;
layoutParams.setMargins(0);
_videoView.layoutParams = layoutParams;
_videoView.invalidate();
@@ -799,11 +777,6 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
val rootParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
_root.layoutParams = rootParams;
_root.invalidate();
if(isMiniPlayer){
_videoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM
}
isFitMode = false;
}
@@ -813,12 +786,17 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
}
fun updateRotateLock() {
_control_rotate_lock.visibility = View.VISIBLE;
_control_rotate_lock_fullscreen.visibility = View.VISIBLE;
if(Settings.instance.playback.autoRotate == 0) {
_control_rotate_lock.visibility = View.GONE;
_control_rotate_lock_fullscreen.visibility = View.GONE;
}
else {
_control_rotate_lock.visibility = View.VISIBLE;
_control_rotate_lock_fullscreen.visibility = View.VISIBLE;
}
if(StatePlayer.instance.rotationLock) {
_control_rotate_lock_fullscreen.setImageResource(R.drawable.ic_screen_lock_rotation_active);
_control_rotate_lock.setImageResource(R.drawable.ic_screen_lock_rotation_active);
_control_rotate_lock_fullscreen.setImageResource(R.drawable.ic_screen_rotation);
_control_rotate_lock.setImageResource(R.drawable.ic_screen_rotation);
}
else {
_control_rotate_lock_fullscreen.setImageResource(R.drawable.ic_screen_lock_rotation);
@@ -3,11 +3,14 @@ package com.futo.platformplayer.views.video
import android.content.Context
import android.net.Uri
import android.util.AttributeSet
import android.util.Xml
import android.widget.RelativeLayout
import androidx.annotation.OptIn
import androidx.fragment.app.findFragment
import androidx.lifecycle.coroutineScope
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.media3.common.C
import androidx.media3.common.C.Encoding
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
@@ -19,9 +22,9 @@ import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.HttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.dash.DashMediaSource
import androidx.media3.exoplayer.dash.manifest.DashManifest
import androidx.media3.exoplayer.dash.manifest.DashManifestParser
import androidx.media3.exoplayer.drm.DefaultDrmSessionManager
import androidx.media3.exoplayer.drm.HttpMediaDrmCallback
import androidx.media3.exoplayer.drm.DefaultDrmSessionManagerProvider
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.MergingMediaSource
@@ -31,18 +34,18 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.AudioUrlSource
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.IAudioUrlWidevineSource
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestWidevineSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlWidevineSource
import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource
@@ -52,13 +55,15 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestAudioSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlSource
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.engine.dev.V8RemoteObject
import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.video.PlayerManager
import com.futo.platformplayer.views.video.datasources.PluginMediaDrmCallback
import com.futo.platformplayer.views.video.datasources.JSHttpDataSource
import com.google.gson.Gson
import getHttpDataSourceFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -322,8 +327,8 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
return _chapters?.let { chaps -> chaps.find { pos.toDouble() / 1000 > it.timeStart && pos.toDouble() / 1000 < it.timeEnd && (toIgnore.isEmpty() || !toIgnore.contains(it)) } };
}
fun setSource(videoSource: IVideoSource?, audioSource: IAudioSource? = null, play: Boolean = false, keepSubtitles: Boolean = false, resume: Boolean = false) {
swapSources(videoSource, audioSource,resume, play, keepSubtitles);
fun setSource(videoSource: IVideoSource?, audioSource: IAudioSource? = null, play: Boolean = false, keepSubtitles: Boolean = false) {
swapSources(videoSource, audioSource,false, play, keepSubtitles);
}
fun swapSources(videoSource: IVideoSource?, audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true, keepSubtitles: Boolean = false): Boolean {
var videoSourceUsed = videoSource;
@@ -412,11 +417,9 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
val didSet = when(videoSource) {
is LocalVideoSource -> { swapVideoSourceLocal(videoSource); true; }
is JSVideoUrlRangeSource -> { swapVideoSourceUrlRange(videoSource); true; }
is IDashManifestWidevineSource -> { swapVideoSourceDashWidevine(videoSource); true }
is IDashManifestSource -> { swapVideoSourceDash(videoSource); true;}
is JSDashManifestRawSource -> swapVideoSourceDashRaw(videoSource, play, resume);
is IHLSManifestSource -> { swapVideoSourceHLS(videoSource); true; }
is IVideoUrlWidevineSource -> { swapVideoSourceUrlWidevine(videoSource); true; }
is IVideoUrlSource -> { swapVideoSourceUrl(videoSource); true; }
null -> { _lastVideoMediaSource = null; true;}
else -> throw IllegalArgumentException("Unsupported video source [${videoSource.javaClass.simpleName}]");
@@ -481,32 +484,6 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
.createMediaSource(MediaItem.fromUri(videoSource.getVideoUrl()));
}
@OptIn(UnstableApi::class)
private fun swapVideoSourceUrlWidevine(videoSource: IVideoUrlWidevineSource) {
Logger.i(TAG, "Loading VideoSource [UrlWidevine]");
val dataSource = if(videoSource is JSSource && videoSource.requiresCustomDatasource)
videoSource.getHttpDataSourceFactory()
else
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT)
val baseCallback = HttpMediaDrmCallback(videoSource.licenseUri, dataSource)
val callback = if (videoSource.hasLicenseRequestExecutor) {
PluginMediaDrmCallback(baseCallback, videoSource.getLicenseRequestExecutor()!!, videoSource.licenseUri)
} else {
baseCallback
}
_lastVideoMediaSource = ProgressiveMediaSource.Factory(dataSource)
.setDrmSessionManagerProvider {
DefaultDrmSessionManager.Builder()
.setMultiSession(true)
.build(callback)
}
.createMediaSource(
MediaItem.fromUri(videoSource.getVideoUrl())
)
}
@OptIn(UnstableApi::class)
private fun swapVideoSourceDash(videoSource: IDashManifestSource) {
Logger.i(TAG, "Loading VideoSource [Dash]");
val dataSource = if(videoSource is JSSource && (videoSource.requiresCustomDatasource))
@@ -517,25 +494,6 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
.createMediaSource(MediaItem.fromUri(videoSource.url))
}
@OptIn(UnstableApi::class)
private fun swapVideoSourceDashWidevine(videoSource: IDashManifestWidevineSource) {
Logger.i(TAG, "Loading VideoSource [DashWidevine]")
val dataSource =
if (videoSource is JSSource && (videoSource.requiresCustomDatasource)) videoSource.getHttpDataSourceFactory()
else DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT)
val baseCallback = HttpMediaDrmCallback(videoSource.licenseUri, dataSource)
val callback = if (videoSource.hasLicenseRequestExecutor) {
PluginMediaDrmCallback(baseCallback, videoSource.getLicenseRequestExecutor()!!, videoSource.licenseUri)
} else {
baseCallback
}
_lastVideoMediaSource = DashMediaSource.Factory(dataSource).setDrmSessionManagerProvider {
DefaultDrmSessionManager.Builder().setMultiSession(true).build(callback)
}.createMediaSource(MediaItem.fromUri(videoSource.url))
}
@OptIn(UnstableApi::class)
private fun swapVideoSourceDashRaw(videoSource: JSDashManifestRawSource, play: Boolean, resume: Boolean): Boolean {
Logger.i(TAG, "Loading VideoSource [Dash]");
@@ -681,7 +639,6 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
return true;
}
}
@OptIn(UnstableApi::class)
private fun swapAudioSourceUrlWidevine(audioSource: IAudioUrlWidevineSource) {
Logger.i(TAG, "Loading AudioSource [UrlWidevine]")
@@ -690,22 +647,20 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
else
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT)
val baseCallback = HttpMediaDrmCallback(audioSource.licenseUri, dataSource)
val callback = if (audioSource.hasLicenseRequestExecutor) {
PluginMediaDrmCallback(baseCallback, audioSource.getLicenseRequestExecutor()!!, audioSource.licenseUri)
} else {
baseCallback
}
val httpRequestHeaders = mapOf("Authorization" to "Bearer " + audioSource.bearerToken)
val provider = DefaultDrmSessionManagerProvider()
provider.setDrmHttpDataSourceFactory(dataSource)
_lastAudioMediaSource = ProgressiveMediaSource.Factory(dataSource)
.setDrmSessionManagerProvider {
DefaultDrmSessionManager.Builder()
.setMultiSession(true)
.build(callback)
}
.setDrmSessionManagerProvider(provider)
.createMediaSource(
MediaItem.fromUri(audioSource.getAudioUrl())
MediaItem.Builder()
.setUri(audioSource.getAudioUrl()).setDrmConfiguration(
MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID)
.setLicenseUri(audioSource.licenseUri)
.setMultiSession(true)
.setLicenseRequestHeaders(httpRequestHeaders)
.build()
).build()
)
}
@@ -360,7 +360,7 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
if(executor != null) {
try {
Logger.Companion.i(TAG, "Executor for " + dataSpec.uri.toString(), null);
byte[] data = executor.executeRequest("GET", dataSpec.uri.toString(), null, dataSpec.httpRequestHeaders);
byte[] data = executor.executeRequest(dataSpec.uri.toString(), dataSpec.httpRequestHeaders);
Logger.Companion.i(TAG, "Executor result for " + dataSpec.uri.toString() + " : " + data.length, null);
if (data == null)
throw new HttpDataSourceException(
@@ -1,23 +0,0 @@
package com.futo.platformplayer.views.video.datasources
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.drm.ExoMediaDrm
import androidx.media3.exoplayer.drm.MediaDrmCallback
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
import java.util.UUID
import kotlin.io.encoding.ExperimentalEncodingApi
@UnstableApi
class PluginMediaDrmCallback(
private val delegate: MediaDrmCallback,
private val requestExecutor: JSRequestExecutor,
private val licenseUrl: String
) : MediaDrmCallback by delegate {
@ExperimentalEncodingApi
override fun executeKeyRequest(uuid: UUID, request: ExoMediaDrm.KeyRequest): ByteArray {
val pluginResponse = requestExecutor.executeRequest("POST", licenseUrl, request.data, mapOf())
return pluginResponse
}
}
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@color/colorPrimary"
android:pathData="M35.25,23.3 L36.4,22.2 38.6,24.45Q39.5,25.3 39.5,26.55Q39.5,27.8 38.6,28.65L32.5,34.75Q31.6,35.6 30.375,35.6Q29.15,35.6 28.3,34.75L13.8,20.25Q12.95,19.4 12.95,18.15Q12.95,16.9 13.8,16.1L19.95,9.95Q20.8,9.1 22.05,9.1Q23.3,9.1 24.1,9.95L26.45,12.25L25.3,13.35L22.9,10.95Q22.55,10.6 22.025,10.6Q21.5,10.6 21.15,10.95L14.85,17.25Q14.5,17.6 14.5,18.15Q14.5,18.7 14.85,19.05L29.5,33.75Q29.85,34.1 30.4,34.1Q30.95,34.1 31.25,33.75L37.6,27.4Q37.95,27.05 37.95,26.525Q37.95,26 37.6,25.65ZM26.15,44.45Q21.6,44.45 17.6,42.725Q13.6,41 10.625,38.025Q7.65,35.05 5.925,31.025Q4.2,27 4.2,22.5H5.75Q5.75,26.65 7.325,30.35Q8.9,34.05 11.65,36.825Q14.4,39.6 18.125,41.2Q21.85,42.8 26,42.85L18.55,35.35L19.65,34.25L29.5,44.1Q28.6,44.25 27.8,44.35Q27,44.45 26.15,44.45ZM32.4,18Q31.7,18 31.1,17.4Q30.5,16.8 30.5,16.1V10.85Q30.5,10.15 31.1,9.525Q31.7,8.9 32.4,8.9H32.55V6.85Q32.55,5.4 33.575,4.4Q34.6,3.4 36.05,3.4Q37.55,3.4 38.55,4.4Q39.55,5.4 39.55,6.85V8.9H39.75Q40.45,8.9 41,9.525Q41.55,10.15 41.55,10.85V16.1Q41.55,16.8 40.975,17.4Q40.4,18 39.65,18ZM34.05,8.9H38.05V6.85Q38.05,6 37.475,5.425Q36.9,4.85 36.05,4.85Q35.2,4.85 34.625,5.425Q34.05,6 34.05,6.85ZM26.25,22.35Q26.25,22.35 26.25,22.35Q26.25,22.35 26.25,22.35Q26.25,22.35 26.25,22.35Q26.25,22.35 26.25,22.35Q26.25,22.35 26.25,22.35Q26.25,22.35 26.25,22.35Q26.25,22.35 26.25,22.35Q26.25,22.35 26.25,22.35Q26.25,22.35 26.25,22.35Q26.25,22.35 26.25,22.35Z"/>
</vector>
@@ -22,7 +22,6 @@
android:id="@+id/button_back"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:contentDescription="@string/cd_button_back"
android:paddingRight="20dp"
app:srcCompat="@drawable/ic_back_thin_white_16dp" />
@@ -22,7 +22,6 @@
android:id="@+id/button_back"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:contentDescription="@string/cd_button_back"
android:paddingRight="20dp"
app:srcCompat="@drawable/ic_back_thin_white_16dp" />
-1
View File
@@ -22,7 +22,6 @@
android:id="@+id/button_back"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:contentDescription="@string/cd_button_back"
android:paddingRight="20dp"
app:srcCompat="@drawable/ic_back_thin_white_16dp" />
@@ -10,7 +10,6 @@
android:id="@+id/button_back"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_button_back"
android:padding="10dp"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_back_thin_white_16dp"
@@ -14,7 +14,6 @@
android:id="@+id/button_close"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_button_close"
android:scaleType="fitCenter"
android:padding="10dp"
app:layout_constraintLeft_toLeftOf="parent"
@@ -75,7 +75,6 @@
android:id="@+id/incognito_button"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_incognito_button"
android:src="@drawable/ic_disabled_visible_purple"
android:background="@drawable/background_button_round_black"
android:scaleType="fitCenter"
@@ -21,7 +21,6 @@
android:id="@+id/button_back"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:contentDescription="@string/cd_button_back"
android:paddingRight="20dp"
app:srcCompat="@drawable/ic_back_thin_white_16dp" />
@@ -9,7 +9,6 @@
android:id="@+id/button_back"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_button_back"
android:padding="10dp"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_back_thin_white_16dp"
@@ -20,7 +19,6 @@
android:id="@+id/button_help"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/cd_button_help"
app:srcCompat="@drawable/ic_help"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
<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"
@@ -10,111 +9,96 @@
android:id="@+id/button_back"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_button_back"
android:padding="10dp"
android:scaleType="fitCenter"
app:layout_constraintLeft_toLeftOf="parent"
app:srcCompat="@drawable/ic_back_thin_white_16dp"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_back_thin_white_16dp" />
app:layout_constraintLeft_toLeftOf="parent" />
<ImageButton
android:id="@+id/button_help"
android:layout_width="50dp"
android:layout_height="50dp"
app:srcCompat="@drawable/ic_help"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_help" />
app:layout_constraintTop_toTopOf="parent" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0px"
<ImageView
android:id="@+id/image_polycentric"
android:layout_width="80dp"
android:layout_height="80dp"
app:srcCompat="@drawable/neopass"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/button_help"
app:layout_constraintBottom_toBottomOf="parent">
android:layout_marginTop="40dp"/>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
<TextView
android:id="@+id/text_polycentric"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/polycentric"
android:fontFamily="@font/inter_light"
android:textSize="32dp"
android:layout_marginTop="12dp"
app:layout_constraintTop_toBottomOf="@id/image_polycentric"
app:layout_constraintLeft_toLeftOf="@id/image_polycentric"
app:layout_constraintRight_toRightOf="@id/image_polycentric" />
<TextView
android:id="@+id/text_profile_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/profile_name"
android:fontFamily="@font/inter_light"
android:textSize="16dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toTopOf="@id/edit_profile_name"
app:layout_constraintLeft_toLeftOf="@id/edit_profile_name" />
<EditText
android:id="@+id/edit_profile_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/this_will_be_visible_to_other_users"
android:layout_marginStart="40dp"
android:layout_marginEnd="40dp"
android:layout_marginTop="60dp"
android:background="@drawable/background_16_round_4dp"
android:singleLine="true"
app:layout_constraintTop_toBottomOf="@id/text_polycentric"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toTopOf="@id/button_create_profile"/>
<LinearLayout
android:id="@+id/button_create_profile"
android:layout_width="140dp"
android:layout_height="40dp"
android:background="@drawable/background_button_primary_round"
android:gravity="center"
android:layout_marginTop="40dp"
android:layout_marginBottom="10dp"
android:orientation="horizontal"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/edit_profile_name">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/black">
android:fontFamily="@font/inter_light"
android:textColor="@color/white"
android:textSize="16dp"
android:text="@string/create_profile" />
</LinearLayout>
<com.futo.platformplayer.views.LoaderView
android:id="@+id/loader"
android:layout_width="match_parent"
android:layout_height="50dp"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/button_create_profile"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
<ImageView
android:id="@+id/image_polycentric"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_marginTop="40dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/neopass" />
<TextView
android:id="@+id/text_polycentric"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:fontFamily="@font/inter_light"
android:text="@string/polycentric"
android:textSize="32dp"
app:layout_constraintLeft_toLeftOf="@id/image_polycentric"
app:layout_constraintRight_toRightOf="@id/image_polycentric"
app:layout_constraintTop_toBottomOf="@id/image_polycentric" />
<TextView
android:id="@+id/text_profile_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:fontFamily="@font/inter_light"
android:text="@string/profile_name"
android:textSize="16dp"
app:layout_constraintBottom_toTopOf="@id/edit_profile_name"
app:layout_constraintLeft_toLeftOf="@id/edit_profile_name" />
<EditText
android:id="@+id/edit_profile_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="40dp"
android:layout_marginTop="60dp"
android:layout_marginEnd="40dp"
android:background="@drawable/background_16_round_4dp"
android:hint="@string/this_will_be_visible_to_other_users"
android:singleLine="true"
app:layout_constraintBottom_toTopOf="@id/button_create_profile"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_polycentric" />
<LinearLayout
android:id="@+id/button_create_profile"
android:layout_width="140dp"
android:layout_height="40dp"
android:layout_marginTop="40dp"
android:layout_marginBottom="10dp"
android:background="@drawable/background_button_primary_round"
android:gravity="center"
android:orientation="horizontal"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/edit_profile_name">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/inter_light"
android:text="@string/create_profile"
android:textColor="@color/white"
android:textSize="16dp" />
</LinearLayout>
<com.futo.platformplayer.views.LoaderView
android:id="@+id/loader"
android:layout_width="match_parent"
android:layout_height="50dp"
android:visibility="gone"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/button_create_profile" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -9,7 +9,6 @@
android:id="@+id/button_back"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_button_back"
android:padding="10dp"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_back_thin_white_16dp"
@@ -20,7 +19,6 @@
android:id="@+id/button_help"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/cd_button_help"
app:srcCompat="@drawable/ic_help"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />

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