Compare commits

...

82 Commits

Author SHA1 Message Date
Trevor 6fa97a21f6 merge master 2025-10-01 13:34:16 -05:00
Trevor b71d1d3cd6 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-10-01 13:27:51 -05:00
Trevor 3ab38c7e4e changed from global to coroutine 2025-10-01 13:14:25 -05:00
Trevor 1abcc92a3b addressing pr comments 2025-10-01 13:11:59 -05:00
Kelvin 642d218c54 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-09-29 18:45:56 +02:00
Kelvin 26b5470200 Fix crash fix on async promise handling 2025-09-29 18:45:42 +02:00
Koen J 547fe7bc13 Updated target SDK. 2025-09-29 15:46:45 +02:00
Koen J 678305e366 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-09-29 15:28:31 +02:00
Koen J 9f07673d85 Updated compile SDK. 2025-09-29 15:27:51 +02:00
Kelvin 19429263a9 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-09-29 15:01:19 +02:00
Kelvin 986652adab Refs 2025-09-29 15:01:04 +02:00
Koen J 4d93a58d5d Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-09-29 13:15:58 +02:00
Koen J 817c90f3af Translation fix. 2025-09-29 13:15:06 +02:00
Kelvin 77348b3787 Refs 2025-09-29 13:06:42 +02:00
Kelvin 31e26d03c6 Merge 2025-09-29 12:45:00 +02:00
Kelvin 1ef566ab16 Async fixes, local file playback support 2025-09-29 12:31:17 +02:00
Koen J 7597f5136c Fix Android getting stuck. 2025-09-26 13:46:43 +02:00
Koen 9a2a70622f Merge branch 'marcus/fcast-casting-sdk' into 'master'
Experimental casting backend

See merge request videostreaming/grayjay!145
2025-09-10 15:26:16 +00:00
Marcus Hanestad 4fc33411fd Experimental casting backend 2025-09-10 15:26:16 +00:00
Kelvin a9bb900994 Change when plugins are disabled on reload and listing reloads 2025-09-08 19:00:41 +02:00
Koen J 8c1a18d8b4 Build fixes. 2025-08-27 09:58:54 +02:00
Koen J 14ae5f1572 Fixed translations to align. 2025-08-26 21:32:13 +02:00
koen-futo ed40994600 Merge pull request #2357 from 0xrxL/master
Italian localization
2025-08-26 21:14:14 +02:00
koen-futo 90e8c35b19 Merge pull request #2096 from alpqn/master
Added Turkish Translations
2025-08-26 21:13:47 +02:00
Kelvin 4d017ad357 Refs 2025-08-24 21:41:07 +02:00
Kelvin 2ca2a9db23 Workaround for global lifetime scope unavailable 2025-08-24 19:35:15 +02:00
Kelvin 713d46c781 Refs 2025-08-21 22:23:07 +02:00
Kelvin 0429665173 Fix title for relay server 2025-08-21 22:18:00 +02:00
Kelvin ac05edca77 Setting to disable short filling 2025-08-21 22:14:30 +02:00
Kelvin ad3dacf68f Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-08-21 22:07:15 +02:00
Kelvin 91a8996c11 Shorts fix video size scaling for some aspect ratios, long press support for tags, home plugin filters now support long press to only select that one 2025-08-21 22:06:52 +02:00
Kelvin 40c4a51a2b Dialog input support, configurable relay server, radio views select/deselect all long press 2025-08-21 20:41:18 +02:00
Kelvin f8e0aaf4d2 Merge branch 'fix-logincall' into 'master'
fix: login prompt looping on search video

See merge request videostreaming/grayjay!144
2025-08-21 16:00:35 +00:00
zvonimir ad97b5a406 fix: login prompt looping on search video 2025-08-21 17:59:08 +02:00
Trevor 25ab7aff92 Improve QR code error text styling to match app design
- Add proper margins (20dp start/end) for better spacing
- Center the text alignment for better visual balance
- Use full width (0dp) with constraints for consistent layout
- Match the styling patterns used elsewhere in the app
- Improves readability and visual consistency when QR code is too large
2025-08-20 11:01:30 -05:00
Trevor d8a0781d10 Fix export dialog to close instead of auto-sharing when clicked outside
- Change default close action from 0 (share) to -1 (no action)
- This prevents the dialog from automatically sharing when users click outside
- Users must now explicitly choose Share or Save to Device to proceed
- Improves user experience by preventing accidental sharing
2025-08-20 10:59:22 -05:00
Trevor 42040e5f3d Center the export dialog buttons
- Add center: true parameter to both Share and Save to Device Action constructors
- This centers the buttons horizontally in the dialog layout
- Buttons are now both equal size and properly centered for better visual balance
- Improves the overall UI appearance and user experience
2025-08-20 10:52:28 -05:00
Trevor f00e71522f Make export dialog buttons equal size
- Change both Share and Save to Device buttons to use ActionStyle.NONE
- This creates two equal-sized buttons side by side instead of primary/secondary styling
- Improves UI consistency and makes both options equally prominent
- Users can now easily choose between sharing or saving without visual hierarchy bias
2025-08-20 10:49:22 -05:00
Trevor 8916fd35ab Add save to device option for Polycentric profile export
- Add dialog with two options: Share or Save to Device
- Implement saveToDevice method using MediaStore API to save to Downloads folder
- Add necessary string resources for the new UI elements
- Use UIDialogs.showDialog with Action and ActionStyle for proper dialog handling
- Add ContentValues and MediaStore imports for file saving functionality

Users can now choose to either share the exported profile file or save it directly to their device's Downloads folder for later use.
2025-08-20 10:34:14 -05:00
Kelvin b0e0c1b75f Merge branch 'PiP-play-pause-fix' into 'master'
PiP Play Pause Fix

See merge request videostreaming/grayjay!143
2025-08-20 13:41:43 +00:00
Kai b1fce443e9 fix pause and play buttons not working correctly in PiP
Changelog: changed
2025-08-20 08:39:12 -04:00
Trevor cfb030f59b Remove debug logging from file sharing implementation
- Remove all debug logging added during troubleshooting
- Keep the essential fix for EXTRA_STREAM Parcelable handling
- Clean up the code while maintaining the working functionality
- Remove verbose logging from handleIntent, handleUrlAll, handleContent, and handleUnknownText

The file sharing functionality is now working correctly and the code is clean.
2025-08-19 11:59:08 -05:00
Trevor 71f626e8e0 Fix file sharing to properly handle EXTRA_STREAM Parcelable
- Add support for EXTRA_STREAM as Parcelable URI instead of just String
- Check for streamParcelable first, then fall back to string extras
- Add logging to show both String and Parcelable EXTRA_STREAM values
- This should fix the issue where file sharing was receiving description text
instead of actual file content

The logs showed that EXTRA_STREAM was null as String but the file content
should be available as a Parcelable URI.
2025-08-19 11:56:30 -05:00
Trevor 6f4d7fd6b9 Add comprehensive debugging for file sharing intent handling
- Add logging for all intent details (action, data, type, extras)
- Add detailed logging for ACTION_SEND intent processing
- Add logging for targetData processing and handleUrlAll calls
- Add logging for URI scheme detection
- This will help identify where the file sharing process is failing

The logs will show the complete flow from intent reception to processing.
2025-08-19 11:54:29 -05:00
Trevor f207ef9954 Add debugging logs for Polycentric file import
- Add logging to handleContent method to show file path and MIME type
- Add logging to show file content length and first 100 characters
- Add logging to handleUnknownText method to show received text content
- Add warning log when text format is not recognized
- This will help debug why Polycentric profile file import isn't working

The logs will show exactly what content is being received when sharing
Polycentric profile files to Grayjay.
2025-08-19 11:51:21 -05:00
Trevor 1ad3b7ae8f Fix Polycentric profile file import in MainActivity
- Update handleUnknownText method to detect Polycentric profile data
- Check for 'polycentric://' prefix in shared text files
- Launch PolycentricImportProfileActivity when Polycentric profile is detected
- Add logging for Polycentric profile detection
- Fixes 'unknown url format' error when sharing Polycentric profile files to Grayjay

This completes the file sharing workflow: users can now export Polycentric
profiles to files and share them to Grayjay, which will automatically
detect and import the profile data.
2025-08-19 11:48:18 -05:00
Trevor 217d738dd1 Add file import functionality to PolycentricImportProfileActivity
- Add 'Import from File' button to the import profile UI
- Add file picker launcher using ActivityResultContracts.GetContent()
- Support importing Polycentric profiles from text files
- Read file content and pass it to the existing import method
- Add string resource for 'Import from File' button text
- Update layout to include file import button between QR scan and manual entry

This allows users to import Polycentric profiles from files exported by
the backup functionality, completing the export/import workflow.
2025-08-19 11:44:01 -05:00
Trevor 799dad8875 Fix FileProvider authority to use package name directly
- Change FileProvider authority from string resource to use packageName directly
- Update AndroidManifest.xml to use ${applicationId}.fileprovider instead of @string/authority
- This ensures the authority matches the actual package name for each build variant
- Fixes the 'Couldn\'t find meta-data for provider' error in debug builds

The issue was that different build flavors have different package names
(com.futo.platformplayer.d for debug, com.futo.platformplayer for stable, etc.)
but the string resource wasn\'t being resolved correctly for all variants.
2025-08-19 11:36:53 -05:00
Trevor dc91987e88 updated file export 2025-08-19 11:23:56 -05:00
Trevor 8f5d90a1c8 Fix UninitializedPropertyAccessException in PolycentricBackupActivity
- Move export bundle creation before try-catch block to ensure _exportBundle is initialized
- Fix crash when accessing _exportBundle in error logging before it's been set
- Use local bundle variable in catch block instead of accessing uninitialized _exportBundle
- Ensure _exportBundle is always available for UI operations and error handling

This resolves the crash that occurred when QR code generation failed and the app
tried to access the uninitialized _exportBundle property.
2025-08-19 11:21:34 -05:00
Trevor 6e58107a5e Fix QR code size validation to use byte count instead of character count
- Replace character-based limit with byte-based limit using UTF-8 encoding
- QR codes are limited by bytes, not characters (1 char can be 1-4 bytes in UTF-8)
- Set limit to 2300 bytes (QR Code Version 40 with Error Correction Level M can hold ~2331 bytes)
- Update error logging to show both character count and byte size for debugging
- Apply fix to both PolycentricBackupActivity and QRCodeFullscreenActivity

This should now correctly handle strings with multi-byte characters and provide
accurate size validation for QR code generation.
2025-08-19 11:19:11 -05:00
Trevor 79f478e421 Fix QR code size limits and add better error logging
- Correct QR code capacity limits for Error Correction Level M (15%)
- Update limit from 2900 to 2300 characters (actual M level capacity is ~2334)
- Add detailed error logging to show bundle length when QR generation fails
- Fix FileProvider authority to use dynamic string resource instead of hardcoded value
- Ensure consistent limits across both PolycentricBackupActivity and QRCodeFullscreenActivity

The previous limit was based on Error Correction Level L (7%) but we use Level M (15%),
which has a lower capacity but better error recovery.
2025-08-19 11:13:45 -05:00
Trevor a20ebd49a4 Add proactive QR code size validation and checks
- Add isContentSuitableForQRCode() helper function with 2900 character limit
- Check QR code size before attempting generation in both activities
- Prevent fullscreen QR viewer from launching when data is too large
- Use conservative 2900 character limit (vs theoretical 2953 max)
- Add size validation in both PolycentricBackupActivity and QRCodeFullscreenActivity
- Improve user experience by failing fast with clear error messages

This ensures QR codes are only generated when they have a reasonable
chance of success, and prevents unnecessary attempts to generate
QR codes that are too large for reliable scanning.
2025-08-19 10:59:16 -05:00
Kelvin 66f8711055 Fix login warnings working on redirects 2025-08-19 17:52:14 +02:00
Trevor 31f0109438 Fix TransactionTooLargeException in QRCodeFullscreenActivity
- Remove bitmap from Intent extras to prevent TransactionTooLargeException
- Pass only QR text through Intent and regenerate bitmap in fullscreen activity
- Add QR code generation logic to QRCodeFullscreenActivity
- Update createIntent method signature to only accept QR text
- Fixes crash when launching fullscreen QR viewer with large bitmaps

This resolves the 800KB+ data parcel size issue that was causing
the activity to fail to launch.
2025-08-19 10:29:16 -05:00
Trevor bbd9ba0a0a fixd import 2025-08-18 16:02:29 -05:00
Trevor bc67f4c486 Simplify Polycentric profile export with file export option
- Remove GZIP compression logic that was causing crashes
- Revert to simple QR code generation with single error correction level
- Add file export button when QR code is too large for scanning
- Add FileProvider configuration for sharing exported files
- Update string resources for new file export functionality
- Keep fullscreen QR code viewer for smaller profiles

This provides a more reliable solution: QR codes work for smaller profiles,
and file export handles large profiles that exceed QR code limits.
2025-08-18 14:26:25 -05:00
Trevor c862b60c71 Fix SettingsActivity crash by using local context instead of global context
- Replace StateApp.instance.initializeFiles() with direct FragmentedStorage initialization
- Use SettingsActivity's own filesDir instead of relying on global context
- Add FragmentedStorage import to resolve compilation errors
- Prevents 'Attempted to use a global context while MainActivity is no longer available' error

This ensures SettingsActivity can initialize files independently without
depending on MainActivity's global context state.
2025-08-13 12:16:26 -05:00
Trevor 1524687f75 Fix SettingsActivity crash when files directory not initialized
- Add StateApp.instance.initializeFiles() calls before accessing Settings
- Fixes crash when SettingsActivity is launched before MainActivity
- Ensures FragmentedStorage is properly initialized before loading files

This prevents the 'Files dir should be initialized before loading a file'
error that was causing the SettingsActivity to crash on startup.
2025-08-13 12:10:40 -05:00
Trevor cb74e82fa1 Add fullscreen QR code viewer for easier scanning
- Create QRCodeFullscreenActivity for large QR code display
- Add click listener to QR code image in PolycentricBackupActivity
- Add visual feedback with ripple effect and hint text
- Add localized strings for fullscreen hint
- Update layout to include hint text below QR code
- Add activity to AndroidManifest.xml

This makes it much easier to scan QR codes by providing
a fullscreen view when tapping the QR code image.
2025-08-13 11:59:56 -05:00
Trevor 4b3e89d0af Fix QR code generation for large polycentric export bundles
- Add GZIP compression for large export data (>2000 chars)
- Implement fallback QR generation with different error correction levels
- Add automatic decompression support in import functionality
- Improve error handling with fallback to text display
- Add localized error messages for QR code failures
- Add compression ratio logging for debugging

This fixes the 'Data too big' error when generating QR codes for
polycentric profile exports by automatically compressing large data
and providing multiple fallback mechanisms.
2025-08-13 11:34:56 -05:00
Trevor 31a34e4583 Merge branch 'master' into ts/polycentric-moderation 2025-08-01 10:14:16 -05:00
Trevor 19b96c2ea1 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-08-01 10:13:19 -05:00
Trevor 545ece59ad fix for new server backfill 2025-08-01 10:13:06 -05:00
quonverbat 940bed2cee Merge branch 'futo-org:master' into master 2025-07-12 22:12:21 +03:00
Trevor c93fc664f6 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-07-08 12:45:10 -05:00
Koen J eb8e9270e0 Merge branch 'ts/polycentric-moderation' of gitlab.futo.org:videostreaming/grayjay into ts/polycentric-moderation 2025-07-08 16:30:49 +02:00
Koen J 7aea4a3f9f Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into ts/polycentric-moderation 2025-07-08 16:30:18 +02:00
Trevor 76ce60d679 fixed comments not deleting in the video player 2025-07-02 14:40:11 -05:00
Trevor 24dbb543ae added blurb to explain moderation to moderation page 2025-07-02 13:06:30 -05:00
Trevor 79a2c6484c updated submodule 2025-07-02 12:09:41 -05:00
Trevor 3aa873f82b moderation does not apply to comments posted by user 2025-07-02 10:49:56 -05:00
Trevor 49f3fa9cb8 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-07-01 15:15:31 -05:00
0xrxL 4eb20a1843 Typo 2025-06-14 15:29:49 +02:00
0xrxL 98c6378148 Fix spacing 2025-06-14 09:04:40 +02:00
0xrxL bb066a7a31 Added italian localization 2025-06-14 09:02:25 +02:00
0xrxL b5d3261f03 Add files via upload 2025-06-14 09:01:41 +02:00
quonverbat 755bebaecb Merge branch 'futo-org:master' into master 2025-05-30 00:07:27 +03:00
Trevor 49367c6c75 added moderation button to polycentric screen and sliders for moderation 2025-04-21 14:37:27 -05:00
Trevor e1cb3308df pre-build 2025-04-08 17:46:43 -05:00
Trevor eb48d6c494 initial polycentric moderation implementation 2025-04-08 17:41:47 -05:00
quonverbat 004e4be4d3 Added Turkish Translations 2025-04-02 00:32:10 +03:00
104 changed files with 5550 additions and 908 deletions
-3
View File
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ea10d3c5562c9f449a4e89e9c3dfcf881ed79a952f3409bc005bcc62c2cf4b81
size 65512557
+8 -2
View File
@@ -39,7 +39,7 @@ protobuf {
android {
namespace 'com.futo.platformplayer'
compileSdk 34
compileSdk 36
flavorDimensions "buildType"
productFlavors {
stable {
@@ -97,7 +97,7 @@ android {
defaultConfig {
minSdk 28
targetSdk 34
targetSdk 35
versionCode gitVersionCode
versionName gitVersionName
@@ -231,4 +231,10 @@ dependencies {
testImplementation "org.mockito:mockito-core:5.4.0"
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
//Rust casting SDK
implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.3.1') {
// Polycentricandroid includes this
exclude group: 'net.java.dev.jna'
}
}
+8
View File
@@ -238,5 +238,13 @@
android:name=".activities.SyncShowPairingCodeActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.PolycentricModerationActivity"
android:exported="false"
android:screenOrientation="portrait" />
<activity
android:name=".activities.QRCodeFullscreenActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
</application>
</manifest>
+1
View File
@@ -67,6 +67,7 @@ class ScriptException extends Error {
super(arguments[0]);
this.plugin_type = "ScriptException";
this.message = arguments[0];
this.msg = arguments[0];
}
else {
super(msg);
@@ -12,6 +12,7 @@ import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.logging.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
@@ -21,7 +22,6 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.selects.SelectClause0
import kotlinx.coroutines.selects.SelectClause1
import java.util.concurrent.CancellationException
import java.util.concurrent.CountDownLatch
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
@@ -194,7 +194,6 @@ fun V8ObjectToHashMap(obj: V8ValueObject?): HashMap<String, String> {
return map;
}
fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
val latch = CountDownLatch(1);
var promiseResult: T? = null;
@@ -204,16 +203,19 @@ fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
override fun onFulfilled(p0: V8Value?) {
if(p0 is V8ValueError)
promiseException = ScriptExecutionException(plugin.config, p0.message);
else
else {
if(p0 is V8ValueObject)
p0.setWeak();
promiseResult = p0 as T;
}
latch.countDown();
}
override fun onRejected(p0: V8Value?) {
promiseException = (NotImplementedError("onRejected promise not implemented.."));
promiseException = p0?.toException(plugin.config);
latch.countDown();
}
override fun onCatch(p0: V8Value?) {
promiseException = (NotImplementedError("onCatch promise not implemented.."));
promiseException = p0?.toException(plugin.config);
latch.countDown();
}
});
@@ -223,8 +225,25 @@ fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
promiseException = CancellationException("Cancelled by system");
latch.countDown();
}
plugin.unbusy {
latch.await();
//Logger.i("V8", "V8ValueBlocking started (Busy) [" + blockCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString()+ ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString());
if(!promise.isPending) {
try {
Logger.i("V8", "V8Promise resolved synchronously");
if(promise.isFulfilled)
promiseResult = promise.getResult<T>();
else
promiseException = promise.getResult<V8Value>().toException(plugin.config);
}
catch(ex: Throwable) {
promiseException = ex;
}
}
else {
plugin.unbusy {
latch.await();
}
}
if(promiseException != null)
throw promiseException!!;
@@ -249,12 +268,25 @@ fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T>
underlyingDef.complete(p0 as T);
}
override fun onRejected(p0: V8Value?) {
plugin.resolvePromise(promise);
underlyingDef.completeExceptionally(NotImplementedError("onRejected promise not implemented.."));
try {
plugin.resolvePromise(promise);
val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onRejected promise not implemented..");
Logger.i("V8", "Promise rejected, setting exception");
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
}
catch(ex: Throwable) {
Logger.e("V8", "Rejection handling failed?" , ex);
}
}
override fun onCatch(p0: V8Value?) {
plugin.resolvePromise(promise);
underlyingDef.completeExceptionally(NotImplementedError("onCatch promise not implemented.."));
try {
plugin.resolvePromise(promise);
val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onCatch promise not implemented..");
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
}
catch(ex: Throwable) {
Logger.e("V8", "Catching handling failed?" , ex);
}
}
});
}
@@ -265,6 +297,20 @@ fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T>
return def;
}
fun V8Value.toException(config: IV8PluginConfig): Throwable {
val p0 = this;
if(p0 is V8ValueObject) {
val pluginType = p0.getOrDefault(config, "plugin_type", "Promise Exception", "")?.let { if(!it.isNullOrBlank()) it + "" else "" }
val msg = p0.getOrDefault<String?>(config, "msg", "Promise Exception", null)
?: p0.getOrDefault(config, "message", "Promise Exception", "");
return Throwable("Promise Failed: " + pluginType + msg);
}
else if(p0 is V8ValueString)
return Throwable("Promise Failed:" + p0.value);
else
return NotImplementedError("onCatch promise not implemented..");
}
class V8Deferred<T>(val deferred: Deferred<T>, val estDuration: Int = -1): Deferred<T> by deferred {
fun <R> convert(conversion: (result: T)->R): V8Deferred<R>{
@@ -0,0 +1,80 @@
package com.futo.platformplayer
import android.content.Context
import android.content.SharedPreferences
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import org.json.JSONObject
class ModerationsManager private constructor(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences("polycentric_moderation", Context.MODE_PRIVATE)
private val _moderationLevels = MutableLiveData<Map<String, Int>>()
val moderationLevels: LiveData<Map<String, Int>> = _moderationLevels
init {
loadModerationLevels()
}
private fun loadModerationLevels() {
val levels = mutableMapOf<String, Int>()
levels["hate"] = prefs.getInt("offensive_level", 2)
levels["sexual"] = prefs.getInt("explicit_level", 1)
levels["violence"] = prefs.getInt("violence_level", 1)
_moderationLevels.value = levels
}
fun setModerationLevel(category: String, level: Int) {
when (category) {
"hate" -> prefs.edit().putInt("offensive_level", level).apply()
"sexual" -> prefs.edit().putInt("explicit_level", level).apply()
"violence" -> prefs.edit().putInt("violence_level", level).apply()
}
val currentMap = _moderationLevels.value?.toMutableMap() ?: mutableMapOf()
currentMap[category] = level
_moderationLevels.value = currentMap
}
fun getModerationLevelsJson(): String {
val json = JSONObject()
moderationLevels.value?.forEach { (key, value) ->
json.put(key, value)
}
return json.toString()
}
fun shouldFilter(category: String, contentLevel: Int): Boolean {
val userLevel = when (category) {
"hate" -> prefs.getInt("offensive_level", 2)
"sexual" -> prefs.getInt("explicit_level", 1)
"violence" -> prefs.getInt("violence_level", 1)
else -> 3
}
return contentLevel > userLevel
}
fun getCurrentModerationLevels(): Map<String, Int>? {
return moderationLevels.value
}
companion object {
@Volatile
private var instance: ModerationsManager? = null
fun initialize(context: Context) {
if (instance == null) {
synchronized(this) {
if (instance == null) {
instance = ModerationsManager(context.applicationContext)
}
}
}
}
fun getInstance(): ModerationsManager {
return instance ?: throw IllegalStateException("ModerationsManager not initialized")
}
}
}
@@ -25,6 +25,7 @@ import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePayment
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.states.StateSync
import com.futo.platformplayer.states.StateUpdate
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.FragmentedStorageFileJson
@@ -34,6 +35,7 @@ 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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -201,6 +203,8 @@ class Settings : FragmentedStorageFileJson() {
8 -> "zh";
9 -> "ru";
10 -> "ar";
11 -> "it";
12 -> "tr";
else -> null
}
}
@@ -608,6 +612,11 @@ class Settings : FragmentedStorageFileJson() {
@AdvancedField
@FormField(R.string.shorts_pregenerate, FieldForm.TOGGLE, R.string.shorts_pregenerate_description, 28)
var shortsPregenerate: Boolean = false;
@AdvancedField
@FormField(R.string.shorts_fit_video, FieldForm.TOGGLE, R.string.shorts_fit_video_description, 29)
@FormFieldWarning(R.string.shorts_fit_video_warning)
var shortsFitVideo: Boolean = false;
}
@FormField(R.string.comments, "group", R.string.comments_description, 6)
@@ -710,6 +719,11 @@ class Settings : FragmentedStorageFileJson() {
@Serializable(with = FlexibleBooleanSerializer::class)
var allowLinkLocalIpv4: Boolean = false;
@AdvancedField
@FormField(R.string.experimental_cast, FieldForm.TOGGLE, R.string.experimental_cast_description, 6)
@Serializable(with = FlexibleBooleanSerializer::class)
var experimentalCasting: Boolean = false
/*TODO: Should we have a different casting quality?
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
@DropdownFieldOptionsId(R.array.preferred_quality_array)
@@ -1092,6 +1106,39 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.local_connections, FieldForm.TOGGLE, R.string.local_connections_description, 3)
var localConnections: Boolean = true;
var syncServerUrl: String? = null;
@FormField(R.string.relay_server, FieldForm.READONLYTEXT, -1, 6)
val syncServer: String get() = if(syncServerUrl?.isBlank() == true) StateSync.RELAY_SERVER else syncServerUrl ?: StateSync.RELAY_SERVER;
@AdvancedField
@FormField(R.string.configure_sync_server, FieldForm.BUTTON, R.string.configure_sync_server_description, 7)
fun configureSyncServer() {
SettingsActivity.getActivity()?.let { context ->
UIDialogs.showDialog(context, R.drawable.device_sync, false,
"Enter the url to your relay server",
"Using your own relay server requires a proper setup with portforwarding.\nUse at your own risk.",
null,
syncServerUrl ?: "",
"YourRelayServerDomain.com", 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Reset", {
syncServerUrl = null;
instance.save();
context.reloadSettings();
UIDialogs.toast("Sync server changes require a restart");
}, UIDialogs.ActionStyle.ACCENT),
UIDialogs.Action.withInput("Configure", {
syncServerUrl = it?.text
instance.save();
context.reloadSettings();
UIDialogs.toast("Sync server changes require a restart");
}, UIDialogs.ActionStyle.PRIMARY),
)
}
}
}
@FormField(R.string.info, FieldForm.GROUP, -1, 21)
@@ -113,8 +113,8 @@ class UIDialogs {
currentDialog.code,
currentDialog.defaultCloseAction,
*currentDialog.actions.map {
return@map Action(it.text, {
it.action();
return@map Action.withInput(it.text, { str ->
it.invokeAction(str);
multiShowDialog(context, dialogDescriptor.drop(1), finally);
}, it.style);
}.toTypedArray());
@@ -203,7 +203,9 @@ class UIDialogs {
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
return showDialog(context, icon, false, text, textDetails, code, defaultCloseAction, *actions);
}
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog
= showDialog(context, icon, animated, text, textDetails, code, null, null, defaultCloseAction, *actions);
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, input: String?, placeholder: String?, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
val builder = AlertDialog.Builder(context);
val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null);
builder.setView(view);
@@ -226,6 +228,16 @@ class UIDialogs {
this.text = textDetails;
}
};
var inputView = view.findViewById<TextView>(R.id.dialog_text_input);
inputView.apply {
if (input == null && placeholder == null) this.visibility = View.GONE;
else {
this.text = input ?: "";
this.hint = placeholder ?: "";
this.visibility = View.VISIBLE;
this.textAlignment = View.TEXT_ALIGNMENT_VIEW_START
}
};
view.findViewById<TextView>(R.id.dialog_text_code).apply {
if (code == null) this.visibility = View.GONE;
else {
@@ -250,7 +262,7 @@ class UIDialogs {
buttonView.textSize = 14f;
buttonView.typeface = resources.getFont(R.font.inter_regular);
buttonView.text = act.text;
buttonView.setOnClickListener { act.action(); dialog.dismiss(); };
buttonView.setOnClickListener { act.invokeAction(DialogResult(inputView?.text?.toString())); dialog.dismiss(); };
when(act.style) {
ActionStyle.PRIMARY -> buttonView.setBackgroundResource(R.drawable.background_button_primary);
ActionStyle.ACCENT -> buttonView.setBackgroundResource(R.drawable.background_button_accent);
@@ -275,7 +287,7 @@ class UIDialogs {
};
dialog.setOnCancelListener {
if(defaultCloseAction >= 0 && defaultCloseAction < actions.size)
actions[defaultCloseAction].action();
actions[defaultCloseAction].invokeAction(DialogResult(inputView?.text?.toString()));
}
dialog.setOnDismissListener {
registerDialogClosed(dialog);
@@ -535,17 +547,36 @@ class UIDialogs {
}
class Action {
val text: String;
val action: ()->Unit;
val action: ((DialogResult?)->Unit);
val style: ActionStyle;
var center: Boolean;
constructor(text: String, action: ()->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false) {
this.text = text;
this.action = { action() };
this.style = style;
this.center = center;
}
protected constructor(text: String, action: (DialogResult?)->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false) {
this.text = text;
this.action = action;
this.style = style;
this.center = center;
}
fun invokeAction(input: DialogResult? = null) {
this.action(input);
}
companion object {
fun withInput(text: String, action: (DialogResult?)->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false): Action {
return Action(text, action, style, center);
}
}
}
class DialogResult(
val text: String?
);
enum class ActionStyle {
NONE,
PRIMARY,
@@ -79,20 +79,9 @@ class LoginActivity : AppCompatActivity() {
val uiMods = authConfig.uiMods?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.UIMod>();
var currentScale = 100;
var currentDesktop = false;
_webView.setInitialScale(50);
webViewClient.onPageLoaded.subscribe { view, url ->
_textUrl.setText(url ?: "");
if(!isFirstLoad)
return@subscribe;
isFirstLoad = false;
if(!authConfig.loginButton.isNullOrEmpty() && authConfig.loginButton.matches(REGEX_LOGIN_BUTTON)) {
Logger.i(TAG, "Clicking login button [${authConfig.loginButton}]");
//TODO: Find most reliable way to wait for page js to finish
view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {});
}
if(loginWarnings.size > 0 && url != null) {
synchronized(loginWarnings) {
val warning = loginWarnings.find { url.matches(it.getRegex()) };
@@ -106,6 +95,16 @@ class LoginActivity : AppCompatActivity() {
}
}
if(!isFirstLoad)
return@subscribe;
isFirstLoad = false;
if(!authConfig.loginButton.isNullOrEmpty() && authConfig.loginButton.matches(REGEX_LOGIN_BUTTON)) {
Logger.i(TAG, "Clicking login button [${authConfig.loginButton}]");
//TODO: Find most reliable way to wait for page js to finish
view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {});
}
/*
var specifiedScale = false;
var specifiedDesktop = false;
@@ -39,6 +39,7 @@ 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.api.media.models.video.LocalVideoDetails
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.dp
@@ -706,14 +707,21 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
return;
Logger.i(TAG, "handleIntent started by " + intent.action);
var targetData: String? = null;
when (intent.action) {
Intent.ACTION_SEND -> {
targetData = intent.getStringExtra(Intent.EXTRA_STREAM)
?: intent.getStringExtra(Intent.EXTRA_TEXT);
Logger.i(TAG, "Share Received: " + targetData);
val streamExtra = intent.getStringExtra(Intent.EXTRA_STREAM);
val textExtra = intent.getStringExtra(Intent.EXTRA_TEXT);
val streamParcelable = intent.getParcelableExtra<android.net.Uri>(Intent.EXTRA_STREAM);
// Try to get the actual file content
targetData = when {
streamParcelable != null -> streamParcelable.toString()
streamExtra != null -> streamExtra
textExtra != null -> textExtra
else -> null
}
}
Intent.ACTION_VIEW -> {
@@ -768,7 +776,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if (targetData != null) {
lifecycleScope.launch(Dispatchers.Main) {
try {
handleUrlAll(targetData)
handleUrlAll(targetData, intent)
} catch (e: Throwable) {
Logger.e(TAG, "Unhandled exception in handleUrlAll", e)
}
@@ -779,8 +787,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
suspend fun handleUrlAll(url: String) {
suspend fun handleUrlAll(url: String, openIntent: Intent? = null) {
val uri = Uri.parse(url)
val intent = openIntent ?: this.intent;
when (uri.scheme) {
"grayjay" -> {
if (url.startsWith("grayjay://license/")) {
@@ -807,11 +816,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
"content" -> {
if (!handleContent(url, intent.type)) {
if (!handleContent(url, intent?.type)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
getString(R.string.unknown_content_format) + " [${url}]\n[${intent.type}]",
getString(R.string.unknown_content_format) + " [${url}]\n[${intent?.type}]",
"Ok",
{ });
}
@@ -932,6 +941,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} else if (file.lowercase().endsWith(".txt") || mime == "text/plain") {
return handleUnknownText(String(data));
}
else if (mime?.let { it.startsWith("video/") || it.startsWith("audio/") } ?: false) {
val mediaItem = LocalVideoDetails.fromContent(file, mime);
navigateWhenReady(_fragVideoDetail, mediaItem);
return true;
}
return false;
}
@@ -994,6 +1009,14 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
fun handleUnknownText(text: String): Boolean {
try {
// Check for Polycentric profile data
if (text.startsWith("polycentric://")) {
startActivity(Intent(this, PolycentricImportProfileActivity::class.java).apply {
putExtra("url", text.trim())
})
return true;
}
if (text.startsWith("@/Subscription") || text.startsWith("Subscriptions")) {
val lines = text.split("\n").map { it.trim() }.drop(1).filter { it.isNotEmpty() };
navigate(_fragImportSubscriptions, lines);
@@ -1046,7 +1069,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
Logger.i(TAG, "handleFCast");
try {
StateCasting.instance.handleUrl(this, url)
StateCasting.instance.handleUrl(url)
return true;
} catch (e: Throwable) {
Log.e(TAG, "Failed to parse FCast URL '${url}'.", e)
@@ -13,15 +13,18 @@ import android.view.View
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateApp.Companion.withContext
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.buttons.BigButton
import com.futo.platformplayer.activities.QRCodeFullscreenActivity
import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.SignedEvent
import com.futo.polycentric.core.StorageTypeCRDTItem
@@ -29,8 +32,10 @@ import com.futo.polycentric.core.StorageTypeCRDTSetItem
import com.futo.polycentric.core.Store
import com.futo.polycentric.core.toBase64Url
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.MultiFormatWriter
import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -41,11 +46,27 @@ import userpackage.Protocol.URLInfo
class PolycentricBackupActivity : AppCompatActivity() {
private lateinit var _buttonShare: BigButton;
private lateinit var _buttonCopy: BigButton;
private lateinit var _buttonExportFile: BigButton;
private lateinit var _imageQR: ImageView;
private lateinit var _exportBundle: String;
private lateinit var _textQR: TextView;
private lateinit var _textQRHint: TextView;
private lateinit var _loader: View
private val _createDocumentLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
uri?.let { fileUri ->
try {
contentResolver.openOutputStream(fileUri)?.use { outputStream ->
outputStream.write(_exportBundle.toByteArray())
}
UIDialogs.toast(this, getString(R.string.profile_saved_successfully))
} catch (e: Exception) {
Logger.e(TAG, "Failed to write to document", e)
UIDialogs.toast(this, "Failed to save profile: ${e.message}")
}
}
}
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
@@ -57,8 +78,10 @@ class PolycentricBackupActivity : AppCompatActivity() {
_buttonShare = findViewById(R.id.button_share)
_buttonCopy = findViewById(R.id.button_copy)
_buttonExportFile = findViewById(R.id.button_export_file)
_imageQR = findViewById(R.id.image_qr)
_textQR = findViewById(R.id.text_qr)
_textQRHint = findViewById(R.id.text_qr_hint)
_loader = findViewById(R.id.progress_loader)
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
finish();
@@ -66,14 +89,23 @@ class PolycentricBackupActivity : AppCompatActivity() {
_imageQR.visibility = View.INVISIBLE
_textQR.visibility = View.INVISIBLE
_textQRHint.visibility = View.INVISIBLE
_loader.visibility = View.VISIBLE
_buttonShare.visibility = View.INVISIBLE
_buttonCopy.visibility = View.INVISIBLE
_buttonExportFile.visibility = View.INVISIBLE
lifecycleScope.launch {
val bundle = withContext(Dispatchers.IO) { createExportBundle() }
_exportBundle = bundle
Logger.i(TAG, "Export bundle created, length: ${bundle.length}")
try {
val pair = withContext(Dispatchers.IO) {
val bundle = createExportBundle()
if (!isContentSuitableForQRCode(bundle)) {
throw Exception("Data too big for QR code generation")
}
val dimension = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics
).toInt()
@@ -81,18 +113,35 @@ class PolycentricBackupActivity : AppCompatActivity() {
Pair(bundle, qr)
}
_exportBundle = pair.first
_imageQR.setImageBitmap(pair.second)
_imageQR.visibility = View.VISIBLE
_textQR.visibility = View.VISIBLE
_textQRHint.visibility = View.VISIBLE
_buttonShare.visibility = View.VISIBLE
_buttonCopy.visibility = View.VISIBLE
_imageQR.setOnClickListener {
val intent = QRCodeFullscreenActivity.createIntent(this@PolycentricBackupActivity, _exportBundle)
startActivity(intent)
}
} catch (e: Exception) {
Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e)
val byteSize = bundle.toByteArray(Charsets.UTF_8).size
Logger.e(TAG, "QR code generation failed. Bundle length: ${bundle.length} chars, ${byteSize} bytes, Error: ${e.message}", e)
if (e.message?.contains("Data too big") == true) {
_textQR.text = getString(R.string.qr_code_too_large_use_file_export)
_buttonExportFile.visibility = View.VISIBLE
} else {
_textQR.text = getString(R.string.failed_to_generate_qr_code)
}
_textQR.visibility = View.VISIBLE
_textQRHint.visibility = View.INVISIBLE
_buttonShare.visibility = View.VISIBLE
_buttonCopy.visibility = View.VISIBLE
// Hide QR image since generation failed
_imageQR.visibility = View.INVISIBLE
_textQR.visibility = View.INVISIBLE
_buttonShare.visibility = View.INVISIBLE
_buttonCopy.visibility = View.INVISIBLE
} finally {
_loader.visibility = View.GONE
}
@@ -108,11 +157,29 @@ class PolycentricBackupActivity : AppCompatActivity() {
val clip = ClipData.newPlainText(getString(R.string.copied_text), _exportBundle);
clipboard.setPrimaryClip(clip);
};
_buttonExportFile.onClick.subscribe {
val fileName = "polycentric_profile_${System.currentTimeMillis()}.txt"
_createDocumentLauncher.launch(fileName)
};
}
private fun isContentSuitableForQRCode(content: String): Boolean {
val bytes = content.toByteArray(Charsets.UTF_8)
return bytes.size <= 2300 // QR Code Version 40 with Error Correction Level M can hold ~2331 bytes
}
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height);
return bitMatrixToBitmap(bitMatrix);
if (!isContentSuitableForQRCode(content)) {
throw Exception("Data too big for QR code generation")
}
val hints = java.util.EnumMap<EncodeHintType, Any>(EncodeHintType::class.java)
hints[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.M
hints[EncodeHintType.MARGIN] = 1
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints)
return bitMatrixToBitmap(bitMatrix)
}
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
@@ -203,9 +270,11 @@ class PolycentricBackupActivity : AppCompatActivity() {
.setBody(exportBundle.toByteString())
.build();
return "polycentric://" + urlInfo.toByteArray().toBase64Url()
val data = urlInfo.toByteArray()
return "polycentric://" + data.toBase64Url()
}
companion object {
private const val TAG = "PolycentricBackupActivity";
}
@@ -34,6 +34,7 @@ import userpackage.Protocol.ExportBundle
class PolycentricImportProfileActivity : AppCompatActivity() {
private lateinit var _buttonHelp: ImageButton;
private lateinit var _buttonScanProfile: LinearLayout;
private lateinit var _buttonImportFile: LinearLayout;
private lateinit var _buttonImportProfile: LinearLayout;
private lateinit var _editProfile: EditText;
private lateinit var _loaderOverlay: LoaderOverlay;
@@ -48,6 +49,56 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
}
}
private val _filePickerLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { fileUri ->
try {
// Check file size before reading
val fileSize = contentResolver.openFileDescriptor(fileUri, "r")?.statSize ?: 0
val maxFileSize = 10 * 1024 * 1024 // 10MB limit
if (fileSize > maxFileSize) {
UIDialogs.toast(this, "File too large. Maximum size is 10MB.")
return@let
}
if (fileSize == 0L) {
UIDialogs.toast(this, "Selected file is empty.")
return@let
}
val content = contentResolver.openInputStream(fileUri)?.bufferedReader()?.readText()
content?.let { fileContent ->
val trimmedContent = fileContent.trim()
// Check if content is empty after trimming
if (trimmedContent.isEmpty()) {
UIDialogs.toast(this, "Selected file contains no data.")
return@let
}
// Check if content looks like a valid polycentric URL
if (!trimmedContent.startsWith("polycentric://")) {
UIDialogs.toast(this, "Selected file does not contain a valid polycentric profile URL.")
return@let
}
import(trimmedContent)
} ?: run {
UIDialogs.toast(this, "Could not read file content.")
}
} catch (e: SecurityException) {
Logger.e(TAG, "Security exception reading file", e)
UIDialogs.toast(this, "Permission denied to read file.")
} catch (e: OutOfMemoryError) {
Logger.e(TAG, "Out of memory reading file", e)
UIDialogs.toast(this, "File too large to process.")
} catch (e: Exception) {
Logger.e(TAG, "Failed to read file", e)
UIDialogs.toast(this, "Failed to read file: ${e.message}")
}
}
}
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
@@ -59,6 +110,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
_buttonHelp = findViewById(R.id.button_help);
_buttonScanProfile = findViewById(R.id.button_scan_profile);
_buttonImportFile = findViewById(R.id.button_import_file);
_buttonImportProfile = findViewById(R.id.button_import_profile);
_loaderOverlay = findViewById(R.id.loader_overlay);
_editProfile = findViewById(R.id.edit_profile);
@@ -82,6 +134,10 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
_qrCodeResultLauncher.launch(integrator.createScanIntent())
};
_buttonImportFile.setOnClickListener {
_filePickerLauncher.launch("text/plain")
};
_buttonImportProfile.setOnClickListener {
if (_editProfile.text.isEmpty()) {
UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data));
@@ -109,6 +165,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
try {
val data = url.substring("polycentric://".length).base64UrlToByteArray();
val urlInfo = Protocol.URLInfo.parseFrom(data);
if (urlInfo.urlType != 3L) {
throw Exception("Expected urlInfo struct of type ExportBundle")
}
@@ -0,0 +1,156 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.os.Bundle
import android.widget.ImageButton
import android.widget.SeekBar
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.ModerationsManager
import com.futo.platformplayer.R
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.setNavigationBarColorAndIcons
class PolycentricModerationActivity : AppCompatActivity() {
private lateinit var _seekbarOffensive: SeekBar
private lateinit var _seekbarExplicit: SeekBar
private lateinit var _seekbarViolence: SeekBar
private lateinit var _textOffensiveDesc: TextView
private lateinit var _textExplicitDesc: TextView
private lateinit var _textViolenceDesc: TextView
private lateinit var _textOffensiveValue: TextView
private lateinit var _textExplicitValue: TextView
private lateinit var _textViolenceValue: TextView
private lateinit var _moderationsManager: ModerationsManager
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_polycentric_moderation)
setNavigationBarColorAndIcons()
try {
_moderationsManager = ModerationsManager.getInstance()
} catch (e: IllegalStateException) {
// ModerationsManager not initialized, finish activity
finish()
return
}
_seekbarOffensive = findViewById(R.id.seekbar_offensive)
_seekbarExplicit = findViewById(R.id.seekbar_explicit)
_seekbarViolence = findViewById(R.id.seekbar_violence)
_textOffensiveDesc = findViewById(R.id.text_offensive_desc)
_textExplicitDesc = findViewById(R.id.text_explicit_desc)
_textViolenceDesc = findViewById(R.id.text_violence_desc)
_textOffensiveValue = findViewById(R.id.text_offensive_value)
_textExplicitValue = findViewById(R.id.text_explicit_value)
_textViolenceValue = findViewById(R.id.text_violence_value)
findViewById<ImageButton>(R.id.button_back)?.setOnClickListener {
finish()
}
loadSettings()
setupListeners()
}
private fun loadSettings() {
if (isFinishing || isDestroyed) return
val levels = _moderationsManager.moderationLevels.value ?: mapOf()
val offensiveLevel = levels["hate"] ?: 2
val explicitLevel = levels["sexual"] ?: 1
val violenceLevel = levels["violence"] ?: 1
_seekbarOffensive.progress = offensiveLevel
_seekbarExplicit.progress = explicitLevel
_seekbarViolence.progress = violenceLevel
updateDescriptionText(_seekbarOffensive, _textOffensiveDesc, _textOffensiveValue, getOffensiveDescriptions())
updateDescriptionText(_seekbarExplicit, _textExplicitDesc, _textExplicitValue, getExplicitDescriptions())
updateDescriptionText(_seekbarViolence, _textViolenceDesc, _textViolenceValue, getViolenceDescriptions())
}
private fun setupListeners() {
_seekbarOffensive.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
if (isFinishing || isDestroyed) return
updateDescriptionText(seekBar, _textOffensiveDesc, _textOffensiveValue, getOffensiveDescriptions())
if (fromUser) {
_moderationsManager.setModerationLevel("hate", progress)
}
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
_seekbarExplicit.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
if (isFinishing || isDestroyed) return
updateDescriptionText(seekBar, _textExplicitDesc, _textExplicitValue, getExplicitDescriptions())
if (fromUser) {
_moderationsManager.setModerationLevel("sexual", progress)
}
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
_seekbarViolence.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
if (isFinishing || isDestroyed) return
updateDescriptionText(seekBar, _textViolenceDesc, _textViolenceValue, getViolenceDescriptions())
if (fromUser) {
_moderationsManager.setModerationLevel("violence", progress)
}
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
}
private fun updateDescriptionText(seekBar: SeekBar?, textDesc: TextView, textValue: TextView, descriptions: Array<String>) {
if (isFinishing || isDestroyed) return
val progress = seekBar?.progress ?: 0
if (progress in descriptions.indices) {
textDesc.text = descriptions[progress]
textValue.text = progress.toString()
}
}
private fun getOffensiveDescriptions(): Array<String> {
return arrayOf(
"Neutral, general terms, no bias or hate.",
"Mildly sensitive, factual.",
"Potentially offensive content",
"Offensive content"
)
}
private fun getExplicitDescriptions(): Array<String> {
return arrayOf(
"No explicit content",
"Mildly suggestive, factual or educational",
"Moderate sexual content, non-graphic",
"Explicit sexual content"
)
}
private fun getViolenceDescriptions(): Array<String> {
return arrayOf(
"Non-violent",
"Mild violence, factual or contextual",
"Moderate violence, some graphic content.",
"Graphic violence"
)
}
}
@@ -49,7 +49,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
private lateinit var _buttonHelp: ImageButton;
private lateinit var _editName: EditText;
private lateinit var _buttonExport: BigButton;
private lateinit var _buttonOpenHarborProfile: BigButton;
private lateinit var _buttonModeration: BigButton;
private lateinit var _buttonLogout: BigButton;
private lateinit var _buttonDelete: BigButton;
private lateinit var _username: String;
@@ -71,7 +71,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
_imagePolycentric = findViewById(R.id.image_polycentric);
_editName = findViewById(R.id.edit_profile_name);
_buttonExport = findViewById(R.id.button_export);
_buttonOpenHarborProfile = findViewById(R.id.button_open_harbor_profile);
_buttonModeration = findViewById(R.id.button_moderation);
_buttonLogout = findViewById(R.id.button_logout);
_buttonDelete = findViewById(R.id.button_delete);
_loaderOverlay = findViewById(R.id.loader_overlay);
@@ -99,15 +99,9 @@ class PolycentricProfileActivity : AppCompatActivity() {
startActivity(Intent(this, PolycentricBackupActivity::class.java));
};
_buttonOpenHarborProfile.onClick.subscribe {
val processHandle = StatePolycentric.instance.processHandle!!;
processHandle?.let {
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(it.system));
val url = it.system.systemToURLInfoSystemLinkUrl(systemState.servers.asIterable());
val navUrl = "https://harbor.social/" + url.substring("polycentric://".length)
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(navUrl)))
}
}
_buttonModeration.onClick.subscribe {
startActivity(Intent(this, PolycentricModerationActivity::class.java));
};
_buttonLogout.onClick.subscribe {
StatePolycentric.instance.setProcessHandle(null);
@@ -0,0 +1,108 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.os.Bundle
import android.util.TypedValue
import android.view.View
import android.widget.ImageButton
import android.widget.ImageView
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.R
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.MultiFormatWriter
import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
class QRCodeFullscreenActivity : AppCompatActivity() {
companion object {
private const val EXTRA_QR_TEXT = "qr_text"
fun createIntent(context: Context, qrText: String): android.content.Intent {
return android.content.Intent(context, QRCodeFullscreenActivity::class.java).apply {
putExtra(EXTRA_QR_TEXT, qrText)
}
}
}
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_qr_code_fullscreen)
setNavigationBarColorAndIcons()
val qrText = intent.getStringExtra(EXTRA_QR_TEXT)
val imageQR = findViewById<ImageView>(R.id.image_qr_fullscreen)
val buttonBack = findViewById<ImageButton>(R.id.button_back_fullscreen)
val buttonClose = findViewById<ImageButton>(R.id.button_close_fullscreen)
// Generate QR code bitmap from text
qrText?.let { text ->
try {
if (!isContentSuitableForQRCode(text)) {
throw Exception("Data too big for QR code generation")
}
val dimension = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, 300f, resources.displayMetrics
).toInt()
val qrBitmap = generateQRCode(text, dimension, dimension)
imageQR.setImageBitmap(qrBitmap)
} catch (e: Exception) {
// If QR generation fails, show error or fallback
imageQR.setImageResource(R.drawable.ic_qr)
}
}
buttonBack.setOnClickListener {
finish()
}
buttonClose.setOnClickListener {
finish()
}
imageQR.setOnClickListener {
finish()
}
}
private fun isContentSuitableForQRCode(content: String): Boolean {
val bytes = content.toByteArray(Charsets.UTF_8)
return bytes.size <= 2300 // QR Code Version 40 with Error Correction Level M can hold ~2331 bytes
}
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
if (!isContentSuitableForQRCode(content)) {
throw Exception("Data too big for QR code generation")
}
val hints = java.util.EnumMap<EncodeHintType, Any>(EncodeHintType::class.java)
hints[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.M
hints[EncodeHintType.MARGIN] = 1
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints)
return bitMatrixToBitmap(bitMatrix)
}
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
val width = matrix.width
val height = matrix.height
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
for (x in 0 until width) {
for (y in 0 until height) {
bmp.setPixel(x, y, if (matrix[x, y]) Color.BLACK else Color.WHITE)
}
}
return bmp
}
}
@@ -21,6 +21,7 @@ import com.futo.platformplayer.*
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.views.LoaderView
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.ReadOnlyTextField
@@ -114,6 +115,11 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
var isFirstLoad = true;
fun reloadSettings() {
// Ensure files are initialized before accessing Settings
if (!FragmentedStorage.isInitialized) {
FragmentedStorage.initialize(filesDir);
}
val firstLoad = isFirstLoad;
isFirstLoad = false;
_form.setSearchVisible(false);
@@ -149,6 +155,11 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
}
fun updateDevMode() {
// Ensure files are initialized before accessing SettingsDev
if (!FragmentedStorage.isInitialized) {
FragmentedStorage.initialize(filesDir);
}
if(SettingsDev.instance.developerMode)
_devSets.visibility = View.VISIBLE;
else
@@ -2,10 +2,24 @@ package com.futo.platformplayer.api.media.models.streams
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
import com.futo.platformplayer.downloads.VideoLocal
class LocalVideoUnMuxedSourceDescriptor(private val video: VideoLocal) : VideoUnMuxedSourceDescriptor() {
override val videoSources: Array<IVideoSource> get() = video.videoSource.toTypedArray();
override val audioSources: Array<IAudioSource> get() = video.audioSource.toTypedArray();
class LocalVideoUnMuxedSourceDescriptor : VideoUnMuxedSourceDescriptor {
override val videoSources: Array<IVideoSource>;
override val audioSources: Array<IAudioSource>;
constructor(video: VideoLocal) {
videoSources = video.videoSource.toTypedArray();
audioSources = video.audioSource.toTypedArray();
}
constructor(audio: LocalAudioContentSource) {
videoSources = arrayOf()
audioSources = arrayOf(audio);
}
constructor(videoSources: Array<IVideoSource>, audioSources: Array<IAudioSource>) {
this.videoSources = videoSources;
this.audioSources = audioSources;
}
}
@@ -14,7 +14,8 @@ class AudioUrlSource(
override val language: String = Language.UNKNOWN,
override val duration: Long? = null,
override var priority: Boolean = false,
override var original: Boolean = false
override var original: Boolean = false,
var isLocal: Boolean = false
) : IAudioUrlSource, IStreamMetaDataSource{
override var streamMetaData: StreamMetaData? = null;
@@ -14,7 +14,8 @@ open class VideoUrlSource(
override val codec : String = "",
override val bitrate : Int? = 0,
override var priority: Boolean = false
override var priority: Boolean = false,
var isLocal: Boolean = false
) : IVideoUrlSource, IStreamMetaDataSource {
override var streamMetaData: StreamMetaData? = null;
@@ -0,0 +1,122 @@
package com.futo.platformplayer.api.media.models.video
import android.annotation.SuppressLint
import android.net.Uri
import android.provider.MediaStore
import android.provider.OpenableColumns
import androidx.core.net.toUri
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
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.playback.IPlaybackTracker
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.streams.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.LocalVideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.AudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
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.LocalAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.SubtitleRawSource
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
import com.futo.platformplayer.api.media.platforms.local.models.LocalVideoMuxedSourceDescriptor
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.others.Language
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.states.StateApp
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.OffsetDateTime
@kotlinx.serialization.Serializable
open class LocalVideoDetails(
override val id: PlatformID,
override val name: String,
override val thumbnails: Thumbnails,
override val author: PlatformAuthorLink,
override val url: String,
override val duration: Long,
val mimeType: String? = null,
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override val datetime: OffsetDateTime?
) : IPlatformVideo, IPlatformVideoDetails {
final override val contentType: ContentType get() = ContentType.MEDIA;
override var playbackTime: Long = -1;
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override var playbackDate: OffsetDateTime? = null;
override val isLive: Boolean get() = false;
override val dash: IDashManifestSource? get() = null;
override val hls: IHLSManifestSource? get() = null;
override val live: IVideoSource? get() = null;
override val shareUrl: String = ""
override val viewCount: Long = -1
override val rating: IRating = RatingLikes(0)
override val description: String = "";
override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false)
(LocalVideoUnMuxedSourceDescriptor(
arrayOf(),
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name))
))
else (LocalVideoMuxedSourceDescriptor(
LocalVideoContentSource(url, mimeType ?: "", name)
))
);
override val preview: ISerializedVideoSourceDescriptor? = null;
override val subtitles: List<SubtitleRawSource> = listOf()
override val isShort: Boolean = false
fun toJson() : String {
return Json.encodeToString(this);
}
fun fromJson(str : String) : SerializedPlatformVideoDetails {
return Serializer.json.decodeFromString<SerializedPlatformVideoDetails>(str);
}
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? = null;
override fun getPlaybackTracker(): IPlaybackTracker? = null;
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? = null;
companion object {
fun fromFile(name: String, filePath: String, mimeType: String? = null) : LocalVideoDetails {
if(filePath.startsWith("content://"))
return fromContent(filePath, mimeType);
return LocalVideoDetails(PlatformID("FILE", filePath, null, 0, -1),
name, Thumbnails(), PlatformAuthorLink.UNKNOWN, filePath, -1, mimeType, null);
}
fun fromContent(contentUrl: String, mimeType: String? = null) : LocalVideoDetails {
var nameToUse = getFileNameFromContentUrl(contentUrl) ?: "File";
return LocalVideoDetails(PlatformID("FILE", contentUrl, null, 0, -1),
nameToUse, Thumbnails(), PlatformAuthorLink.UNKNOWN, contentUrl, -1, mimeType, null);
}
@SuppressLint("Range")
private fun getFileNameFromContentUrl(url: String): String? {
val cursor = StateApp.instance.context.contentResolver.query(url.toUri(), null, null, null, null);
cursor?.moveToFirst();
val fileName = cursor?.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME));
cursor?.close();
return fileName;
}
}
}
@@ -1,13 +1,23 @@
package com.futo.platformplayer.api.media.platforms.local.models
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
import com.futo.platformplayer.downloads.VideoLocal
class LocalVideoMuxedSourceDescriptor(
private val video: LocalVideoFileSource
) : VideoMuxedSourceDescriptor() {
override val videoSources: Array<IVideoSource> get() = arrayOf(video);
class LocalVideoMuxedSourceDescriptor: VideoMuxedSourceDescriptor {
override val videoSources: Array<IVideoSource>;
constructor(video: LocalVideoFileSource) {
videoSources = arrayOf(video);
}
constructor(video: LocalVideoContentSource) {
videoSources = arrayOf(video);
}
constructor(videoSources: Array<IVideoSource>) {
this.videoSources = videoSources;
}
}
@@ -0,0 +1,33 @@
package com.futo.platformplayer.api.media.platforms.local.models.sources
import android.content.Context
import android.provider.MediaStore
import android.provider.MediaStore.Video
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.others.Language
import java.io.File
class LocalAudioContentSource : IAudioSource {
override val name: String;
override val container: String;
override val codec: String = ""
override val bitrate: Int = 0
override val duration: Long;
override val priority: Boolean = false;
override val language: String = Language.UNKNOWN
override val original: Boolean = false;
var contentUrl: String;
constructor(contentUrl: String, mime: String, name: String? = null) {
this.name = name ?: "File";
container = mime;
duration = 0;
this.contentUrl = contentUrl;
}
}
@@ -0,0 +1,34 @@
package com.futo.platformplayer.api.media.platforms.local.models.sources
import android.content.Context
import android.provider.MediaStore
import android.provider.MediaStore.Video
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.others.Language
import java.io.File
class LocalAudioFileSource: IAudioSource {
override val name: String;
override val container: String;
override val codec: String = ""
override val bitrate: Int = 0
override val duration: Long;
override val priority: Boolean = false;
override val language: String = Language.UNKNOWN;
override val original: Boolean = false;
var file: File;
constructor(file: File) {
this.file = file;
name = file.name;
container = VideoHelper.videoExtensionToMimetype(file.extension) ?: "";
duration = 0;
}
}
@@ -0,0 +1,33 @@
package com.futo.platformplayer.api.media.platforms.local.models.sources
import android.content.Context
import android.provider.MediaStore
import android.provider.MediaStore.Video
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
import com.futo.platformplayer.helpers.VideoHelper
import java.io.File
class LocalVideoContentSource: IVideoSource {
override val name: String;
override val width: Int;
override val height: Int;
override val container: String;
override val codec: String = ""
override val bitrate: Int = 0
override val duration: Long;
override val priority: Boolean = false;
var contentUrl: String;
constructor(contentUrl: String, mime: String, name: String? = null) {
this.name = name ?: "File";
width = 0;
height = 0;
container = mime;
duration = 0;
this.contentUrl = contentUrl;
}
}
@@ -20,7 +20,10 @@ class LocalVideoFileSource: IVideoSource {
override val duration: Long;
override val priority: Boolean = false;
var file: File;
constructor(file: File) {
this.file = file;
name = file.name;
width = 0;
height = 0;
@@ -15,7 +15,7 @@ import kotlinx.coroutines.launch
import java.net.InetAddress
import java.util.UUID
class AirPlayCastingDevice : CastingDevice {
class AirPlayCastingDevice : CastingDeviceLegacy {
//See for more info: https://nto.github.io/AirPlay
override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY;
@@ -2,147 +2,78 @@ package com.futo.platformplayer.casting
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.fcast.sender_sdk.Metadata
import java.net.InetAddress
enum class CastConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED
}
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
enum class CastProtocolType {
CHROMECAST,
AIRPLAY,
FCAST;
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: CastProtocolType) {
encoder.encodeString(value.name)
}
override fun deserialize(decoder: Decoder): CastProtocolType {
val name = decoder.decodeString()
return when (name) {
"FASTCAST" -> FCAST // Handle the renamed case
else -> CastProtocolType.valueOf(name)
}
}
}
}
abstract class CastingDevice {
abstract val protocol: CastProtocolType;
abstract val isReady: Boolean;
abstract var usedRemoteAddress: InetAddress?;
abstract var localAddress: InetAddress?;
abstract val canSetVolume: Boolean;
abstract val canSetSpeed: Boolean;
abstract val isReady: Boolean
abstract val usedRemoteAddress: InetAddress?
abstract val localAddress: InetAddress?
abstract val name: String?
abstract val onConnectionStateChanged: Event1<CastConnectionState>
abstract val onPlayChanged: Event1<Boolean>
abstract val onTimeChanged: Event1<Double>
abstract val onDurationChanged: Event1<Double>
abstract val onVolumeChanged: Event1<Double>
abstract val onSpeedChanged: Event1<Double>
abstract var connectionState: CastConnectionState
abstract val protocolType: CastProtocolType
abstract var isPlaying: Boolean
abstract val expectedCurrentTime: Double
abstract var speed: Double
abstract var time: Double
abstract var duration: Double
abstract var volume: Double
abstract fun canSetVolume(): Boolean
abstract fun canSetSpeed(): Boolean
var name: String? = null;
var isPlaying: Boolean = false
set(value) {
val changed = value != field;
field = value;
if (changed) {
onPlayChanged.emit(value);
}
};
@Throws
abstract fun resumePlayback()
private var lastTimeChangeTime_ms: Long = 0
var time: Double = 0.0
private set
@Throws
abstract fun pausePlayback()
protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastTimeChangeTime_ms && value != time) {
time = value
lastTimeChangeTime_ms = changeTime_ms
onTimeChanged.emit(value)
}
}
@Throws
abstract fun stopPlayback()
private var lastDurationChangeTime_ms: Long = 0
var duration: Double = 0.0
private set
@Throws
abstract fun seekTo(timeSeconds: Double)
protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastDurationChangeTime_ms && value != duration) {
duration = value
lastDurationChangeTime_ms = changeTime_ms
onDurationChanged.emit(value)
}
}
@Throws
abstract fun changeVolume(timeSeconds: Double)
private var lastVolumeChangeTime_ms: Long = 0
var volume: Double = 1.0
private set
@Throws
abstract fun changeSpeed(speed: Double)
protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) {
volume = value
lastVolumeChangeTime_ms = changeTime_ms
onVolumeChanged.emit(value)
}
}
@Throws
abstract fun connect()
private var lastSpeedChangeTime_ms: Long = 0
var speed: Double = 1.0
private set
@Throws
abstract fun disconnect()
abstract fun getDeviceInfo(): CastingDeviceInfo
abstract fun getAddresses(): List<InetAddress>
protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) {
speed = value
lastSpeedChangeTime_ms = changeTime_ms
onSpeedChanged.emit(value)
}
}
@Throws
abstract fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
)
val expectedCurrentTime: Double
get() {
val diff = if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
return time + diff;
};
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
set(value) {
val changed = value != field;
field = value;
@Throws
abstract fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
)
if (changed) {
onConnectionStateChanged.emit(value);
}
};
abstract fun ensureThreadStarted()
}
var onConnectionStateChanged = Event1<CastConnectionState>();
var onPlayChanged = Event1<Boolean>();
var onTimeChanged = Event1<Double>();
var onDurationChanged = Event1<Double>();
var onVolumeChanged = Event1<Double>();
var onSpeedChanged = Event1<Double>();
abstract fun stopCasting();
abstract fun seekVideo(timeSeconds: Double);
abstract fun stopVideo();
abstract fun pauseVideo();
abstract fun resumeVideo();
abstract fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?);
abstract fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?);
open fun changeVolume(volume: Double) { throw NotImplementedError() }
open fun changeSpeed(speed: Double) { throw NotImplementedError() }
abstract fun start();
abstract fun stop();
abstract fun getDeviceInfo(): CastingDeviceInfo;
abstract fun getAddresses(): List<InetAddress>;
}
@@ -0,0 +1,271 @@
package com.futo.platformplayer.casting
import android.os.Build
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import org.fcast.sender_sdk.ApplicationInfo
import org.fcast.sender_sdk.GenericKeyEvent
import org.fcast.sender_sdk.GenericMediaEvent
import org.fcast.sender_sdk.PlaybackState
import org.fcast.sender_sdk.Source
import java.net.InetAddress
import org.fcast.sender_sdk.CastingDevice as RsCastingDevice;
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
import org.fcast.sender_sdk.DeviceConnectionState
import org.fcast.sender_sdk.DeviceFeature
import org.fcast.sender_sdk.IpAddr
import org.fcast.sender_sdk.LoadRequest
import org.fcast.sender_sdk.Metadata
import org.fcast.sender_sdk.ProtocolType
import org.fcast.sender_sdk.urlFormatIpAddr
import java.net.Inet4Address
import java.net.Inet6Address
private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) {
is IpAddr.V4 -> Inet4Address.getByAddress(
byteArrayOf(
addr.o1.toByte(),
addr.o2.toByte(),
addr.o3.toByte(),
addr.o4.toByte()
)
)
is IpAddr.V6 -> Inet6Address.getByAddress(
byteArrayOf(
addr.o1.toByte(),
addr.o2.toByte(),
addr.o3.toByte(),
addr.o4.toByte(),
addr.o5.toByte(),
addr.o6.toByte(),
addr.o7.toByte(),
addr.o8.toByte(),
addr.o9.toByte(),
addr.o10.toByte(),
addr.o11.toByte(),
addr.o12.toByte(),
addr.o13.toByte(),
addr.o14.toByte(),
addr.o15.toByte(),
addr.o16.toByte()
)
)
}
class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
class EventHandler : RsDeviceEventHandler {
var onConnectionStateChanged = Event1<DeviceConnectionState>();
var onPlayChanged = Event1<Boolean>()
var onTimeChanged = Event1<Double>()
var onDurationChanged = Event1<Double>()
var onVolumeChanged = Event1<Double>()
var onSpeedChanged = Event1<Double>()
override fun connectionStateChanged(state: DeviceConnectionState) {
onConnectionStateChanged.emit(state)
}
override fun volumeChanged(volume: Double) {
onVolumeChanged.emit(volume)
}
override fun timeChanged(time: Double) {
onTimeChanged.emit(time)
}
override fun playbackStateChanged(state: PlaybackState) {
onPlayChanged.emit(state == PlaybackState.PLAYING)
}
override fun durationChanged(duration: Double) {
onDurationChanged.emit(duration)
}
override fun speedChanged(speed: Double) {
onSpeedChanged.emit(speed)
}
override fun sourceChanged(source: Source) {
// TODO
}
override fun keyEvent(event: GenericKeyEvent) {
// Unreachable
}
override fun mediaEvent(event: GenericMediaEvent) {
// Unreachable
}
override fun playbackError(message: String) {
Logger.e(TAG, "Playback error: $message")
}
}
val eventHandler = EventHandler()
override val isReady: Boolean
get() = device.isReady()
override val name: String
get() = device.name()
override var usedRemoteAddress: InetAddress? = null
override var localAddress: InetAddress? = null
override fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME)
override fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED)
override val onConnectionStateChanged =
Event1<CastConnectionState>()
override val onPlayChanged: Event1<Boolean>
get() = eventHandler.onPlayChanged
override val onTimeChanged: Event1<Double>
get() = eventHandler.onTimeChanged
override val onDurationChanged: Event1<Double>
get() = eventHandler.onDurationChanged
override val onVolumeChanged: Event1<Double>
get() = eventHandler.onVolumeChanged
override val onSpeedChanged: Event1<Double>
get() = eventHandler.onSpeedChanged
override fun resumePlayback() = device.resumePlayback()
override fun pausePlayback() = device.pausePlayback()
override fun stopPlayback() = device.stopPlayback()
override fun seekTo(timeSeconds: Double) = device.seek(timeSeconds)
override fun changeVolume(newVolume: Double) {
device.changeVolume(newVolume)
volume = newVolume
}
override fun changeSpeed(speed: Double) = device.changeSpeed(speed)
override fun connect() = device.connect(
ApplicationInfo(
"Grayjay Android",
"${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}",
"${Build.MANUFACTURER} ${Build.MODEL}"
),
eventHandler,
1000.toULong()
)
override fun disconnect() = device.disconnect()
override fun getDeviceInfo(): CastingDeviceInfo {
val info = device.getDeviceInfo()
return CastingDeviceInfo(
info.name,
when (info.protocol) {
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
ProtocolType.F_CAST -> CastProtocolType.FCAST
},
addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(),
port = info.port.toInt(),
)
}
override fun getAddresses(): List<InetAddress> = device.getAddresses().map {
ipAddrToInetAddress(it)
}
override fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = device.load(
LoadRequest.Video(
contentType = contentType,
url = contentId,
resumePosition = resumePosition,
speed = speed,
volume = volume,
metadata = metadata
)
)
override fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = device.load(
LoadRequest.Content(
contentType = contentType,
content = content,
resumePosition = resumePosition,
speed = speed,
volume = volume,
metadata = metadata,
)
)
override var connectionState = CastConnectionState.DISCONNECTED
override val protocolType: CastProtocolType
get() = when (device.castingProtocol()) {
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
ProtocolType.F_CAST -> CastProtocolType.FCAST
}
override var volume: Double = 1.0
override var duration: Double = 0.0
private var lastTimeChangeTime_ms: Long = 0
override var time: Double = 0.0
override var speed: Double = 0.0
override var isPlaying: Boolean = false
override val expectedCurrentTime: Double
get() {
val diff =
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
return time + diff
}
init {
eventHandler.onConnectionStateChanged.subscribe { newState ->
when (newState) {
is DeviceConnectionState.Connected -> {
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
localAddress = ipAddrToInetAddress(newState.localAddr)
connectionState = CastConnectionState.CONNECTED
onConnectionStateChanged.emit(CastConnectionState.CONNECTED)
}
DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> {
connectionState = CastConnectionState.CONNECTING
onConnectionStateChanged.emit(CastConnectionState.CONNECTING)
}
DeviceConnectionState.Disconnected -> {
connectionState = CastConnectionState.CONNECTING
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
}
}
if (newState == DeviceConnectionState.Disconnected) {
try {
Logger.i(TAG, "Stopping device")
device.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to stop device: $e")
}
}
}
eventHandler.onPlayChanged.subscribe { isPlaying = it }
eventHandler.onTimeChanged.subscribe {
lastTimeChangeTime_ms = System.currentTimeMillis()
time = it
}
eventHandler.onDurationChanged.subscribe { duration = it }
eventHandler.onVolumeChanged.subscribe { volume = it }
eventHandler.onSpeedChanged.subscribe { speed = it }
}
override fun ensureThreadStarted() {}
companion object {
private val TAG = "CastingDeviceExp"
}
}
@@ -0,0 +1,242 @@
package com.futo.platformplayer.casting
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.fcast.sender_sdk.Metadata
import java.net.InetAddress
enum class CastConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED
}
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
enum class CastProtocolType {
CHROMECAST,
AIRPLAY,
FCAST;
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: CastProtocolType) {
encoder.encodeString(value.name)
}
override fun deserialize(decoder: Decoder): CastProtocolType {
val name = decoder.decodeString()
return when (name) {
"FASTCAST" -> FCAST // Handle the renamed case
else -> CastProtocolType.valueOf(name)
}
}
}
}
abstract class CastingDeviceLegacy {
abstract val protocol: CastProtocolType;
abstract val isReady: Boolean;
abstract var usedRemoteAddress: InetAddress?;
abstract var localAddress: InetAddress?;
abstract val canSetVolume: Boolean;
abstract val canSetSpeed: Boolean;
var name: String? = null;
var isPlaying: Boolean = false
set(value) {
val changed = value != field;
field = value;
if (changed) {
onPlayChanged.emit(value);
}
};
private var lastTimeChangeTime_ms: Long = 0
var time: Double = 0.0
private set
protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastTimeChangeTime_ms && value != time) {
time = value
lastTimeChangeTime_ms = changeTime_ms
onTimeChanged.emit(value)
}
}
private var lastDurationChangeTime_ms: Long = 0
var duration: Double = 0.0
private set
protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastDurationChangeTime_ms && value != duration) {
duration = value
lastDurationChangeTime_ms = changeTime_ms
onDurationChanged.emit(value)
}
}
private var lastVolumeChangeTime_ms: Long = 0
var volume: Double = 1.0
private set
protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) {
volume = value
lastVolumeChangeTime_ms = changeTime_ms
onVolumeChanged.emit(value)
}
}
private var lastSpeedChangeTime_ms: Long = 0
var speed: Double = 1.0
private set
protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) {
speed = value
lastSpeedChangeTime_ms = changeTime_ms
onSpeedChanged.emit(value)
}
}
val expectedCurrentTime: Double
get() {
val diff =
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
return time + diff;
};
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
set(value) {
val changed = value != field;
field = value;
if (changed) {
onConnectionStateChanged.emit(value);
}
};
var onConnectionStateChanged = Event1<CastConnectionState>();
var onPlayChanged = Event1<Boolean>();
var onTimeChanged = Event1<Double>();
var onDurationChanged = Event1<Double>();
var onVolumeChanged = Event1<Double>();
var onSpeedChanged = Event1<Double>();
abstract fun stopCasting();
abstract fun seekVideo(timeSeconds: Double);
abstract fun stopVideo();
abstract fun pauseVideo();
abstract fun resumeVideo();
abstract fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?
);
abstract fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?
);
open fun changeVolume(volume: Double) {
throw NotImplementedError()
}
open fun changeSpeed(speed: Double) {
throw NotImplementedError()
}
abstract fun start();
abstract fun stop();
abstract fun getDeviceInfo(): CastingDeviceInfo;
abstract fun getAddresses(): List<InetAddress>;
}
class CastingDeviceLegacyWrapper(val inner: CastingDeviceLegacy) : CastingDevice() {
override val isReady: Boolean get() = inner.isReady
override val usedRemoteAddress: InetAddress? get() = inner.usedRemoteAddress
override val localAddress: InetAddress? get() = inner.localAddress
override val name: String? get() = inner.name
override val onConnectionStateChanged: Event1<CastConnectionState> get() = inner.onConnectionStateChanged
override val onPlayChanged: Event1<Boolean> get() = inner.onPlayChanged
override val onTimeChanged: Event1<Double> get() = inner.onTimeChanged
override val onDurationChanged: Event1<Double> get() = inner.onDurationChanged
override val onVolumeChanged: Event1<Double> get() = inner.onVolumeChanged
override val onSpeedChanged: Event1<Double> get() = inner.onSpeedChanged
override var connectionState: CastConnectionState
get() = inner.connectionState
set(_) = Unit
override val protocolType: CastProtocolType get() = inner.protocol
override var isPlaying: Boolean
get() = inner.isPlaying
set(_) = Unit
override val expectedCurrentTime: Double
get() = inner.expectedCurrentTime
override var speed: Double
get() = inner.speed
set(_) = Unit
override var time: Double
get() = inner.time
set(_) = Unit
override var duration: Double
get() = inner.duration
set(_) = Unit
override var volume: Double
get() = inner.volume
set(_) = Unit
override fun canSetVolume(): Boolean = inner.canSetVolume
override fun canSetSpeed(): Boolean = inner.canSetSpeed
override fun resumePlayback() = inner.resumeVideo()
override fun pausePlayback() = inner.pauseVideo()
override fun stopPlayback() = inner.stopVideo()
override fun seekTo(timeSeconds: Double) = inner.seekVideo(timeSeconds)
override fun changeVolume(timeSeconds: Double) = inner.changeVolume(timeSeconds)
override fun changeSpeed(speed: Double) = inner.changeSpeed(speed)
override fun connect() = inner.start()
override fun disconnect() = inner.stop()
override fun getDeviceInfo(): CastingDeviceInfo = inner.getDeviceInfo()
override fun getAddresses(): List<InetAddress> = inner.getAddresses()
override fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = inner.loadVideo(streamType, contentType, contentId, resumePosition, duration, speed)
override fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = inner.loadContent(contentType, content, resumePosition, duration, speed)
override fun ensureThreadStarted() = when (inner) {
is FCastCastingDevice -> inner.ensureThreadStarted()
is ChromecastCastingDevice -> inner.ensureThreadsStarted()
else -> {}
}
}
@@ -27,7 +27,7 @@ import javax.net.ssl.SSLSocket
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
class ChromecastCastingDevice : CastingDevice {
class ChromecastCastingDevice : CastingDeviceLegacy {
//See for more info: https://developers.google.com/cast/docs/media/messages
override val protocol: CastProtocolType get() = CastProtocolType.CHROMECAST;
@@ -3,7 +3,6 @@ package com.futo.platformplayer.casting
import android.os.Looper
import android.util.Base64
import android.util.Log
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
@@ -25,7 +24,6 @@ import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
@@ -34,7 +32,6 @@ import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.math.BigInteger
import java.net.Inet4Address
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
@@ -72,7 +69,7 @@ enum class Opcode(val value: Byte) {
}
}
class FCastCastingDevice : CastingDevice {
class FCastCastingDevice : CastingDeviceLegacy {
//See for more info: TODO
override val protocol: CastProtocolType get() = CastProtocolType.FCAST;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,174 @@
package com.futo.platformplayer.casting
import android.content.Context
import android.util.Log
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo
import org.fcast.sender_sdk.ProtocolType
import org.fcast.sender_sdk.CastContext
import org.fcast.sender_sdk.NsdDeviceDiscoverer
class StateCastingExp : StateCasting() {
private val _context = CastContext()
var _deviceDiscoverer: NsdDeviceDiscoverer? = null
class DiscoveryEventHandler(
private val onDeviceAdded: (RsDeviceInfo) -> Unit,
private val onDeviceRemoved: (String) -> Unit,
private val onDeviceUpdated: (RsDeviceInfo) -> Unit,
) : org.fcast.sender_sdk.DeviceDiscovererEventHandler {
override fun deviceAvailable(deviceInfo: RsDeviceInfo) {
onDeviceAdded(deviceInfo)
}
override fun deviceChanged(deviceInfo: RsDeviceInfo) {
onDeviceUpdated(deviceInfo)
}
override fun deviceRemoved(deviceName: String) {
onDeviceRemoved(deviceName)
}
}
init {
if (BuildConfig.DEBUG) {
org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG)
}
}
override fun handleUrl(url: String) {
try {
val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!!
val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo)
connectDevice(CastingDeviceExp(foundDevice))
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle URL: $e")
}
}
override fun onStop() {
val ad = activeDevice ?: return
_resumeCastingDevice = ad.getDeviceInfo()
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
Logger.i(TAG, "Stopping active device because of onStop.")
try {
ad.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to disconnect from device: $e")
}
}
@Synchronized
override fun start(context: Context) {
if (_started)
return
_started = true
Log.i(TAG, "_resumeCastingDevice set null start")
_resumeCastingDevice = null
Logger.i(TAG, "CastingService starting...")
_castServer.start()
enableDeveloper(true)
Logger.i(TAG, "CastingService started.")
_deviceDiscoverer = NsdDeviceDiscoverer(
context,
DiscoveryEventHandler(
{ deviceInfo -> // Added
Logger.i(TAG, "Device added: ${deviceInfo.name}")
val device = _context.createDeviceFromInfo(deviceInfo)
val deviceHandle = CastingDeviceExp(device)
devices[deviceHandle.device.name()] = deviceHandle
invokeInMainScopeIfRequired {
onDeviceAdded.emit(deviceHandle)
}
},
{ deviceName -> // Removed
invokeInMainScopeIfRequired {
if (devices.containsKey(deviceName)) {
val device = devices.remove(deviceName)
if (device != null) {
onDeviceRemoved.emit(device)
}
}
}
},
{ deviceInfo -> // Updated
Logger.i(TAG, "Device updated: $deviceInfo")
val handle = devices[deviceInfo.name]
if (handle != null && handle is CastingDeviceExp) {
handle.device.setPort(deviceInfo.port)
handle.device.setAddresses(deviceInfo.addresses)
invokeInMainScopeIfRequired {
onDeviceChanged.emit(handle)
}
}
},
)
)
}
@Synchronized
override fun stop() {
if (!_started) {
return
}
_started = false
Logger.i(TAG, "CastingService stopping.")
_scopeIO.cancel()
_scopeMain.cancel()
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
val d = activeDevice
activeDevice = null
try {
d?.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to disconnect device: $e")
}
_castServer.stop()
_castServer.removeAllHandlers()
Logger.i(TAG, "CastingService stopped.")
_deviceDiscoverer = null
}
override fun startUpdateTimeJob(
onTimeJobTimeChanged_s: Event1<Long>,
setTime: (Long) -> Unit
): Job? = null
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDeviceExp {
val rsAddrs =
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) } // Throws!
val rsDeviceInfo = RsDeviceInfo(
name = deviceInfo.name,
protocol = when (deviceInfo.type) {
com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST
com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST
else -> throw IllegalArgumentException()
},
addresses = rsAddrs,
port = deviceInfo.port.toUShort(),
)
return CastingDeviceExp(_context.createDeviceFromInfo(rsDeviceInfo))
}
companion object {
private val TAG = "StateCastingExp"
}
}
@@ -0,0 +1,397 @@
package com.futo.platformplayer.casting
import android.content.Context
import android.net.Uri
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.util.Base64
import android.util.Log
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.net.InetAddress
import kotlinx.coroutines.delay
class StateCastingLegacy : StateCasting() {
private var _nsdManager: NsdManager? = null
private val _discoveryListeners = mapOf(
"_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
"_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice),
"_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice),
"_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
)
override fun handleUrl(url: String) {
val uri = Uri.parse(url)
if (uri.scheme != "fcast") {
throw Exception("Expected scheme to be FCast")
}
val type = uri.host
if (type != "r") {
throw Exception("Expected type r")
}
val connectionInfo = uri.pathSegments[0]
val json =
Base64.decode(connectionInfo, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
.toString(Charsets.UTF_8)
val networkConfig = Json.decodeFromString<FCastNetworkConfig>(json)
val tcpService = networkConfig.services.first { v -> v.type == 0 }
val foundInfo = addRememberedDevice(
CastingDeviceInfo(
name = networkConfig.name,
type = CastProtocolType.FCAST,
addresses = networkConfig.addresses.toTypedArray(),
port = tcpService.port
)
)
connectDevice(deviceFromInfo(foundInfo))
}
override fun onStop() {
val ad = activeDevice ?: return;
_resumeCastingDevice = ad.getDeviceInfo()
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
Logger.i(TAG, "Stopping active device because of onStop.");
ad.disconnect();
}
@Synchronized
override fun start(context: Context) {
if (_started)
return;
_started = true;
Log.i(TAG, "_resumeCastingDevice set null start")
_resumeCastingDevice = null;
Logger.i(TAG, "CastingService starting...");
_castServer.start();
enableDeveloper(true);
Logger.i(TAG, "CastingService started.");
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
startDiscovering()
}
@Synchronized
private fun startDiscovering() {
_nsdManager?.apply {
_discoveryListeners.forEach {
discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
}
}
}
@Synchronized
private fun stopDiscovering() {
_nsdManager?.apply {
_discoveryListeners.forEach {
try {
stopServiceDiscovery(it.value)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
}
}
@Synchronized
override fun stop() {
if (!_started)
return;
_started = false;
Logger.i(TAG, "CastingService stopping.")
stopDiscovering()
_scopeIO.cancel();
_scopeMain.cancel();
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
val d = activeDevice;
activeDevice = null;
d?.disconnect();
_castServer.stop();
_castServer.removeAllHandlers();
Logger.i(TAG, "CastingService stopped.")
_nsdManager = null
}
private fun createDiscoveryListener(addOrUpdate: (String, Array<InetAddress>, Int) -> Unit): NsdManager.DiscoveryListener {
return object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(regType: String) {
Log.d(TAG, "Service discovery started for $regType")
}
override fun onDiscoveryStopped(serviceType: String) {
Log.i(TAG, "Discovery stopped: $serviceType")
}
override fun onServiceLost(service: NsdServiceInfo) {
Log.e(TAG, "service lost: $service")
// TODO: Handle service lost, e.g., remove device
}
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
try {
_nsdManager?.stopServiceDiscovery(this)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
try {
_nsdManager?.stopServiceDiscovery(this)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
override fun onServiceFound(service: NsdServiceInfo) {
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
service.hostAddresses.toTypedArray()
} else {
arrayOf(service.host)
}
addOrUpdate(service.serviceName, addresses, service.port)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
_nsdManager?.registerServiceInfoCallback(
service,
{ it.run() },
object : NsdManager.ServiceInfoCallback {
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "onServiceUpdated: $serviceInfo")
addOrUpdate(
serviceInfo.serviceName,
serviceInfo.hostAddresses.toTypedArray(),
serviceInfo.port
)
}
override fun onServiceLost() {
Log.v(TAG, "onServiceLost: $service")
// TODO: Handle service lost
}
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
}
override fun onServiceInfoCallbackUnregistered() {
Log.v(TAG, "onServiceInfoCallbackUnregistered")
}
})
} else {
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
Log.v(TAG, "Resolve failed: $errorCode")
}
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
addOrUpdate(
serviceInfo.serviceName,
arrayOf(serviceInfo.host),
serviceInfo.port
)
}
})
}
}
}
}
override fun startUpdateTimeJob(
onTimeJobTimeChanged_s: Event1<Long>,
setTime: (Long) -> Unit
): Job? {
val d = activeDevice;
if (d is CastingDeviceLegacyWrapper && (d.inner is AirPlayCastingDevice || d.inner is ChromecastCastingDevice)) {
return _scopeMain.launch {
while (true) {
val device = instance.activeDevice
if (device == null || !device.isPlaying) {
break
}
delay(1000)
val time_ms = (device.expectedCurrentTime * 1000.0).toLong()
setTime(time_ms)
onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong())
}
}
}
return null
}
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice {
return CastingDeviceLegacyWrapper(
when (deviceInfo.type) {
CastProtocolType.CHROMECAST -> {
ChromecastCastingDevice(deviceInfo);
}
CastProtocolType.AIRPLAY -> {
AirPlayCastingDevice(deviceInfo);
}
CastProtocolType.FCAST -> {
FCastCastingDevice(deviceInfo);
}
}
)
}
private fun addOrUpdateChromeCastDevice(
name: String,
addresses: Array<InetAddress>,
port: Int
) {
return addOrUpdateCastDevice(
name,
deviceFactory = {
CastingDeviceLegacyWrapper(
ChromecastCastingDevice(
name,
addresses,
port
)
)
},
deviceUpdater = { d ->
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is ChromecastCastingDevice) {
return@addOrUpdateCastDevice false;
}
val changed =
addresses.contentEquals(d.inner.addresses) || d.name != name || d.inner.port != port;
if (changed) {
d.inner.name = name;
d.inner.addresses = addresses;
d.inner.port = port;
}
return@addOrUpdateCastDevice changed;
}
);
}
private fun addOrUpdateAirPlayDevice(name: String, addresses: Array<InetAddress>, port: Int) {
return addOrUpdateCastDevice(
name,
deviceFactory = {
CastingDeviceLegacyWrapper(
AirPlayCastingDevice(
name,
addresses,
port
)
)
},
deviceUpdater = { d ->
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is AirPlayCastingDevice) {
return@addOrUpdateCastDevice false;
}
val changed =
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
if (changed) {
d.inner.name = name;
d.inner.port = port;
d.inner.addresses = addresses;
}
return@addOrUpdateCastDevice changed;
}
);
}
private fun addOrUpdateFastCastDevice(name: String, addresses: Array<InetAddress>, port: Int) {
return addOrUpdateCastDevice(
name,
deviceFactory = { CastingDeviceLegacyWrapper(FCastCastingDevice(name, addresses, port)) },
deviceUpdater = { d ->
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is FCastCastingDevice) {
return@addOrUpdateCastDevice false;
}
val changed =
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
if (changed) {
d.inner.name = name;
d.inner.port = port;
d.inner.addresses = addresses;
}
return@addOrUpdateCastDevice changed;
}
);
}
private inline fun addOrUpdateCastDevice(
name: String,
deviceFactory: () -> CastingDevice,
deviceUpdater: (device: CastingDevice) -> Boolean
) {
var invokeEvents: (() -> Unit)? = null;
synchronized(devices) {
val device = devices[name];
if (device != null) {
val changed = deviceUpdater(device);
if (changed) {
invokeEvents = {
onDeviceChanged.emit(device);
}
}
} else {
val newDevice = deviceFactory();
this.devices[name] = newDevice
invokeEvents = {
onDeviceAdded.emit(newDevice);
};
}
}
invokeEvents?.let { _scopeMain.launch { it(); }; };
}
@Serializable
private data class FCastNetworkConfig(
val name: String,
val addresses: List<String>,
val services: List<FCastService>
)
@Serializable
private data class FCastService(
val port: Int,
val type: Int
)
companion object {
private val TAG = "StateCastingLegacy"
}
}
@@ -8,11 +8,13 @@ import android.view.View
import android.view.WindowManager
import android.widget.*
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.casting.CastProtocolType
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.toInetAddress
import com.futo.platformplayer.logging.Logger
class CastingAddDialog(context: Context?) : AlertDialog(context) {
@@ -38,7 +40,13 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
_buttonConfirm = findViewById(R.id.button_confirm);
_buttonTutorial = findViewById(R.id.button_tutorial)
ArrayAdapter.createFromResource(context, R.array.casting_device_type_array, R.layout.spinner_item_simple).also { adapter ->
val deviceTypeArray = if (Settings.instance.casting.experimentalCasting) {
R.array.exp_casting_device_type_array
} else {
R.array.casting_device_type_array
}
ArrayAdapter.createFromResource(context, deviceTypeArray, R.layout.spinner_item_simple).also { adapter ->
adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
_spinnerType.adapter = adapter;
};
@@ -101,7 +109,11 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
_textError.visibility = View.GONE;
val castingDeviceInfo = CastingDeviceInfo(name, castProtocolType, arrayOf(ip), port.toInt());
StateCasting.instance.addRememberedDevice(castingDeviceInfo);
try {
StateCasting.instance.addRememberedDevice(castingDeviceInfo)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to add remembered device: $e")
}
performDismiss();
};
@@ -7,7 +7,6 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.widget.Button
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
@@ -18,7 +17,6 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.CastingDevice
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
@@ -108,15 +106,16 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
synchronized(StateCasting.instance.devices) {
_devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name })
}
_rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames())
updateUnifiedList()
StateCasting.instance.onDeviceAdded.subscribe(this) { d ->
val name = d.name
if (name != null)
if (name != null) {
_devices.add(name)
updateUnifiedList()
updateUnifiedList()
}
}
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
@@ -12,12 +12,11 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.casting.AirPlayCastingDevice
import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.CastProtocolType
import com.futo.platformplayer.casting.CastingDevice
import com.futo.platformplayer.casting.ChromecastCastingDevice
import com.futo.platformplayer.casting.FCastCastingDevice
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment
import com.futo.platformplayer.logging.Logger
@@ -69,18 +68,18 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_buttonPlay = findViewById(R.id.button_play);
_buttonPlay.setOnClickListener {
StateCasting.instance.activeDevice?.resumeVideo()
StateCasting.instance.resumeVideo()
}
_buttonPause = findViewById(R.id.button_pause);
_buttonPause.setOnClickListener {
StateCasting.instance.activeDevice?.pauseVideo()
StateCasting.instance.pauseVideo()
}
_buttonStop = findViewById(R.id.button_stop);
_buttonStop.setOnClickListener {
(ownerActivity as MainActivity?)?.getFragment<VideoDetailFragment>()?.closeVideoDetails()
StateCasting.instance.activeDevice?.stopVideo()
StateCasting.instance.stopVideo()
}
_buttonNext = findViewById(R.id.button_next);
@@ -90,7 +89,11 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_buttonClose.setOnClickListener { dismiss(); };
_buttonDisconnect.setOnClickListener {
StateCasting.instance.activeDevice?.stopCasting();
try {
StateCasting.instance.activeDevice?.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Active device failed to disconnect: $e")
}
dismiss();
};
@@ -99,12 +102,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
return@OnChangeListener
}
val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener;
try {
activeDevice.seekVideo(value.toDouble());
} catch (e: Throwable) {
Logger.e(TAG, "Failed to change volume.", e);
}
StateCasting.instance.videoSeekTo(value.toDouble())
});
//TODO: Check if volume slider is properly hidden in all cases
@@ -113,14 +111,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
return@OnChangeListener
}
val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener;
if (activeDevice.canSetVolume) {
try {
activeDevice.changeVolume(value.toDouble());
} catch (e: Throwable) {
Logger.e(TAG, "Failed to change volume.", e);
}
}
StateCasting.instance.changeVolume(value.toDouble())
});
setLoading(false);
@@ -172,15 +163,25 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
private fun updateDevice() {
val d = StateCasting.instance.activeDevice ?: return;
if (d is ChromecastCastingDevice) {
_imageDevice.setImageResource(R.drawable.ic_chromecast);
_textType.text = "Chromecast";
} else if (d is AirPlayCastingDevice) {
_imageDevice.setImageResource(R.drawable.ic_airplay);
_textType.text = "AirPlay";
} else if (d is FCastCastingDevice) {
_imageDevice.setImageResource(R.drawable.ic_fc);
_textType.text = "FastCast";
when (d.protocolType) {
CastProtocolType.CHROMECAST -> {
_imageDevice.setImageResource(R.drawable.ic_chromecast);
_textType.text = "Chromecast";
}
CastProtocolType.AIRPLAY -> {
_imageDevice.setImageResource(R.drawable.ic_airplay);
_textType.text = "AirPlay";
}
CastProtocolType.FCAST -> {
_imageDevice.setImageResource(
if (Settings.instance.casting.experimentalCasting) {
R.drawable.ic_exp_fc
} else {
R.drawable.ic_fc
}
)
_textType.text = "FCast";
}
}
_textName.text = d.name;
@@ -192,7 +193,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_sliderPosition.value = d.time.toFloat().coerceAtLeast(0.0f).coerceAtMost(dur)
_sliderPosition.valueTo = dur
if (d.canSetVolume) {
if (d.canSetVolume()) {
_layoutVolumeAdjustable.visibility = View.VISIBLE;
_layoutVolumeFixed.visibility = View.GONE;
} else {
@@ -214,8 +215,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
CastConnectionState.CONNECTED -> {
enableControls(interactiveControls)
}
CastConnectionState.CONNECTING,
CastConnectionState.DISCONNECTED -> {
CastConnectionState.CONNECTING, CastConnectionState.DISCONNECTED -> {
disableControls(interactiveControls)
}
}
@@ -242,10 +242,12 @@ class V8Plugin {
}
fun <T> busy(handle: ()->T): T {
_busyLock.lock();
//Logger.i(TAG, "Busy Enter [" + _busyLock.holdCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString())
try {
return handle();
}
finally {
//Logger.i(TAG, "Busy Leave [" + _busyLock.holdCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString()+ ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString())
_busyLock.unlock();
}
/*
@@ -21,6 +21,7 @@ import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.PolycentricHomeActivity
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.comments.LazyComment
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.logging.Logger
@@ -170,8 +171,8 @@ class CommentsFragment : MainFragment() {
return@showConfirmationDialog
}
val index = _comments.indexOf(comment)
if (index != -1) {
val index = _comments.indexOfFirst { it == comment || (it is LazyComment && it.getUnderlyingComment() == comment) }
if (index >= 0) {
_comments.removeAt(index)
_adapterComments.notifyItemRemoved(_adapterComments.childToParentPosition(index))
@@ -279,6 +279,14 @@ class HomeFragment : MainFragment() {
else {
view.setToggle(!active);
}
}, { view, views, enabled ->
val toDisable = views.filter { it != view && it.tag == "plugins" };
if(!view.isActive)
view.handleClick();
for(tag in toDisable) {
if(tag.isActive)
tag.handleClick();
}
}).withTag("plugins")
})
else listOf())
@@ -576,9 +576,8 @@ class VideoDetailView : ConstraintLayout {
if(chapter?.type == ChapterType.SKIPPABLE) {
_layoutSkip.visibility = VISIBLE;
} else if(chapter?.type == ChapterType.SKIP || chapter?.type == ChapterType.SKIPONCE) {
val ad = StateCasting.instance.activeDevice
if (ad != null) {
ad.seekVideo(chapter.timeEnd)
if (StateCasting.instance.activeDevice != null) {
StateCasting.instance.videoSeekTo(chapter.timeEnd)
} else {
_player.seekTo((chapter.timeEnd * 1000).toLong());
}
@@ -886,7 +885,7 @@ class VideoDetailView : ConstraintLayout {
if (ad != null) {
val currentChapter = _cast.getCurrentChapter((ad.time * 1000).toLong());
if(currentChapter?.type == ChapterType.SKIPPABLE) {
ad.seekVideo(currentChapter.timeEnd);
StateCasting.instance.videoSeekTo(currentChapter.timeEnd);
}
} else {
val currentChapter = _player.getCurrentChapter(_player.position);
@@ -1154,7 +1153,7 @@ class VideoDetailView : ConstraintLayout {
//Recover cancelled loads
if(video == null) {
val t = (lastPositionMilliseconds / 1000.0f).roundToLong();
if(_searchVideo != null)
if(_searchVideo != null && !wasLoginCall)
setVideoOverview(_searchVideo!!, true, t);
else if(_url != null && !wasLoginCall)
setVideo(_url!!, t, _playWhenReady);
@@ -2368,11 +2367,11 @@ class VideoDetailView : ConstraintLayout {
?.distinct()
?.toList() ?: listOf() else audioSources?.toList() ?: listOf();
val canSetSpeed = !_isCasting || StateCasting.instance.activeDevice?.canSetSpeed == true
val canSetSpeed = !_isCasting || StateCasting.instance.activeDevice?.canSetSpeed() == true
val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()
val qualityPlaybackSpeedTitle = if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", currentPlaybackRate)})"); } else null;
_overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString(
R.string.quality), null, true,
R.string.quality), null, true,
qualityPlaybackSpeedTitle,
if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply {
val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds();
@@ -2393,7 +2392,7 @@ class VideoDetailView : ConstraintLayout {
val newPlaybackSpeed = playbackSpeedString.toDouble();
if (_isCasting) {
val ad = StateCasting.instance.activeDevice ?: return@subscribe
if (!ad.canSetSpeed) {
if (!ad.canSetSpeed()) {
return@subscribe
}
@@ -2517,6 +2516,7 @@ class VideoDetailView : ConstraintLayout {
if (!StateCasting.instance.resumeVideo()) {
_player.play();
}
onShouldEnterPictureInPictureChanged.emit()
//TODO: This was needed because handleLowerVolume was done.
//_player.setVolume(1.0f);
@@ -2533,6 +2533,7 @@ class VideoDetailView : ConstraintLayout {
if (!StateCasting.instance.pauseVideo()) {
_player.pause();
}
onShouldEnterPictureInPictureChanged.emit()
}
private fun handleSeek(ms: Long) {
Logger.i(TAG, "handleSeek(ms=$ms)")
@@ -3,16 +3,9 @@ package com.futo.platformplayer.models
import com.futo.platformplayer.casting.CastProtocolType
@kotlinx.serialization.Serializable
class CastingDeviceInfo {
var name: String;
var type: CastProtocolType;
var addresses: Array<String>;
var port: Int;
constructor(name: String, type: CastProtocolType, addresses: Array<String>, port: Int) {
this.name = name;
this.type = type;
this.addresses = addresses;
this.port = port;
}
}
class CastingDeviceInfo(
var name: String,
var type: CastProtocolType,
var addresses: Array<String>,
var port: Int
)
@@ -49,6 +49,7 @@ 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 com.futo.polycentric.core.toBase64Url
import kotlinx.coroutines.*
import java.io.File
import java.util.*
@@ -135,8 +136,12 @@ class StateApp {
return _scope;
}
val scope: CoroutineScope get() {
val thisScope = scopeOrNull
?: throw IllegalStateException("Attempted to use a global lifetime scope while MainActivity is no longer available");
val thisScope = scopeOrNull;
if(thisScope == null) {
//throw IllegalStateException("Attempted to use a global lifetime scope while MainActivity is no longer available");
Logger.w(TAG, "Attempted to use a global lifetime scope while MainActivity is no longer available, USING GLOBAL SCOPE");
return GlobalScope;
}
return thisScope;
}
val scopeGetter: ()->CoroutineScope get() {
@@ -381,6 +386,26 @@ class StateApp {
_cacheDirectory?.let { ApiMethods.initCache(it) };
}
Logger.i(TAG, "MainApp Starting: Initializing [ModerationsManager]");
ModerationsManager.initialize(context);
Logger.i(TAG, "MainApp Starting: Setting [ModerationLevelProvider]");
ApiMethods.setModerationLevelProvider {
try {
ModerationsManager.getInstance().getCurrentModerationLevels()
} catch (e: IllegalStateException) {
null
}
}
ApiMethods.setModerationExemptSystemProvider {
try {
StatePolycentric.instance.processHandle?.system?.toProto()?.toByteArray()?.toBase64Url()
} catch (e: Throwable) {
null
}
}
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);
@@ -177,16 +177,11 @@ class StatePlatform {
}
withContext(Dispatchers.IO) {
var toDisables = mutableListOf<IPlatformClient>();
var enabled: Array<String>;
synchronized(_clientsLock) {
for(e in _enabledClients) {
try {
e.disable();
onSourceDisabled.emit(e);
}
catch(ex: Throwable) {
UIDialogs.appToast(ToastView.Toast("If this happens often, please inform the developers on Github", false, null, "Plugin [${e.name}] failed to disable"));
}
toDisables.add(e);
}
_enabledClients.clear();
@@ -236,6 +231,18 @@ class StatePlatform {
}
}
selectClients(*enabled);
for(toDisable in toDisables) {
launch(Dispatchers.IO) {
try {
toDisable.disable();
onSourceDisabled.emit(toDisable);
}
catch(ex: Throwable) {
Logger.e(TAG, "FAILED TO DISABLE CLIENT [${toDisable?.name}] AFTER UpdateAvailableClients", ex);
}
}
}
};
}
@@ -348,11 +355,11 @@ class StatePlatform {
StateApp.instance.handleCaptchaException(c, ex);
}
var toDisable: IPlatformClient? = null;
synchronized(_clientsLock) {
if (_enabledClients.contains(client)) {
_enabledClients.remove(client);
client.disable();
onSourceDisabled.emit(client);
toDisable = client;
newClient.initialize();
_enabledClients.add(newClient);
}
@@ -360,6 +367,18 @@ class StatePlatform {
_availableClients.removeIf { it.id == id };
_availableClients.add(newClient);
}
if(toDisable != null) {
launch(Dispatchers.IO) {
try {
toDisable?.disable();
onSourceDisabled.emit(client);
}
catch (ex: Throwable) {
Logger.e(TAG, "FAILED TO DISABLE CLIENT [${toDisable?.name}] AFTER RELOAD", ex);
}
}
}
afterReload?.invoke();
return@withContext newClient;
};
@@ -402,18 +402,25 @@ class StatePlugins {
}
val icon = config.absoluteIconUrl?.let { absIconUrl ->
withContext(Dispatchers.Main) {
it.setText("Saving plugin...");
it.setProgress(0.75);
}
val iconResp = client.get(absIconUrl);
if(iconResp.isOk)
return@let iconResp.body?.byteStream()?.use { it.readBytes() };
return@let null;
}
withContext(Dispatchers.Main) {
it.setText("Saving plugin...");
it.setProgress(0.75);
}
val installEx = StatePlugins.instance.createPlugin(config, script, icon, reinstall);
if(installEx != null)
throw installEx;
withContext(Dispatchers.Main) {
it.setText("Reloading available plugins...");
it.setProgress(0.9);
}
StatePlatform.instance.updateAvailableClients(context);
withContext(Dispatchers.Main) {
@@ -522,9 +529,7 @@ class StatePlugins {
if(id == StateDeveloper.DEV_ID)
throw IllegalStateException("Attempted to retrieve a persistent developer plugin, this is not allowed");
synchronized(_plugins) {
return _plugins.findItem { it.config.id == id };
}
return _plugins.findItem { it.config.id == id };
}
fun getPlugins(): List<SourcePluginDescriptor> {
return _plugins.getItems();
@@ -533,12 +538,10 @@ class StatePlugins {
fun deletePlugin(id: String) {
synchronized(_pluginScripts) {
synchronized(_plugins) {
_pluginScripts.deleteFile(id);
val plugins = _plugins.findItems { it.config.id == id };
for(plugin in plugins)
_plugins.delete(plugin);
}
_pluginScripts.deleteFile(id);
val plugins = _plugins.findItems { it.config.id == id };
for(plugin in plugins)
_plugins.delete(plugin);
}
}
fun createPlugin(config: SourcePluginConfig, script: String, icon: ByteArray? = null, reinstall: Boolean = false, flags: List<String> = listOf()) : Throwable? {
@@ -28,6 +28,7 @@ import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ensureServerAndBackfill
import com.futo.polycentric.core.ClaimType
import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Opinion
@@ -46,8 +47,10 @@ import com.google.protobuf.ByteString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import userpackage.Protocol
import userpackage.Protocol.Reference
@@ -67,6 +70,8 @@ class StatePolycentric {
private val _commentPool = ForkJoinPool(2);
private val _commentPoolDispatcher = _commentPool.asCoroutineDispatcher();
private val _backgroundJob = SupervisorJob()
private val _backgroundScope = CoroutineScope(_backgroundJob + Dispatchers.IO)
fun load(context: Context) {
if (!enabled) {
@@ -173,6 +178,15 @@ class StatePolycentric {
}
_likeDislikeMap = newMap
// Ensure current server is registered & synced (runs in background)
_backgroundScope.launch {
try {
processHandle.ensureServerAndBackfill()
} catch (e: Throwable) {
Logger.w(TAG, "Failed to ensure server and backfill: "+e.message)
}
}
} else {
_activeProcessHandle.setAndSave("");
_likeDislikeMap = hashMapOf()
@@ -559,6 +573,11 @@ class StatePolycentric {
};
}
fun cleanup() {
_backgroundJob.cancel()
_commentPool.shutdown()
}
companion object {
private const val TAG = "StatePolycentric";
@@ -57,9 +57,12 @@ class StateSync {
return
}
var relayServerUrl = Settings.instance.synchronization.syncServer;
Logger.i(TAG, "Relay used: ${relayServerUrl}");
syncService = SyncService(
SERVICE_NAME,
RELAY_SERVER,
relayServerUrl,
RELAY_PUBLIC_KEY,
APP_ID,
StoreBasedSyncDatabaseProvider(),
@@ -4,6 +4,7 @@ import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.LinearLayout
import androidx.core.view.children
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
@@ -28,6 +29,8 @@ import kotlinx.coroutines.launch
class ToggleBar : LinearLayout {
private val _tagsContainer: LinearLayout;
private var allowLongPress: Boolean = false;
override fun onAttachedToWindow() {
super.onAttachedToWindow();
}
@@ -48,12 +51,31 @@ class ToggleBar : LinearLayout {
for(button in buttons) {
_tagsContainer.addView(ToggleTagView(context).apply {
if(button.icon > 0)
this.setInfo(button.icon, button.name, button.isActive, button.isButton);
this.setInfo(button.icon, button.name, button.isActive, button.isButton, button.tag);
else if(button.iconVariable != null)
this.setInfo(button.iconVariable, button.name, button.isActive, button.isButton);
this.setInfo(button.iconVariable, button.name, button.isActive, button.isButton, button.tag);
else
this.setInfo(button.name, button.isActive, button.isButton);
this.setInfo(button.name, button.isActive, button.isButton, button.tag);
this.onClick.subscribe({ view, enabled -> button.action(view, enabled); });
if(allowLongPress) {
this.onLongClick.subscribe({ view, enabled ->
for (tagView in _tagsContainer.children.filter { it is ToggleTagView }) {
if (tagView != view && tagView is ToggleTagView && !tagView.isButton) {
if (enabled && !tagView.isActive) {
tagView.handleClick();
} else if (!enabled && tagView.isActive) {
tagView.handleClick();
}
}
}
})
}
else if(button.actionLong != null) {
this.onLongClick.subscribe({ view, enabled ->
val tags = _tagsContainer.children.filter { it is ToggleTagView }.map { it as ToggleTagView }.toList();
button.actionLong!!(view, tags, enabled);
});
}
});
}
}
@@ -63,16 +85,18 @@ class ToggleBar : LinearLayout {
val icon: Int;
val iconVariable: ImageVariable?;
val action: (ToggleTagView, Boolean)->Unit;
val actionLong: ((ToggleTagView, List<ToggleTagView>, Boolean) -> Unit)?;
val isActive: Boolean;
var isButton: Boolean = false
private set;
var tag: String? = null;
constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit, actionLong: ((ToggleTagView, List<ToggleTagView>, Boolean)->Unit)? = null) {
this.name = name;
this.icon = 0;
this.iconVariable = icon;
this.action = action;
this.actionLong = actionLong;
this.isActive = isActive;
}
constructor(name: String, icon: Int, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
@@ -80,6 +104,7 @@ class ToggleBar : LinearLayout {
this.icon = icon;
this.iconVariable = null;
this.action = action;
this.actionLong = null;
this.isActive = isActive;
}
constructor(name: String, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
@@ -87,6 +112,7 @@ class ToggleBar : LinearLayout {
this.icon = 0;
this.iconVariable = null;
this.action = action;
this.actionLong = null;
this.isActive = isActive;
}
@@ -4,21 +4,19 @@ import android.graphics.drawable.Animatable
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.R
import com.futo.platformplayer.casting.AirPlayCastingDevice
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.CastProtocolType
import com.futo.platformplayer.casting.CastingDevice
import com.futo.platformplayer.casting.ChromecastCastingDevice
import com.futo.platformplayer.casting.FCastCastingDevice
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import androidx.core.view.isVisible
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger
class DeviceViewHolder : ViewHolder {
private val _layoutDevice: FrameLayout;
@@ -56,16 +54,18 @@ class DeviceViewHolder : ViewHolder {
val connect = {
device?.let { dev ->
if (dev.isReady) {
StateCasting.instance.activeDevice?.stopCasting()
StateCasting.instance.connectDevice(dev)
onConnect.emit(dev)
} else {
try {
view.context?.let { UIDialogs.toast(it, "Device not ready, may be offline") }
} catch (e: Throwable) {
//Ignored
try {
if (dev.isReady) {
StateCasting.instance.activeDevice?.stopPlayback()
StateCasting.instance.connectDevice(dev)
onConnect.emit(dev)
} else {
view.context?.let {
UIDialogs.toast(it, "Device not ready, may be offline")
}
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to connect: $e")
}
}
}
@@ -81,15 +81,25 @@ class DeviceViewHolder : ViewHolder {
}
fun bind(d: CastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) {
if (d is ChromecastCastingDevice) {
_imageDevice.setImageResource(R.drawable.ic_chromecast);
_textType.text = "Chromecast";
} else if (d is AirPlayCastingDevice) {
_imageDevice.setImageResource(R.drawable.ic_airplay);
_textType.text = "AirPlay";
} else if (d is FCastCastingDevice) {
_imageDevice.setImageResource(R.drawable.ic_fc);
_textType.text = "FCast";
when (d.protocolType) {
CastProtocolType.CHROMECAST -> {
_imageDevice.setImageResource(R.drawable.ic_chromecast);
_textType.text = "Chromecast";
}
CastProtocolType.AIRPLAY -> {
_imageDevice.setImageResource(R.drawable.ic_airplay);
_textType.text = "AirPlay";
}
CastProtocolType.FCAST -> {
_imageDevice.setImageResource(
if (Settings.instance.casting.experimentalCasting) {
R.drawable.ic_exp_fc
} else {
R.drawable.ic_fc
}
)
_textType.text = "FCast";
}
}
_textName.text = d.name;
@@ -136,4 +146,8 @@ class DeviceViewHolder : ViewHolder {
device = d;
}
companion object {
private val TAG = "DeviceViewHolder"
}
}
@@ -2,12 +2,7 @@ package com.futo.platformplayer.views.casting
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
@@ -21,14 +21,13 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.casting.AirPlayCastingDevice
import com.futo.platformplayer.casting.ChromecastCastingDevice
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.formatDuration
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.views.TargetTapLoaderView
import com.futo.platformplayer.views.behavior.GestureControlView
@@ -36,7 +35,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class CastView : ConstraintLayout {
@@ -99,19 +97,30 @@ class CastView : ConstraintLayout {
val d = StateCasting.instance.activeDevice ?: return@subscribe;
_speedHoldWasPlaying = d.isPlaying
_speedHoldPrevRate = d.speed
if (d.canSetSpeed)
d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed())
d.resumeVideo()
try {
if (d.canSetSpeed()) {
d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed())
}
d.resumePlayback()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to change playback speed to hold playback speed: $e")
}
}
_gestureControlView.onSpeedHoldEnd.subscribe {
val d = StateCasting.instance.activeDevice ?: return@subscribe;
if (!_speedHoldWasPlaying) d.pauseVideo()
d.changeSpeed(_speedHoldPrevRate)
try {
val d = StateCasting.instance.activeDevice ?: return@subscribe;
if (!_speedHoldWasPlaying) {
d.pausePlayback()
}
d.changeSpeed(_speedHoldPrevRate)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to change playback speed to previous hold playback speed: $e")
}
}
_gestureControlView.onSeek.subscribe {
val d = StateCasting.instance.activeDevice ?: return@subscribe;
StateCasting.instance.videoSeekTo(d.expectedCurrentTime + it / 1000);
StateCasting.instance.videoSeekTo( d.expectedCurrentTime + it / 1000);
};
_buttonLoop.setOnClickListener {
@@ -220,22 +229,9 @@ class CastView : ConstraintLayout {
stopTimeJob()
if(isPlaying) {
val d = StateCasting.instance.activeDevice;
if (d is AirPlayCastingDevice || d is ChromecastCastingDevice) {
_updateTimeJob = _scope.launch {
while (true) {
val device = StateCasting.instance.activeDevice;
if (device == null || !device.isPlaying) {
break;
}
delay(1000);
val time_ms = (device.expectedCurrentTime * 1000.0).toLong()
setTime(time_ms);
onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong())
}
}
}
StateCasting.instance.startUpdateTimeJob(
onTimeJobTimeChanged_s
) { setTime(it) }
if (!_inPictureInPicture) {
_buttonPause.visibility = View.VISIBLE;
@@ -333,4 +329,8 @@ class CastView : ConstraintLayout {
_loaderGame.visibility = View.VISIBLE
_loaderGame.startLoader(expectedDurationMs.toLong())
}
companion object {
private val TAG = "CastView";
}
}
@@ -4,7 +4,7 @@ import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.Event3
import java.lang.reflect.Field
@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY)
@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY, AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class AdvancedField();
@@ -50,6 +50,29 @@ class RadioGroupView : FlexboxLayout {
radioView.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
radioView.setInfo(option.first, initiallySelectedOptions.contains(option.second));
radioView.setPadding(_padding_px, _padding_px, _padding_px, _padding_px);
if(multiSelect)
radioView.onLongClick.subscribe {
val selected = !radioView.selected;
if (selected) {
selectedOptions.clear();
for(v in radioViews)
v.setIsSelected(true);
selectedOptions.addAll(options.map { it.second });
} else {
if(atLeastOne) {
for(v in radioViews)
v.setIsSelected(false);
selectedOptions.clear();
selectedOptions.add(option.second);
}
else {
for(v in radioViews)
v.setIsSelected(false);
selectedOptions.clear();
}
}
onSelectedChange.emit(selectedOptions);
}
radioView.onClick.subscribe {
val selected = !radioView.selected;
if (selected) {
@@ -20,6 +20,7 @@ class RadioView : LinearLayout {
val selected get() = _selected;
var onClick = Event0();
var onLongClick = Event0();
var onSelectedChange = Event1<Boolean>();
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
@@ -32,6 +33,13 @@ class RadioView : LinearLayout {
setIsSelected(!_selected)
}
};
_root.setOnLongClickListener {
onLongClick.emit();
if (_handleClick) {
setIsSelected(!_selected)
}
return@setOnLongClickListener true;
}
_root.setBackgroundResource(R.drawable.background_radio_unselected);
_textTag.setTextColor(ContextCompat.getColor(context, R.color.gray_67));
@@ -23,12 +23,16 @@ class ToggleTagView : LinearLayout {
private var _text: String = "";
private var _image: ImageView;
var tag: String? = null
private set;
var isActive: Boolean = false
private set;
var isButton: Boolean = false
private set;
var onClick = Event2<ToggleTagView, Boolean>();
var onLongClick = Event2<ToggleTagView, Boolean>();
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
LayoutInflater.from(context).inflate(R.layout.view_toggle_tag, this, true);
@@ -36,10 +40,25 @@ class ToggleTagView : LinearLayout {
_textTag = findViewById(R.id.text_tag);
_image = findViewById(R.id.image_tag);
_root.setOnClickListener {
if(!isButton)
setToggle(!isActive);
onClick.emit(this, isActive);
handleClick();
}
_root.setOnLongClickListener {
if(onLongClick.hasListeners())
onLongClick.emit(this, isActive);
else {
if(!isButton) {
setToggle(!isActive);
}
onClick.emit(this, isActive);
}
return@setOnLongClickListener true;
}
}
fun handleClick() {
if(!isButton)
setToggle(!isActive);
onClick.emit(this, isActive);
}
fun setToggle(isActive: Boolean) {
@@ -70,9 +89,10 @@ class ToggleTagView : LinearLayout {
_image.visibility = View.VISIBLE;
_textTag.visibility = if(!toggle.name.isNullOrEmpty()) View.VISIBLE else View.GONE;
this.isButton = isButton;
tag = toggle.tag;
}
fun setInfo(imageResource: Int, text: String, isActive: Boolean, isButton: Boolean = false) {
fun setInfo(imageResource: Int, text: String, isActive: Boolean, isButton: Boolean = false, tag: String? = null) {
_text = text;
_textTag.text = text;
setToggle(isActive);
@@ -80,8 +100,9 @@ class ToggleTagView : LinearLayout {
_image.visibility = View.VISIBLE;
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
this.isButton = isButton;
this.tag = tag;
}
fun setInfo(image: ImageVariable, text: String, isActive: Boolean, isButton: Boolean = false) {
fun setInfo(image: ImageVariable, text: String, isActive: Boolean, isButton: Boolean = false, tag: String? = null) {
_text = text;
_textTag.text = text;
setToggle(isActive);
@@ -89,13 +110,15 @@ class ToggleTagView : LinearLayout {
_image.visibility = View.VISIBLE;
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
this.isButton = isButton;
this.tag = tag;
}
fun setInfo(text: String, isActive: Boolean, isButton: Boolean = false) {
fun setInfo(text: String, isActive: Boolean, isButton: Boolean = false, tag: String? = null) {
_image.visibility = View.GONE;
_text = text;
_textTag.text = text;
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
setToggle(isActive);
this.isButton = isButton;
this.tag = tag;
}
}
@@ -161,8 +161,8 @@ class CommentsList : ConstraintLayout {
return@showConfirmationDialog
}
val index = _comments.indexOf(comment)
if (index != -1) {
val index = _comments.indexOfFirst { it == comment || (it is LazyComment && it.getUnderlyingComment() == comment) }
if (index >= 0) {
_comments.removeAt(index)
_adapterComments.notifyItemRemoved(_adapterComments.childToParentPosition(index))
@@ -11,10 +11,12 @@ import androidx.annotation.OptIn
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.PlayerView
import androidx.media3.ui.TimeBar
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StatePlayer
@@ -66,6 +68,11 @@ class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) :
videoView = findViewById(R.id.short_player_view)
progressBar = findViewById(R.id.short_player_progress_bar)
if(Settings.instance.playback.shortsFitVideo)
videoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT;
else
videoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM;
videoView.subtitleView?.setFixedTextSize(Dimension.SP, 18F);
if (!isInEditMode) {
@@ -64,6 +64,10 @@ 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.local.models.sources.LocalAudioContentSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioFileSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
@@ -480,6 +484,8 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
is IHLSManifestSource -> { swapVideoSourceHLS(videoSource); true; }
is IVideoUrlWidevineSource -> { swapVideoSourceUrlWidevine(videoSource); true; }
is IVideoUrlSource -> { swapVideoSourceUrl(videoSource); true; }
is LocalVideoFileSource -> { swapVideoSourceLocalFile(videoSource); true; }
is LocalVideoContentSource -> { swapVideoSourceLocalContent(videoSource); true; }
null -> { _lastVideoMediaSource = null; true;}
else -> throw IllegalArgumentException("Unsupported video source [${videoSource.javaClass.simpleName}]");
}
@@ -496,6 +502,8 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
is JSDashManifestRawAudioSource -> swapAudioSourceDashRaw(audioSource, play, resume, swapId);
is IAudioUrlWidevineSource -> { swapAudioSourceUrlWidevine(audioSource); true; }
is IAudioUrlSource -> { swapAudioSourceUrl(audioSource); true; }
is LocalAudioFileSource -> { swapAudioSourceLocalFile(audioSource); true; }
is LocalAudioContentSource -> { swapAudioSourceLocalContent(audioSource); true; }
null -> { _lastAudioMediaSource = null; true; }
else -> throw IllegalArgumentException("Unsupported video source [${audioSource.javaClass.simpleName}]");
}
@@ -514,6 +522,23 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
.createMediaSource(MediaItem.fromUri(Uri.fromFile(file)));
}
@OptIn(UnstableApi::class)
private fun swapVideoSourceLocalFile(videoSource: LocalVideoFileSource) {
Logger.i(TAG, "Loading VideoSource [Local]");
val file = videoSource.file;
if(!file.exists())
throw IllegalArgumentException("File for this video does not exist");
_lastVideoMediaSource = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context))
.createMediaSource(MediaItem.fromUri(Uri.fromFile(file)));
}
@OptIn(UnstableApi::class)
private fun swapVideoSourceLocalContent(videoSource: LocalVideoContentSource) {
Logger.i(TAG, "Loading VideoSource [Local]");
if(!videoSource.contentUrl.startsWith("content://"))
throw IllegalArgumentException("Not a content uri");
_lastVideoMediaSource = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context))
.createMediaSource(MediaItem.fromUri(videoSource.contentUrl));
}
@OptIn(UnstableApi::class)
private fun swapVideoSourceUrlRange(videoSource: JSVideoUrlRangeSource) {
Logger.i(TAG, "Loading JSVideoUrlRangeSource");
if(videoSource.hasItag) {
@@ -707,6 +732,23 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
.createMediaSource(MediaItem.fromUri(Uri.fromFile(file)));
}
@OptIn(UnstableApi::class)
private fun swapAudioSourceLocalFile(audioSource: LocalAudioFileSource) {
Logger.i(TAG, "Loading VideoSource [Local]");
val file = audioSource.file;
if(!file.exists())
throw IllegalArgumentException("File for this video does not exist");
_lastAudioMediaSource = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context))
.createMediaSource(MediaItem.fromUri(Uri.fromFile(file)));
}
@OptIn(UnstableApi::class)
private fun swapAudioSourceLocalContent(audioSource: LocalAudioContentSource) {
Logger.i(TAG, "Loading VideoSource [Local]");
if(!audioSource.contentUrl.startsWith("content://"))
throw IllegalArgumentException("Not a content uri");
_lastAudioMediaSource = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context))
.createMediaSource(MediaItem.fromUri(audioSource.contentUrl));
}
@OptIn(UnstableApi::class)
private fun swapAudioSourceUrlRange(audioSource: JSAudioUrlRangeSource) {
Logger.i(TAG, "Loading JSAudioUrlRangeSource");
if(audioSource.hasItag) {
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/colorPrimary" />
<corners android:radius="18dp" />
</shape>
+14
View File
@@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="111.96dp"
android:height="114.46dp"
android:viewportWidth="111.96"
android:viewportHeight="114.46">
<path
android:pathData="m84.76,5.58c2.06,-2.06 0.6,-5.58 -2.31,-5.58H3.27C1.46,-0 -0,1.46 -0,3.27V82.45c0,2.91 3.52,4.37 5.58,2.31L20.37,69.98c0.61,-0.61 0.96,-1.45 0.96,-2.31V24.6c0,-1.81 1.46,-3.27 3.27,-3.27h43.07c0.87,0 1.7,-0.34 2.31,-0.96z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="m45.68,73.5v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,91.9c-0.68,0 -1.23,-0.55 -1.23,-1.23v-17.18c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,69.57c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,69.57c-0.68,0 -1.23,-0.55 -1.23,-1.23L48.38,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L48.38,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM89.77,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23h-16.89c-0.68,0 -1.23,-0.55 -1.23,-1.23L70.43,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,73.5v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,91.9c-0.68,0 -1.23,-0.55 -1.23,-1.23v-17.18c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM89.77,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23h-16.89c-0.68,0 -1.23,-0.55 -1.23,-1.23L70.43,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,95.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,114.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,95.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM111.77,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L93.65,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L92.43,28.83c0,-0.68 0.55,-1.23 1.23,-1.23L110.55,27.6c0.68,0 1.23,0.55 1.23,1.23z"
android:strokeWidth="0"
android:fillColor="#ffffff"/>
</vector>
@@ -29,6 +29,8 @@
android:id="@+id/image_qr"
android:layout_width="200dp"
android:layout_height="200dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:padding="8dp"
app:srcCompat="@drawable/ic_qr"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
@@ -37,15 +39,31 @@
<TextView
android:id="@+id/text_qr"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/scan_to_import"
android:fontFamily="@font/inter_light"
android:textSize="32dp"
android:textAlignment="center"
android:layout_marginTop="12dp"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
app:layout_constraintTop_toBottomOf="@id/image_qr"
app:layout_constraintLeft_toLeftOf="@id/image_qr"
app:layout_constraintRight_toRightOf="@id/image_qr" />
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
<TextView
android:id="@+id/text_qr_hint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/tap_qr_code_for_fullscreen"
android:fontFamily="@font/inter_light"
android:textSize="14dp"
android:textColor="@color/gray_400"
android:layout_marginTop="8dp"
app:layout_constraintTop_toBottomOf="@id/text_qr"
app:layout_constraintLeft_toLeftOf="@id/text_qr"
app:layout_constraintRight_toRightOf="@id/text_qr" />
<LinearLayout
android:id="@+id/layout_buttons"
@@ -55,7 +73,7 @@
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:layout_marginTop="30dp"
app:layout_constraintTop_toBottomOf="@id/text_qr"
app:layout_constraintTop_toBottomOf="@id/text_qr_hint"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent">
@@ -75,6 +93,15 @@
app:buttonSubText="@string/copy_your_identity_to_clipboard"
app:buttonIcon="@drawable/ic_copy"
android:layout_marginTop="8dp" />
<com.futo.platformplayer.views.buttons.BigButton
android:id="@+id/button_export_file"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:buttonText="@string/export_to_file"
app:buttonSubText="@string/save_profile_to_file_for_sharing"
app:buttonIcon="@drawable/ic_download"
android:layout_marginTop="8dp" />
</LinearLayout>
<ProgressBar
@@ -47,6 +47,28 @@
android:text="@string/scan_qr" />
</LinearLayout>
<LinearLayout
android:id="@+id/button_import_file"
android:layout_width="140dp"
android:layout_height="40dp"
android:background="@drawable/background_button_primary_round"
android:gravity="center"
android:layout_marginTop="10dp"
android:layout_marginBottom="10dp"
android:orientation="horizontal"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/button_scan_profile">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/inter_light"
android:textColor="@color/white"
android:textSize="16dp"
android:text="@string/import_from_file" />
</LinearLayout>
<TextView
android:id="@+id/text_or"
android:layout_width="wrap_content"
@@ -55,7 +77,7 @@
android:fontFamily="@font/inter_light"
android:textSize="28dp"
android:layout_marginTop="30dp"
app:layout_constraintTop_toBottomOf="@id/button_scan_profile"
app:layout_constraintTop_toBottomOf="@id/button_import_file"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
@@ -0,0 +1,230 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
android:padding="16dp">
<LinearLayout
android:id="@+id/header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintTop_toTopOf="parent">
<ImageButton
android:id="@+id/button_back"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/back"
android:padding="12dp"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_back_thin_white_16dp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_weight="1"
android:text="Moderation Settings"
android:textColor="?attr/colorOnBackground"
android:textSize="18sp"
android:textStyle="bold" />
</LinearLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/header">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- BEGIN BLURB -->
<TextView
android:id="@+id/text_moderation_blurb"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:text="A lower slider value hides more content. Posts or comments with a tag level ABOVE your selected value will be hidden. (Level 0 = most strict, Level 3 = allow everything)"
android:textColor="?attr/colorOnSurface"
android:textSize="14sp" />
<!-- END BLURB -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardBackgroundColor="?attr/colorSurface"
app:cardCornerRadius="16dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Moderation Levels"
android:textColor="?attr/colorOnSurface"
android:textSize="18sp"
android:textStyle="bold" />
<!-- Offensive Content -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Offensive Content"
android:textColor="?attr/colorOnSurface"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/text_offensive_desc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Description"
android:textColor="?attr/colorOnSurface"
android:textSize="14sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<SeekBar
android:id="@+id/seekbar_offensive"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:max="3"
android:progress="2" />
<TextView
android:id="@+id/text_offensive_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:background="@drawable/bg_slider_value"
android:gravity="center"
android:minWidth="36dp"
android:padding="8dp"
android:text="2"
android:textColor="@android:color/white"
android:textStyle="bold" />
</LinearLayout>
<!-- Explicit Content -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Explicit Content"
android:textColor="?attr/colorOnSurface"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/text_explicit_desc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Description"
android:textColor="?attr/colorOnSurface"
android:textSize="14sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<SeekBar
android:id="@+id/seekbar_explicit"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:max="3"
android:progress="1" />
<TextView
android:id="@+id/text_explicit_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:background="@drawable/bg_slider_value"
android:gravity="center"
android:minWidth="36dp"
android:padding="8dp"
android:text="1"
android:textColor="@android:color/white"
android:textStyle="bold" />
</LinearLayout>
<!-- Violence -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Violence"
android:textColor="?attr/colorOnSurface"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/text_violence_desc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Description"
android:textColor="?attr/colorOnSurface"
android:textSize="14sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<SeekBar
android:id="@+id/seekbar_violence"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:max="3"
android:progress="1" />
<TextView
android:id="@+id/text_violence_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:background="@drawable/bg_slider_value"
android:gravity="center"
android:minWidth="36dp"
android:padding="8dp"
android:text="1"
android:textColor="@android:color/white"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
</LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -73,7 +73,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/inter_regular"
android:text="Further customize your profile, make platform claims, and other creator-specific features in the Harbor app."
android:text="Further customize your profile, make platform claims, and other creator-specific features in the Polycentric app."
android:textSize="12dp"
android:linksClickable="true"
android:paddingLeft="20dp"
@@ -90,7 +90,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/inter_regular"
android:text="https://harbor.social"
android:text="https://polycentric.io"
android:textSize="12dp"
android:linksClickable="true"
android:paddingLeft="20dp"
@@ -107,7 +107,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/inter_regular"
android:text="After you've installed Harbor you can export this profile to Harbor using the Export button."
android:text="After you've installed Polycentric you can export this profile to Polycentric using the Export button."
android:textSize="12dp"
android:linksClickable="true"
android:paddingLeft="20dp"
@@ -133,13 +133,13 @@
app:layout_constraintBottom_toBottomOf="parent">
<com.futo.platformplayer.views.buttons.BigButton
android:id="@+id/button_open_harbor_profile"
android:id="@+id/button_moderation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:buttonText="Harbor Profile"
app:buttonSubText="See your Harbor profile in a browser"
app:buttonIcon="@drawable/ic_export"
android:layout_marginTop="8dp" />
app:buttonSubText="Set moderation settings for polycentric comments"
android:layout_marginTop="8dp"
app:buttonIcon="@drawable/ic_settings"
app:buttonText="Moderation Settings" />
<com.futo.platformplayer.views.buttons.BigButton
android:id="@+id/button_export"
@@ -168,6 +168,7 @@
app:buttonIcon="@drawable/ic_trash"
android:layout_marginTop="8dp"
app:buttonBackground="@drawable/background_big_button_red"/>
</LinearLayout>
<com.futo.platformplayer.views.overlays.LoaderOverlay
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black">
<!-- Top navigation bar -->
<ImageButton
android:id="@+id/button_back_fullscreen"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_button_back"
android:padding="10dp"
android:scaleType="fitCenter"
android:background="?android:attr/selectableItemBackgroundBorderless"
app:srcCompat="@drawable/ic_back_thin_white_16dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
android:layout_marginTop="16dp"
android:layout_marginStart="16dp" />
<ImageButton
android:id="@+id/button_close_fullscreen"
android:layout_width="50dp"
android:layout_height="50dp"
android:contentDescription="@string/cd_button_close"
android:padding="10dp"
android:scaleType="fitCenter"
android:background="?android:attr/selectableItemBackgroundBorderless"
app:srcCompat="@drawable/ic_close"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp" />
<!-- Full screen QR code -->
<ImageView
android:id="@+id/image_qr_fullscreen"
android:layout_width="0dp"
android:layout_height="0dp"
android:scaleType="fitCenter"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:padding="40dp"
app:layout_constraintTop_toBottomOf="@id/button_back_fullscreen"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:layout_marginTop="20dp"
android:layout_marginBottom="20dp"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -63,6 +63,16 @@
android:layout_height="wrap_content"
tools:ignore="HardcodedText" />
<EditText
android:id="@+id/dialog_text_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_marginRight="10dp"
android:layout_marginTop="10dp"
android:visibility="gone"
/>
<LinearLayout
android:id="@+id/dialog_buttons"
android:layout_width="match_parent"
@@ -9,7 +9,7 @@
android:layout_above="@+id/short_player_progress_bar"
android:background="@color/black"
app:default_artwork="@drawable/placeholder_video_thumbnail"
app:resize_mode="fill"
app:resize_mode="zoom"
app:show_buffering="when_playing"
app:use_artwork="true"
app:use_controller="false" />
+15
View File
@@ -704,4 +704,19 @@
<item>Newest</item>
<item>Oldest</item>
</string-array>
<string-array name="app_languages">
<item>النظام</item>
<item>الإنجليزية (EN)</item>
<item>الألمانية (DE)</item>
<item>الإسبانية (ES)</item>
<item>البرتغالية (PT)</item>
<item>الفرنسية (FR)</item>
<item>اليابانية (JA)</item>
<item>الكورية (KO)</item>
<item>الصينية (ZH)</item>
<item>الروسية (RU)</item>
<item>العربية (AR)</item>
<item>الإيطالية (IT)</item>
<item>التركية (TR)</item>
</string-array>
</resources>
+17
View File
@@ -404,6 +404,8 @@
<string name="unknown_reconstruction_type">Unbekannter Rekonstruktionstyp</string>
<string name="failed_to_parse_newpipe_subscriptions">Fehler beim Parsen von NewPipe-Abonnements</string>
<string name="failed_to_generate_qr_code">Fehler beim Generieren des QR-Codes</string>
<string name="qr_code_too_large_use_text_below">QR-Code zu groß. Verwenden Sie den Text unten, um Ihr Profil zu teilen.</string>
<string name="tap_qr_code_for_fullscreen">Tippen Sie auf den QR-Code für Vollbildansicht</string>
<string name="share_text">Text teilen</string>
<string name="copied_text">Text kopiert</string>
<string name="must_be_at_least_3_characters_long">Muss mindestens 3 Zeichen lang sein.</string>
@@ -704,4 +706,19 @@
<item>Newest</item>
<item>Oldest</item>
</string-array>
<string-array name="app_languages">
<item>System</item>
<item>Englisch (EN)</item>
<item>Deutsch (DE)</item>
<item>Spanisch (ES)</item>
<item>Portugiesisch (PT)</item>
<item>Französisch (FR)</item>
<item>Japanisch (JA)</item>
<item>Koreanisch (KO)</item>
<item>Chinesisch (ZH)</item>
<item>Russisch (RU)</item>
<item>Arabisch (AR)</item>
<item>Italienisch (IT)</item>
<item>Türkisch (TR)</item>
</string-array>
</resources>
+17
View File
@@ -381,6 +381,8 @@
<string name="unknown_reconstruction_type">Tipo de reconstrucción desconocido</string>
<string name="failed_to_parse_newpipe_subscriptions">Error al analizar las suscripciones de NewPipe</string>
<string name="failed_to_generate_qr_code">Error al generar el código QR</string>
<string name="qr_code_too_large_use_text_below">Código QR demasiado grande. Use el texto de abajo para compartir su perfil.</string>
<string name="tap_qr_code_for_fullscreen">Toca el código QR para vista completa</string>
<string name="share_text">Compartir texto</string>
<string name="copied_text">Texto copiado</string>
<string name="must_be_at_least_3_characters_long">Debe tener al menos 3 caracteres de longitud.</string>
@@ -714,4 +716,19 @@
<item>Newest</item>
<item>Oldest</item>
</string-array>
<string-array name="app_languages">
<item>Sistema</item>
<item>Inglés (EN)</item>
<item>Alemán (DE)</item>
<item>Español (ES)</item>
<item>Portugués (PT)</item>
<item>Francés (FR)</item>
<item>Japonés (JA)</item>
<item>Coreano (KO)</item>
<item>Chino (ZH)</item>
<item>Ruso (RU)</item>
<item>Árabe (AR)</item>
<item>Italiano (IT)</item>
<item>Turco (TR)</item>
</string-array>
</resources>
+17
View File
@@ -420,6 +420,8 @@
<string name="unknown_reconstruction_type">Type de reconstruction inconnu</string>
<string name="failed_to_parse_newpipe_subscriptions">Échec de l\'analyse des abonnements NewPipe</string>
<string name="failed_to_generate_qr_code">Échec de la génération du code QR</string>
<string name="qr_code_too_large_use_text_below">Code QR trop volumineux. Utilisez le texte ci-dessous pour partager votre profil.</string>
<string name="tap_qr_code_for_fullscreen">Appuyez sur le code QR pour la vue plein écran</string>
<string name="share_text">Partager le texte</string>
<string name="copied_text">Texte copié</string>
<string name="must_be_at_least_3_characters_long">Doit comporter au moins 3 caractères.</string>
@@ -712,4 +714,19 @@
<item>Newest</item>
<item>Oldest</item>
</string-array>
<string-array name="app_languages">
<item>Système</item>
<item>Anglais (EN)</item>
<item>Allemand (DE)</item>
<item>Espagnol (ES)</item>
<item>Portugais (PT)</item>
<item>Français (FR)</item>
<item>Japonais (JA)</item>
<item>Coréen (KO)</item>
<item>Chinois (ZH)</item>
<item>Russe (RU)</item>
<item>Arabe (AR)</item>
<item>Italien (IT)</item>
<item>Turc (TR)</item>
</string-array>
</resources>
File diff suppressed because it is too large Load Diff
+15
View File
@@ -704,4 +704,19 @@
<item>Newest</item>
<item>Oldest</item>
</string-array>
<string-array name="app_languages">
<item>システム</item>
<item>英語 (EN)</item>
<item>ドイツ語 (DE)</item>
<item>スペイン語 (ES)</item>
<item>ポルトガル語 (PT)</item>
<item>フランス語 (FR)</item>
<item>日本語 (JA)</item>
<item>韓国語 (KO)</item>
<item>中国語 (ZH)</item>
<item>ロシア語 (RU)</item>
<item>アラビア語 (AR)</item>
<item>イタリア語 (IT)</item>
<item>トルコ語 (TR)</item>
</string-array>
</resources>
+15
View File
@@ -704,4 +704,19 @@
<item>Newest</item>
<item>Oldest</item>
</string-array>
<string-array name="app_languages">
<item>시스템</item>
<item>영어 (EN)</item>
<item>독일어 (DE)</item>
<item>스페인어 (ES)</item>
<item>포르투갈어 (PT)</item>
<item>프랑스어 (FR)</item>
<item>일본어 (JA)</item>
<item>한국어 (KO)</item>
<item>중국어 (ZH)</item>
<item>러시아어 (RU)</item>
<item>아랍어 (AR)</item>
<item>이탈리아어 (IT)</item>
<item>터키어 (TR)</item>
</string-array>
</resources>
+15
View File
@@ -704,4 +704,19 @@
<item>Newest</item>
<item>Oldest</item>
</string-array>
<string-array name="app_languages">
<item>Sistema</item>
<item>Inglês (EN)</item>
<item>Alemão (DE)</item>
<item>Espanhol (ES)</item>
<item>Português (PT)</item>
<item>Francês (FR)</item>
<item>Japonês (JA)</item>
<item>Coreano (KO)</item>
<item>Chinês (ZH)</item>
<item>Russo (RU)</item>
<item>Árabe (AR)</item>
<item>Italiano (IT)</item>
<item>Turco (TR)</item>
</string-array>
</resources>
+15
View File
@@ -704,4 +704,19 @@
<item>Newest</item>
<item>Oldest</item>
</string-array>
<string-array name="app_languages">
<item>Система</item>
<item>Английский (EN)</item>
<item>Немецкий (DE)</item>
<item>Испанский (ES)</item>
<item>Португальский (PT)</item>
<item>Французский (FR)</item>
<item>Японский (JA)</item>
<item>Корейский (KO)</item>
<item>Китайский (ZH)</item>
<item>Русский (RU)</item>
<item>Арабский (AR)</item>
<item>Итальянский (IT)</item>
<item>Турецкий (TR)</item>
</string-array>
</resources>
File diff suppressed because it is too large Load Diff
+15
View File
@@ -704,4 +704,19 @@
<item>Newest</item>
<item>Oldest</item>
</string-array>
<string-array name="app_languages">
<item>系统</item>
<item>英语 (EN)</item>
<item>德语 (DE)</item>
<item>西班牙语 (ES)</item>
<item>葡萄牙语 (PT)</item>
<item>法语 (FR)</item>
<item>日语 (JA)</item>
<item>韩语 (KO)</item>
<item>中文 (ZH)</item>
<item>俄语 (RU)</item>
<item>阿拉伯语 (AR)</item>
<item>意大利语 (IT)</item>
<item>土耳其语 (TR)</item>
</string-array>
</resources>
+26
View File
@@ -82,6 +82,8 @@
<string name="allow_ipv6_description">If casting over IPV6 is allowed, can cause issues on some networks</string>
<string name="allow_ipv4">Allow Link Local IPV4</string>
<string name="allow_ipv4_description">If casting over IPV4 link local is allowed, can cause issues on some networks</string>
<string name="experimental_cast">Experimental</string>
<string name="experimental_cast_description">Use experimental casting backend (requires restart)</string>
<string name="discover">Discover</string>
<string name="find_new_video_sources_to_add">Find new video sources to add</string>
<string name="these_sources_have_been_disabled">These sources have been disabled</string>
@@ -338,6 +340,8 @@
<string name="test_background_worker">Test Background Worker</string>
<string name="test_background_worker_description"></string>
<string name="clear_payment">Clear Payment</string>
<string name="configure_sync_server">Configure Sync Server</string>
<string name="configure_sync_server_description">Allows you to change the Sync Server to a self-hosted one.</string>
<string name="clears_cookies_when_you_log_out">Clears cookies when you log out</string>
<string name="clears_in_app_browser_cookies">Clears in-app browser cookies</string>
<string name="configure_browsing_behavior">Configure browsing behavior</string>
@@ -438,6 +442,9 @@
<string name="delete_watchlist_on_finish_description">After you leave a video that you mostly watched, it will be removed from watch later.</string>
<string name="shorts_pregenerate">Pre-generate shorts sources</string>
<string name="shorts_pregenerate_description">Generates short sources (when applicable) one video ahead</string>
<string name="shorts_fit_video">Fit Shorts Video</string>
<string name="shorts_fit_video_description">Will scale the video to fit the view, instead of filling the view properly.</string>
<string name="shorts_fit_video_warning">This setting will require you to reboot Grayjay.</string>
<string name="seek_offset">Seek duration</string>
<string name="min_playback_speed">Minimum Playback Speed</string>
<string name="min_playback_speed_description">Minimum Available Speed</string>
@@ -473,6 +480,7 @@
<string name="number_of_concurrent_threads_to_multiply_download_speeds_from_throttled_sources">Number of concurrent threads to multiply download speeds from throttled sources</string>
<string name="payment">Payment</string>
<string name="payment_status">Payment Status</string>
<string name="relay_server">Sync Relay Server</string>
<string name="bypass_rotation_prevention">Bypass Rotation Prevention</string>
<string name="playlist_delete_confirmation">Playlist Delete Confirmation</string>
<string name="playlist_delete_confirmation_description">Show confirmation dialog when deleting media from a playlist</string>
@@ -645,6 +653,18 @@
<string name="failed_to_parse_text_file">Failed to parse text file</string>
<string name="failed_to_parse_newpipe_subscriptions">Failed to parse NewPipe Subscriptions</string>
<string name="failed_to_generate_qr_code">Failed to generate QR code</string>
<string name="qr_code_too_large_use_file_export">QR code too large. Use the file export button below to share your profile.</string>
<string name="tap_qr_code_for_fullscreen">Tap QR code for fullscreen view</string>
<string name="export_to_file">Export to File</string>
<string name="save_profile_to_file_for_sharing">Save profile to file for sharing</string>
<string name="import_from_file">Import from File</string>
<string name="export_profile">Export Profile</string>
<string name="choose_export_option">Choose export option</string>
<string name="save_to_device">Save to Device</string>
<string name="share_profile">Share Profile</string>
<string name="profile_saved_to_downloads">Profile saved to Downloads</string>
<string name="failed_to_save_profile">Failed to save profile</string>
<string name="authority">com.futo.platformplayer.fileprovider</string>
<string name="share_text">Share Text</string>
<string name="copied_text">Copied Text</string>
<string name="must_be_at_least_3_characters_long">Must be at least 3 characters long.</string>
@@ -1052,6 +1072,8 @@
<item>Chinese (ZH)</item>
<item>Russian (RU)</item>
<item>Arabic (AR)</item>
<item>Italian (IT)</item>
<item>Turkish (TR)</item>
</string-array>
<string-array name="player_background_behavior">
<item>None</item>
@@ -1096,6 +1118,10 @@
<item>ChromeCast</item>
<item>AirPlay</item>
</string-array>
<string-array name="exp_casting_device_type_array" translatable="false">
<item>FCast</item>
<item>ChromeCast</item>
</string-array>
<string-array name="log_levels">
<item>None</item>
<item>Error</item>
+1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="shares" path="shares/"/>
<files-path name="files" path="." />
<external-path name="external_files" path="." />
</paths>
+10
View File
@@ -8,6 +8,16 @@
<receiver android:name=".receivers.InstallReceiver" />
<activity android:name=".activities.MainActivity" android:launchMode="singleInstance">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.OPENABLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
<data android:mimeType="audio/*" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />

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