Merge branch 'master' into pip-improvement

# Conflicts:
#	app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt
This commit is contained in:
Kai
2025-06-13 08:45:09 -05:00
120 changed files with 2825 additions and 405 deletions
@@ -1,6 +1,9 @@
name: Bug Report
description: Let us know about an unexpected error, a crash, or an incorrect behavior.
labels: ["Bug"]
labels: ["Bug", "Android"]
title: "Bug: "
type: bug
projects: ["futo-org/19"]
body:
- type: markdown
attributes:
@@ -18,11 +21,33 @@ body:
* if you've found out a particular series of UI interactions can introduce buggy behavior, please label those steps 1-n with markdown
- type: textarea
id: what-happened
id: reproduction-steps
attributes:
label: What happened?
description: What did you expect to happen?
placeholder: Tell us what you see!
label: Reproduction steps
description: Please provide us with the steps to reproduce the issue if possible. This step makes a big difference if we are going to be able to fix it so be as precise as possible.
placeholder: |
0. Play a Youtube video
1. Press on Download button
2. Select quality 1440p
3. Grayjay crashes when attempting to download
validations:
required: true
- type: textarea
id: actual-result
attributes:
label: Actual result
description: What happend?
placeholder: Tell us what you saw!
validations:
required: true
- type: textarea
id: expected-result
attributes:
label: Expected result
description: What was suppose to happen?
placeholder: Tell us what you expected to happen!
validations:
required: true
@@ -31,7 +56,7 @@ body:
attributes:
label: Grayjay Version
description: In the application, select More > Settings, scroll to the bottom and locate the value next to "Version Name".
placeholder: "242"
placeholder: "311"
validations:
required: true
@@ -42,19 +67,23 @@ body:
multiple: true
options:
- "All"
- "Youtube"
- "Odysee"
- "Rumble"
- "Kick"
- "Twitch"
- "PeerTube"
- "Patreon"
- "Nebula"
- "Apple Podcasts"
- "BiliBili (CN)"
- "Bitchute"
- "SoundCloud"
- "Crunchyroll"
- "CuriosityStream"
- "Dailymotion"
- "Apple Podcasts"
- "Kick"
- "Nebula"
- "Odysee"
- "Patreon"
- "PeerTube"
- "Rumble"
- "SoundCloud"
- "Spotify"
- "TedTalks"
- "Twitch"
- "Youtube"
- "Other"
validations:
required: true
@@ -66,6 +95,30 @@ body:
description: In the application, select Sources > [the broken plugin], write down the value under "Version".
placeholder: "12"
- type: input
id: android-version
attributes:
label: Which android version are you using?
placeholder: "Android 15"
validations:
required: true
- type: input
id: phone-model
attributes:
label: Which device are you using?
placeholder: "Google Pixel 9"
validations:
required: true
- type: input
id: os-version
attributes:
label: Which operating system are you using?
placeholder: "GrapheneOS/CalyxOS/Tizen/HyperOS 2/..."
validations:
required: true
- type: checkboxes
id: login
attributes:
@@ -86,9 +139,28 @@ body:
validations:
required: true
- type: textarea
id: grayjay-references
attributes:
label: References
description: |
Are there any other GitHub issues, whether open or closed, that are related to the problem you've described above? If so, please create a list below that mentions each of them. For example:
```
- #10
```
placeholder:
value:
validations:
required: false
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
render: shell
- type: markdown
attributes:
value: |
**Note:** If the submit button is disabled and you have filled out all required fields, please check that you did not forget a **Title** for the issue.
@@ -1,13 +1,16 @@
name: Feature Request
description: Suggest a new feature or other enhancement.
labels: ["Enhancement"]
labels: ["Enhancement", "Android"]
title: "Feature request: "
type: feature
projects: ["futo-org/19"]
body:
- type: markdown
attributes:
value: |
# Thank you for opening a feature request.
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues and feature requests relating to the Grayjay android application
For discussion related to enhancements, please see: [The FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
@@ -1,13 +1,16 @@
name: Documentation Issue
description: Report an issue or suggest a change in the documentation.
labels: ["Documentation"]
title: "Documentation: "
type: task
projects: ["futo-org/19"]
body:
- type: markdown
attributes:
value: |
# Thank you for opening a documentation change request.
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application. Use the `Documentation` issue type to report problems with the documentation in our code repositories, inside the application, or on [https://grayjay.app](https://grayjay.app)
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay android application. Use the `Documentation` issue type to report problems with the documentation in our code repositories, inside the application, or on [https://grayjay.app](https://grayjay.app)
Technical writers monitor this issue type, so report Grayjay bugs or feature requests with the `Bug report` or `Feature Request` issue types instead to get engineering attention.
For general usage questions, please see: [The Official FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
@@ -11,7 +11,7 @@ import java.nio.ByteBuffer
import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/*
class SyncServerTests {
//private val relayHost = "relay.grayjay.app"
@@ -335,4 +335,4 @@ class SyncServerTests {
class AlwaysAuthorized : IAuthorizable {
override val isAuthorized: Boolean get() = true
}
}*/
@@ -13,7 +13,7 @@ import kotlin.random.Random
import java.io.InputStream
import java.io.OutputStream
import kotlin.time.Duration.Companion.seconds
/*
data class PipeStreams(
val initiatorInput: LittleEndianDataInputStream,
val initiatorOutput: LittleEndianDataOutputStream,
@@ -509,4 +509,4 @@ class Authorized : IAuthorizable {
class Unauthorized : IAuthorizable {
override val isAuthorized: Boolean = false
}
}*/
-1
View File
@@ -15,7 +15,6 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/>
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<application
android:allowBackup="true"
+38 -5
View File
@@ -32,7 +32,8 @@ let Type = {
Text: {
RAW: 0,
HTML: 1,
MARKUP: 2
MARKUP: 2,
CODE: 3
},
Chapter: {
NORMAL: 0,
@@ -291,15 +292,39 @@ class PlatformPostDetails extends PlatformPost {
}
}
class PlatformArticleDetails extends PlatformContent {
class PlatformWeb extends PlatformContent {
constructor(obj) {
super(obj, 7);
obj = obj ?? {};
this.plugin_type = "PlatformWeb";
}
}
class PlatformWebDetails extends PlatformWeb {
constructor(obj) {
super(obj, 7);
obj = obj ?? {};
this.plugin_type = "PlatformWebDetails";
this.html = obj.html;
}
}
class PlatformArticle extends PlatformContent {
constructor(obj) {
super(obj, 3);
obj = obj ?? {};
this.plugin_type = "PlatformArticle";
this.rating = obj.rating ?? new RatingLikes(-1);
this.summary = obj.summary ?? "";
this.thumbnails = obj.thumbnails ?? new Thumbnails([]);
}
}
class PlatformArticleDetails extends PlatformArticle {
constructor(obj) {
super(obj, 3);
obj = obj ?? {};
this.plugin_type = "PlatformArticleDetails";
this.rating = obj.rating ?? new RatingLikes(-1);
this.summary = obj.summary ?? "";
this.segments = obj.segments ?? [];
this.thumbnails = obj.thumbnails ?? new Thumbnails([]);
}
}
class ArticleSegment {
@@ -315,9 +340,17 @@ class ArticleTextSegment extends ArticleSegment {
}
}
class ArticleImagesSegment extends ArticleSegment {
constructor(images) {
constructor(images, caption) {
super(2);
this.images = images;
this.caption = caption;
}
}
class ArticleHeaderSegment extends ArticleSegment {
constructor(content, level) {
super(3);
this.level = level;
this.content = content;
}
}
class ArticleNestedSegment extends ArticleSegment {
@@ -219,9 +219,7 @@ private fun ByteArray.toInetAddress(): InetAddress {
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket? {
ensureNotMainThread()
val timeout = 2000
val timeout = 10000
val addresses = if(!Settings.instance.casting.allowIpv6) attemptAddresses.filterIsInstance<Inet4Address>() else attemptAddresses;
if(addresses.isEmpty())
throw IllegalStateException("No valid addresses found (ipv6: ${(if(Settings.instance.casting.allowIpv6) "enabled" else "disabled")})");
@@ -243,8 +241,11 @@ fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket?
return null;
}
val sortedAddresses: List<InetAddress> = addresses
.sortedBy { addr -> addressScore(addr) }
val sockets: ArrayList<Socket> = arrayListOf();
for (i in addresses.indices) {
for (i in sortedAddresses.indices) {
sockets.add(Socket());
}
@@ -252,7 +253,7 @@ fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket?
var connectedSocket: Socket? = null;
val threads: ArrayList<Thread> = arrayListOf();
for (i in 0 until sockets.size) {
val address = addresses[i];
val address = sortedAddresses[i];
val socket = sockets[i];
val thread = Thread {
try {
@@ -29,6 +29,7 @@ import com.futo.platformplayer.states.StateUpdate
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.FragmentedStorageFileJson
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.fields.AdvancedField
import com.futo.platformplayer.views.fields.DropdownFieldOptionsId
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.FormField
@@ -175,6 +176,10 @@ class Settings : FragmentedStorageFileJson() {
}
}*/
@FormField(R.string.advanced_settings, FieldForm.TOGGLE, R.string.advanced_settings_description, -1, "advancedSettings")
var advancedSettings: Boolean = false;
@FormField(R.string.language, "group", -1, 0)
var language = LanguageSettings();
@Serializable
@@ -221,10 +226,11 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.show_home_filters_plugin_names, FieldForm.TOGGLE, R.string.show_home_filters_plugin_names_description, 5)
var showHomeFiltersPluginNames: Boolean = false;
@AdvancedField
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
var previewFeedItems: Boolean = true;
@AdvancedField
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
var progressBar: Boolean = true;
@@ -253,9 +259,11 @@ class Settings : FragmentedStorageFileJson() {
@DropdownFieldOptionsId(R.array.feed_style)
var searchFeedStyle: Int = 1;
@AdvancedField
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
var previewFeedItems: Boolean = true;
@AdvancedField
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
var progressBar: Boolean = true;
@@ -277,6 +285,7 @@ class Settings : FragmentedStorageFileJson() {
@Serializable
class ChannelSettings {
@AdvancedField
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
var progressBar: Boolean = true;
}
@@ -302,16 +311,20 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.use_subscription_exchange, FieldForm.TOGGLE, R.string.use_subscription_exchange_description, 6)
var useSubscriptionExchange: Boolean = false;
@AdvancedField
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
var previewFeedItems: Boolean = true;
@AdvancedField
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 7)
var progressBar: Boolean = true;
@AdvancedField
@FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 8)
@Serializable(with = FlexibleBooleanSerializer::class)
var fetchOnAppBoot: Boolean = true;
@AdvancedField
@FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 9)
var fetchOnTabOpen: Boolean = true;
@@ -342,13 +355,16 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.show_watch_metrics, FieldForm.TOGGLE, R.string.show_watch_metrics_description, 12)
var showWatchMetrics: Boolean = false;
@AdvancedField
@FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 13)
var allowPlaytimeTracking: Boolean = true;
@AdvancedField
@FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 14)
var alwaysReloadFromCache: Boolean = false;
@AdvancedField
@FormField(R.string.peek_channel_contents, FieldForm.TOGGLE, R.string.peek_channel_contents_description, 15)
var peekChannelContents: Boolean = false;
@@ -425,9 +441,11 @@ class Settings : FragmentedStorageFileJson() {
var preferredPreviewQuality: Int = 5;
fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality);
@AdvancedField
@FormField(R.string.simplify_sources, FieldForm.TOGGLE, R.string.simplify_sources_description, 4)
var simplifySources: Boolean = true;
@AdvancedField
@FormField(R.string.always_allow_reverse_landscape_auto_rotate, FieldForm.TOGGLE, R.string.always_allow_reverse_landscape_auto_rotate_description, 5)
var alwaysAllowReverseLandscapeAutoRotate: Boolean = true
@@ -438,6 +456,7 @@ class Settings : FragmentedStorageFileJson() {
fun isBackgroundContinue() = backgroundPlay == 1;
fun isBackgroundPictureInPicture() = backgroundPlay == 2;
@AdvancedField
@FormField(R.string.resume_after_preview, FieldForm.DROPDOWN, R.string.when_watching_a_video_in_preview_mode_resume_at_the_position_when_opening_the_video_code, 7)
@DropdownFieldOptionsId(R.array.resume_after_preview)
var resumeAfterPreview: Int = 1;
@@ -464,14 +483,10 @@ class Settings : FragmentedStorageFileJson() {
};
}
@AdvancedField
@FormField(R.string.live_chat_webview, FieldForm.TOGGLE, R.string.use_the_live_chat_web_window_when_available_over_native_implementation, 9)
var useLiveChatWindow: Boolean = true;
@FormField(R.string.background_switch_audio, FieldForm.TOGGLE, R.string.background_switch_audio_description, 10)
var backgroundSwitchToAudio: Boolean = true;
@FormField(R.string.restart_after_audio_focus_loss, FieldForm.DROPDOWN, R.string.restart_playback_when_gaining_audio_focus_after_a_loss, 11)
@DropdownFieldOptionsId(R.array.restart_playback_after_loss)
var restartPlaybackAfterLoss: Int = 1;
@@ -497,6 +512,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.autoplay, FieldForm.TOGGLE, R.string.autoplay, 21)
var autoplay: Boolean = false;
@AdvancedField
@FormField(R.string.delete_watchlist_on_finish, FieldForm.TOGGLE, R.string.delete_watchlist_on_finish_description, 22)
var deleteFromWatchLaterAuto: Boolean = true;
@@ -515,6 +531,77 @@ class Settings : FragmentedStorageFileJson() {
else -> 10_000L;
}
}
@FormField(R.string.min_playback_speed, FieldForm.DROPDOWN, R.string.min_playback_speed_description, 25)
@DropdownFieldOptionsId(R.array.min_playback_speed)
var minimumPlaybackSpeed: Int = 0;
@FormField(R.string.max_playback_speed, FieldForm.DROPDOWN, R.string.max_playback_speed_description, 26)
@DropdownFieldOptionsId(R.array.max_playback_speed)
var maximumPlaybackSpeed: Int = 2;
@FormField(R.string.step_playback_speed, FieldForm.DROPDOWN, R.string.step_playback_speed_description, 26)
@DropdownFieldOptionsId(R.array.step_playback_speed)
var stepPlaybackSpeed: Int = 1;
fun getPlaybackSpeedStep(): Double {
return when(stepPlaybackSpeed) {
0 -> 0.05
1 -> 0.1
2 -> 0.25
else -> 0.1;
}
}
fun getPlaybackSpeeds(): List<Double> {
val playbackSpeeds = mutableListOf<Double>();
playbackSpeeds.add(1.0);
val minSpeed = when(minimumPlaybackSpeed) {
0 -> 0.25
1 -> 0.5
2 -> 1.0
else -> 0.25
}
val maxSpeed = when(maximumPlaybackSpeed) {
0 -> 2.0
1 -> 2.25
2 -> 3.0
3 -> 4.0
4 -> 5.0
else -> 2.25;
}
var testSpeed = 1.0;
while(testSpeed > minSpeed) {
val nextSpeed = (testSpeed - 0.25) as Double;
testSpeed = Math.max(nextSpeed, minSpeed);
playbackSpeeds.add(testSpeed);
}
testSpeed = 1.0;
while(testSpeed < maxSpeed) {
val nextSpeed = (testSpeed + if(testSpeed < 2) 0.25 else 1.0) as Double;
testSpeed = Math.min(nextSpeed, maxSpeed);
playbackSpeeds.add(testSpeed);
}
playbackSpeeds.sort();
return playbackSpeeds;
}
@FormField(R.string.hold_playback_speed, FieldForm.DROPDOWN, R.string.hold_playback_speed_description, 27)
@DropdownFieldOptionsId(R.array.hold_playback_speeds)
var holdPlaybackSpeed: Int = 3;
fun getHoldPlaybackSpeed(): Double {
return when(holdPlaybackSpeed) {
0 -> 1.25
1 -> 1.5
2 -> 1.75
3 -> 2.0
4 -> 2.25
5 -> 2.5
6 -> 2.75
7 -> 3.0
else -> 2.0
}
}
}
@FormField(R.string.comments, "group", R.string.comments_description, 6)
@@ -530,6 +617,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.default_recommendations, FieldForm.TOGGLE, R.string.default_recommendations_description, 0)
var recommendationsDefault: Boolean = false;
@AdvancedField
@FormField(R.string.hide_recommendations, FieldForm.TOGGLE, R.string.hide_recommendations_description, 0)
var hideRecommendations: Boolean = false;
@@ -566,10 +654,12 @@ class Settings : FragmentedStorageFileJson() {
var preferredAudioQuality: Int = 1;
fun isHighBitrateDefault(): Boolean = preferredAudioQuality > 0;
@AdvancedField
@FormField(R.string.byte_range_download, FieldForm.TOGGLE, R.string.attempt_to_utilize_byte_ranges, 4)
@Serializable(with = FlexibleBooleanSerializer::class)
var byteRangeDownload: Boolean = true;
@AdvancedField
@FormField(R.string.byte_range_concurrency, FieldForm.DROPDOWN, R.string.number_of_concurrent_threads_to_multiply_download_speeds_from_throttled_sources, 5)
@DropdownFieldOptionsId(R.array.thread_count)
var byteRangeConcurrency: Int = 3;
@@ -599,15 +689,21 @@ class Settings : FragmentedStorageFileJson() {
@Serializable(with = FlexibleBooleanSerializer::class)
var keepScreenOn: Boolean = true;
@AdvancedField
@FormField(R.string.always_proxy_requests, FieldForm.TOGGLE, R.string.always_proxy_requests_description, 3)
@Serializable(with = FlexibleBooleanSerializer::class)
var alwaysProxyRequests: Boolean = false;
@AdvancedField
@FormField(R.string.allow_ipv6, FieldForm.TOGGLE, R.string.allow_ipv6_description, 4)
@Serializable(with = FlexibleBooleanSerializer::class)
var allowIpv6: Boolean = true;
@AdvancedField
@FormField(R.string.allow_ipv4, FieldForm.TOGGLE, R.string.allow_ipv4_description, 5)
@Serializable(with = FlexibleBooleanSerializer::class)
var allowLinkLocalIpv4: Boolean = false;
/*TODO: Should we have a different casting quality?
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
@DropdownFieldOptionsId(R.array.preferred_quality_array)
@@ -675,9 +771,11 @@ class Settings : FragmentedStorageFileJson() {
@Serializable
class Plugins {
@AdvancedField
@FormField(R.string.check_disabled_plugin_updates, FieldForm.TOGGLE, R.string.check_disabled_plugin_updates_description, -1)
var checkDisabledPluginsForUpdates: Boolean = false;
@AdvancedField
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
var clearCookiesOnLogout: Boolean = true;
@@ -878,7 +976,23 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.payment_status, FieldForm.READONLYTEXT, -1, 1)
val paymentStatus: String get() = SettingsActivity.getActivity()?.let { if (StatePayment.instance.hasPaid) it.getString(R.string.paid) else it.getString(R.string.not_paid); } ?: "Unknown";
@FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 2)
@FormField(R.string.license_status, FieldForm.BUTTON, R.string.view_license_status, 2)
fun viewLicenseStatus() {
SettingsActivity.getActivity()?.let {
try {
if (StatePayment.instance.hasPaid) {
val paymentKey = StatePayment.instance.getPaymentKey()
UIDialogs.showDialogOk(it, R.drawable.ic_paid, "License activated\n" + paymentKey.first)
} else {
UIDialogs.showDialogOk(it, R.drawable.ic_paid, "No license activated")
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to show license status dialog", e)
}
}
}
@FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 3)
fun clearPayment() {
SettingsActivity.getActivity()?.let { context ->
UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete your license?", {
@@ -896,8 +1010,10 @@ class Settings : FragmentedStorageFileJson() {
var other = Other();
@Serializable
class Other {
@AdvancedField
@FormField(R.string.playlist_delete_confirmation, FieldForm.TOGGLE, R.string.playlist_delete_confirmation_description, 2)
var playlistDeleteConfirmation: Boolean = true;
@AdvancedField
@FormField(R.string.playlist_allow_dups, FieldForm.TOGGLE, R.string.playlist_allow_dups_description, 3)
var playlistAllowDups: Boolean = true;
@@ -319,7 +319,11 @@ class UIDialogs {
closeAction?.invoke()
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action(context.getString(R.string.retry), {
retryAction?.invoke();
try {
retryAction?.invoke();
} catch (e: Throwable) {
Logger.e(TAG, "Unhandled exception retrying", e)
}
}, UIDialogs.ActionStyle.PRIMARY)
);
else
@@ -333,7 +337,11 @@ class UIDialogs {
closeAction?.invoke()
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action(context.getString(R.string.retry), {
retryAction?.invoke();
try {
retryAction?.invoke();
} catch (e: Throwable) {
Logger.e(TAG, "Unhandled exception retrying", e)
}
}, UIDialogs.ActionStyle.PRIMARY)
);
}
@@ -339,6 +339,33 @@ fun ByteArray.fromGzip(): ByteArray {
return outputStream.toByteArray()
}
fun findCandidateAddresses(): List<InetAddress> {
val candidates = NetworkInterface.getNetworkInterfaces()
.toList()
.asSequence()
.filter(::isUsableInterface)
.flatMap { nif ->
nif.interfaceAddresses
.asSequence()
.mapNotNull { ia ->
ia.address.takeIf(::isUsableAddress)?.let { addr ->
nif to ia
}
}
}
.toList()
return candidates
.sortedWith(
compareBy<Pair<NetworkInterface, InterfaceAddress>>(
{ addressScore(it.second.address) },
{ interfaceScore(it.first) },
{ -it.second.networkPrefixLength.toInt() },
{ -it.first.mtu }
)
).map { it.second.address }
}
fun findPreferredAddress(): InetAddress? {
val candidates = NetworkInterface.getNetworkInterfaces()
.toList()
@@ -407,7 +434,7 @@ private fun interfaceScore(nif: NetworkInterface): Int {
}
}
private fun addressScore(addr: InetAddress): Int {
fun addressScore(addr: InetAddress): Int {
return when (addr) {
is Inet4Address -> {
val octets = addr.address.map { it.toInt() and 0xFF }
@@ -25,7 +25,6 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.OptIn
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
@@ -45,6 +44,7 @@ import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.dp
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
import com.futo.platformplayer.fragment.mainactivity.main.ArticleDetailFragment
import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment
import com.futo.platformplayer.fragment.mainactivity.main.BuyFragment
import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
@@ -73,6 +73,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.TutorialFragment
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment.State
import com.futo.platformplayer.fragment.mainactivity.main.WatchLaterFragment
import com.futo.platformplayer.fragment.mainactivity.main.WebDetailFragment
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment
import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment
@@ -114,6 +115,7 @@ import java.io.StringWriter
import java.lang.reflect.InvocationTargetException
import java.util.LinkedList
import java.util.Queue
import java.util.UUID
import java.util.concurrent.ConcurrentLinkedQueue
@@ -152,6 +154,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
//Frags Main
lateinit var _fragMainHome: HomeFragment;
lateinit var _fragPostDetail: PostDetailFragment;
lateinit var _fragArticleDetail: ArticleDetailFragment;
lateinit var _fragWebDetail: WebDetailFragment;
lateinit var _fragMainVideoSearchResults: ContentSearchResultsFragment;
lateinit var _fragMainCreatorSearchResults: CreatorSearchResultsFragment;
lateinit var _fragMainPlaylistSearchResults: PlaylistSearchResultsFragment;
@@ -181,7 +185,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragVideoDetail: VideoDetailFragment;
//State
private val _queue: Queue<Pair<MainFragment, Any?>> = LinkedList();
private val _queue: LinkedList<Pair<MainFragment, Any?>> = LinkedList();
lateinit var fragCurrent: MainFragment private set;
private var _parameterCurrent: Any? = null;
@@ -215,6 +219,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
val mainId = UUID.randomUUID().toString().substring(0, 5)
constructor() : super() {
if (BuildConfig.DEBUG) {
StrictMode.setVmPolicy(
@@ -266,8 +272,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
@UnstableApi
override fun onCreate(savedInstanceState: Bundle?) {
Logger.i(TAG, "MainActivity Starting");
StateApp.instance.setGlobalContext(this, lifecycleScope);
Logger.w(TAG, "MainActivity Starting [$mainId]");
StateApp.instance.setGlobalContext(this, lifecycleScope, mainId);
StateApp.instance.mainAppStarting(this);
super.onCreate(savedInstanceState);
@@ -330,6 +336,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragMainPlaylist = PlaylistFragment.newInstance();
_fragMainRemotePlaylist = RemotePlaylistFragment.newInstance();
_fragPostDetail = PostDetailFragment.newInstance();
_fragArticleDetail = ArticleDetailFragment.newInstance();
_fragWebDetail = WebDetailFragment.newInstance();
_fragWatchlist = WatchLaterFragment.newInstance();
_fragHistory = HistoryFragment.newInstance();
_fragSourceDetail = SourceDetailFragment.newInstance();
@@ -456,6 +464,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragMainPlaylist.topBar = _fragTopBarNavigation;
_fragMainRemotePlaylist.topBar = _fragTopBarNavigation;
_fragPostDetail.topBar = _fragTopBarNavigation;
_fragArticleDetail.topBar = _fragTopBarNavigation;
_fragWebDetail.topBar = _fragTopBarNavigation;
_fragWatchlist.topBar = _fragTopBarNavigation;
_fragHistory.topBar = _fragTopBarNavigation;
_fragSourceDetail.topBar = _fragTopBarNavigation;
@@ -664,13 +674,13 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
override fun onResume() {
super.onResume();
Logger.v(TAG, "onResume")
Logger.w(TAG, "onResume [$mainId]")
_isVisible = true;
}
override fun onPause() {
super.onPause();
Logger.v(TAG, "onPause")
Logger.w(TAG, "onPause [$mainId]")
_isVisible = false;
_qrCodeLoadingDialog?.dismiss()
@@ -679,7 +689,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
override fun onStop() {
super.onStop()
Logger.v(TAG, "_wasStopped = true");
Logger.w(TAG, "onStop [$mainId]");
_wasStopped = true;
}
@@ -855,7 +865,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
return withContext(Dispatchers.IO) {
Logger.i(TAG, "handleUrl(url=$url) on IO");
if (StatePlatform.instance.hasEnabledVideoClient(url)) {
if (StatePlatform.instance.hasEnabledContentClient(url)) {
Logger.i(TAG, "handleUrl(url=$url) found video client");
withContext(Dispatchers.Main) {
if (position > 0)
@@ -1096,8 +1106,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
override fun onDestroy() {
super.onDestroy();
Logger.v(TAG, "onDestroy")
StateApp.instance.mainAppDestroyed(this);
Logger.w(TAG, "onDestroy [$mainId]")
StateApp.instance.mainAppDestroyed(this, mainId);
}
inline fun <reified T> isFragmentActive(): Boolean {
@@ -1177,7 +1187,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if (segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory)
fragBeforeOverlay = fragCurrent;
fragCurrent = segment;
_parameterCurrent = parameter;
}
@@ -1240,6 +1249,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
PlaylistFragment::class -> _fragMainPlaylist as T;
RemotePlaylistFragment::class -> _fragMainRemotePlaylist as T;
PostDetailFragment::class -> _fragPostDetail as T;
ArticleDetailFragment::class -> _fragArticleDetail as T;
WebDetailFragment::class -> _fragWebDetail as T;
WatchLaterFragment::class -> _fragWatchlist as T;
HistoryFragment::class -> _fragHistory as T;
SourceDetailFragment::class -> _fragSourceDetail as T;
@@ -0,0 +1,9 @@
package com.futo.platformplayer.api.media.models.article
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
interface IPlatformArticle: IPlatformContent {
val summary: String?;
val thumbnails: Thumbnails?;
}
@@ -0,0 +1,12 @@
package com.futo.platformplayer.api.media.models.article
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.platforms.js.models.IJSArticleSegment
interface IPlatformArticleDetails: IPlatformContent, IPlatformArticle, IPlatformContentDetails {
val segments: List<IJSArticleSegment>;
val rating : IRating;
}
@@ -8,6 +8,7 @@ enum class ContentType(val value: Int) {
POST(2),
ARTICLE(3),
PLAYLIST(4),
WEB(7),
URL(9),
@@ -5,7 +5,8 @@ import com.futo.platformplayer.api.media.exceptions.UnknownPlatformException
enum class TextType(val value: Int) {
RAW(0),
HTML(1),
MARKUP(2);
MARKUP(2),
CODE(3);
companion object {
fun fromInt(value: Int): TextType
@@ -27,7 +27,9 @@ interface IJSContent: IPlatformContent {
ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj);
ContentType.PLAYLIST -> JSPlaylist(config, obj);
ContentType.LOCKED -> JSLockedContent(config, obj);
ContentType.CHANNEL -> JSChannelContent(config, obj)
ContentType.CHANNEL -> JSChannelContent(config, obj);
ContentType.ARTICLE -> JSArticle(config, obj);
ContentType.WEB -> JSWeb(config, obj);
else -> throw NotImplementedError("Unknown content type ${type}");
}
}
@@ -17,6 +17,7 @@ interface IJSContentDetails: IPlatformContent {
ContentType.MEDIA -> JSVideoDetails(plugin, obj);
ContentType.POST -> JSPostDetails(plugin.config, obj);
ContentType.ARTICLE -> JSArticleDetails(plugin, obj);
ContentType.WEB -> JSWebDetails(plugin, obj);
else -> throw NotImplementedError("Unknown content type ${type}");
}
}
@@ -0,0 +1,39 @@
package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
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.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.post.TextType
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.states.StateDeveloper
open class JSArticle : JSContent, IPlatformArticle, IPluginSourced {
final override val contentType: ContentType get() = ContentType.ARTICLE;
override val summary: String;
override val thumbnails: Thumbnails?;
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
val contextName = "PlatformArticle";
summary = _content.getOrDefault(config, "summary", contextName, "") ?: "";
thumbnails = Thumbnails.fromV8(config, _content.getOrThrow(config, "thumbnails", contextName));
}
}
@@ -4,6 +4,8 @@ import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails
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
@@ -21,20 +23,20 @@ import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.states.StateDeveloper
open class JSArticleDetails : JSContent, IPluginSourced, IPlatformContentDetails {
open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails {
final override val contentType: ContentType get() = ContentType.ARTICLE;
private val _hasGetComments: Boolean;
private val _hasGetContentRecommendations: Boolean;
val rating: IRating;
override val rating: IRating;
val summary: String;
val thumbnails: Thumbnails?;
val segments: List<IJSArticleSegment>;
override val summary: String;
override val thumbnails: Thumbnails?;
override val segments: List<IJSArticleSegment>;
constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) {
val contextName = "PlatformPost";
val contextName = "PlatformArticle";
rating = obj.getOrDefault<V8ValueObject>(client.config, "rating", contextName, null)?.let { IRating.fromV8(client.config, it, contextName) } ?: RatingLikes(0);
summary = _content.getOrThrow(client.config, "summary", contextName);
@@ -99,6 +101,7 @@ open class JSArticleDetails : JSContent, IPluginSourced, IPlatformContentDetails
return when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) {
SegmentType.TEXT -> JSTextSegment(client, obj);
SegmentType.IMAGES -> JSImagesSegment(client, obj);
SegmentType.HEADER -> JSHeaderSegment(client, obj);
SegmentType.NESTED -> JSNestedSegment(client, obj);
else -> null;
}
@@ -110,6 +113,7 @@ enum class SegmentType(val value: Int) {
UNKNOWN(0),
TEXT(1),
IMAGES(2),
HEADER(3),
NESTED(9);
@@ -150,6 +154,17 @@ class JSImagesSegment: IJSArticleSegment {
caption = obj.getOrDefault(client.config, "caption", contextName, "") ?: "";
}
}
class JSHeaderSegment: IJSArticleSegment {
override val type = SegmentType.HEADER;
val content: String;
val level: Int;
constructor(client: JSClient, obj: V8ValueObject) {
val contextName = "JSHeaderSegment";
content = obj.getOrDefault(client.config, "content", contextName, "") ?: "";
level = obj.getOrDefault(client.config, "level", contextName, 1) ?: 1;
}
}
class JSNestedSegment: IJSArticleSegment {
override val type = SegmentType.NESTED;
val nested: IPlatformContent;
@@ -0,0 +1,31 @@
package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.IPluginSourced
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.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.post.TextType
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.states.StateDeveloper
open class JSWeb : JSContent, IPluginSourced {
final override val contentType: ContentType get() = ContentType.WEB;
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
val contextName = "PlatformWeb";
}
}
@@ -0,0 +1,41 @@
package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.IPluginSourced
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.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.post.TextType
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.states.StateDeveloper
open class JSWebDetails : JSContent, IPluginSourced, IPlatformContentDetails {
final override val contentType: ContentType get() = ContentType.WEB;
val html: String?;
//TODO: Options?
constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) {
val contextName = "PlatformWeb";
html = obj.getOrDefault(client.config, "html", contextName, null);
}
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? = null;
override fun getPlaybackTracker(): IPlaybackTracker? = null;
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? = null;
}
@@ -108,7 +108,7 @@ abstract class CastingDevice {
val expectedCurrentTime: Double
get() {
val diff = (System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0;
val diff = if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
return time + diff;
};
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
@@ -35,7 +35,7 @@ class ChromecastCastingDevice : CastingDevice {
override var usedRemoteAddress: InetAddress? = null;
override var localAddress: InetAddress? = null;
override val canSetVolume: Boolean get() = true;
override val canSetSpeed: Boolean get() = false; //TODO: Implement
override val canSetSpeed: Boolean get() = true;
var addresses: Array<InetAddress>? = null;
var port: Int = 0;
@@ -144,6 +144,23 @@ class ChromecastCastingDevice : CastingDevice {
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", json);
}
override fun changeSpeed(speed: Double) {
if (invokeInIOScopeIfRequired { changeSpeed(speed) }) return
val speedClamped = speed.coerceAtLeast(1.0).coerceAtLeast(1.0).coerceAtMost(2.0)
setSpeed(speedClamped)
val mediaSessionId = _mediaSessionId ?: return
val transportId = _transportId ?: return
val setSpeedObject = JSONObject().apply {
put("type", "SET_PLAYBACK_RATE")
put("mediaSessionId", mediaSessionId)
put("playbackRate", speedClamped)
put("requestId", _requestId++)
}
sendChannelMessage(sourceId = "sender-0", destinationId = transportId, namespace = "urn:x-cast:com.google.cast.media", json = setSpeedObject.toString())
}
override fun changeVolume(volume: Double) {
if (invokeInIOScopeIfRequired({ changeVolume(volume) })) {
return;
@@ -344,6 +361,10 @@ class ChromecastCastingDevice : CastingDevice {
//Connection loop
while (_scopeIO?.isActive == true) {
_sessionId = null;
_launchRetries = 0
_mediaSessionId = null;
Logger.i(TAG, "Connecting to Chromecast.");
connectionState = CastConnectionState.CONNECTING;
@@ -499,6 +520,10 @@ class ChromecastCastingDevice : CastingDevice {
}
} catch (e: Throwable) {
Logger.w(TAG, "Failed to send channel message (sourceId: $sourceId, destinationId: $destinationId, namespace: $namespace, json: $json)", e);
_socket?.close();
Logger.i(TAG, "Socket disconnected.");
connectionState = CastConnectionState.CONNECTING;
}
}
@@ -600,7 +625,7 @@ class ChromecastCastingDevice : CastingDevice {
}
isPlaying = playerState == "PLAYING";
if (isPlaying) {
if (isPlaying || playerState == "PAUSED") {
setTime(currentTime);
}
@@ -166,10 +166,11 @@ class StateCasting {
Logger.i(TAG, "CastingService started.");
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
startDiscovering()
}
@Synchronized
fun startDiscovering() {
private fun startDiscovering() {
_nsdManager?.apply {
_discoveryListeners.forEach {
discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
@@ -178,7 +179,7 @@ class StateCasting {
}
@Synchronized
fun stopDiscovering() {
private fun stopDiscovering() {
_nsdManager?.apply {
_discoveryListeners.forEach {
try {
@@ -1220,9 +1221,16 @@ class StateCasting {
private fun getLocalUrl(ad: CastingDevice): String {
var address = ad.localAddress!!
if (address is Inet6Address && address.isLinkLocalAddress) {
address = findPreferredAddress() ?: address
Logger.i(TAG, "Selected casting address: $address")
if (Settings.instance.casting.allowLinkLocalIpv4) {
if (address.isLinkLocalAddress && address is Inet6Address) {
address = findPreferredAddress() ?: address
Logger.i(TAG, "Selected casting address: $address")
}
} else {
if (address.isLinkLocalAddress) {
address = findPreferredAddress() ?: address
Logger.i(TAG, "Selected casting address: $address")
}
}
return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}";
}
@@ -82,7 +82,11 @@ class TaskHandler<TParameter, TResult> {
handled = true;
} catch (e: Throwable) {
Logger.w(TAG, "Handled exception in TaskHandler onSuccess.", e);
onError.emit(e, parameter);
try {
onError.emit(e, parameter);
} catch (e: Throwable) {
Logger.e(TAG, "Unhandled exception in .exception handler 1", e)
}
handled = true;
}
}
@@ -99,10 +103,14 @@ class TaskHandler<TParameter, TResult> {
if (id != _idGenerator)
return@withContext;
if (!onError.emit(e, parameter)) {
Logger.e(TAG, "Uncaught exception handled by TaskHandler.", e);
} else {
//Logger.w(TAG, "Handled exception in TaskHandler invoke.", e); (Prevents duplicate logs)
try {
if (!onError.emit(e, parameter)) {
Logger.e(TAG, "Uncaught exception handled by TaskHandler.", e);
} else {
//Logger.w(TAG, "Handled exception in TaskHandler invoke.", e); (Prevents duplicate logs)
}
} catch (e: Throwable) {
Logger.e(TAG, "Unhandled exception in .exception handler 2", e)
}
}
}
@@ -7,9 +7,7 @@ import android.app.PendingIntent.getBroadcast
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.content.pm.PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED
import android.graphics.drawable.Animatable
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -157,9 +155,6 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller;
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
params.setRequireUserAction(USER_ACTION_NOT_REQUIRED)
}
val sessionId = packageInstaller.createSession(params);
session = packageInstaller.openSession(sessionId)
@@ -103,7 +103,6 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
super.show();
Logger.i(TAG, "Dialog shown.");
StateCasting.instance.startDiscovering()
(_imageLoader.drawable as Animatable?)?.start();
synchronized(StateCasting.instance.devices) {
@@ -148,7 +147,6 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
override fun dismiss() {
super.dismiss()
(_imageLoader.drawable as Animatable?)?.stop()
StateCasting.instance.stopDiscovering()
StateCasting.instance.onDeviceAdded.remove(this)
StateCasting.instance.onDeviceChanged.remove(this)
StateCasting.instance.onDeviceRemoved.remove(this)
@@ -724,7 +724,7 @@ class VideoDownload {
val t = cue.groupValues[1];
val d = cue.groupValues[2];
val url = foundTemplateUrl.replace("\$Number\$", indexCounter.toString());
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
val data = if(executor != null)
executor.executeRequest("GET", url, null, mapOf());
@@ -188,6 +188,14 @@ class V8Plugin {
whenNotBusy {
synchronized(_runtimeLock) {
isStopped = true;
//Cleanup http
for(pack in _depsPackages) {
if(pack is PackageHttp) {
pack.cleanup();
}
}
_runtime?.let {
_runtime = null;
if(!it.isClosed && !it.isDead) {
@@ -12,6 +12,7 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateDeveloper
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.JSClientConstants
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
@@ -77,6 +78,22 @@ class PackageBridge : V8Package {
return "android";
}
@V8Property
fun supportedContent(): Array<Int> {
return arrayOf(
ContentType.MEDIA.value,
ContentType.POST.value,
ContentType.PLAYLIST.value,
ContentType.WEB.value,
ContentType.URL.value,
ContentType.NESTED_VIDEO.value,
ContentType.CHANNEL.value,
ContentType.LOCKED.value,
ContentType.PLACEHOLDER.value,
ContentType.DEFERRED.value
)
}
@V8Function
fun dispose(value: V8Value) {
Logger.e(TAG, "Manual dispose: " + value.javaClass.name);
@@ -8,9 +8,7 @@ import com.caoccao.javet.enums.V8ProxyMode
import com.caoccao.javet.interop.V8Runtime
import com.caoccao.javet.values.V8Value
import com.caoccao.javet.values.primitive.V8ValueString
import com.caoccao.javet.values.reference.V8ValueArrayBuffer
import com.caoccao.javet.values.reference.V8ValueObject
import com.caoccao.javet.values.reference.V8ValueSharedArrayBuffer
import com.caoccao.javet.values.reference.V8ValueTypedArray
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
@@ -20,15 +18,9 @@ import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.engine.internal.IV8Convertable
import com.futo.platformplayer.engine.internal.V8BindObject
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.net.SocketTimeoutException
import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask
import kotlin.concurrent.thread
import kotlin.streams.asSequence
class PackageHttp: V8Package {
@Transient
@@ -49,6 +41,9 @@ class PackageHttp: V8Package {
private var _batchPoolLock: Any = Any();
private var _batchPool: ForkJoinPool? = null;
private val aliveSockets = mutableListOf<SocketResult>();
private var _cleanedUp = false;
constructor(plugin: V8Plugin, config: IV8PluginConfig): super(plugin) {
_config = config;
@@ -58,6 +53,27 @@ class PackageHttp: V8Package {
_packageClientAuth = PackageHttpClient(this, _clientAuth);
}
fun cleanup(){
Logger.w(TAG, "PackageHttp Cleaning up")
val sockets = synchronized(aliveSockets) { aliveSockets.toList() }
_cleanedUp = true;
for(socket in sockets){
try {
Logger.w(TAG, "PackageHttp Socket Cleaned Up");
socket.close(1001, "Cleanup");
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to close socket", ex);
}
}
if(sockets.size > 0) {
//Thread.sleep(100); //Give sockets a bit
}
synchronized(aliveSockets) {
aliveSockets.clear();
}
}
/*
Automatically adjusting threadpool dedicated per PackageHttp for batch requests.
@@ -111,24 +127,24 @@ class PackageHttp: V8Package {
@V8Function
fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse {
return if(useAuth)
_packageClientAuth.request(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING)
_packageClientAuth.requestInternal(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING)
else
_packageClient.request(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING);
_packageClient.requestInternal(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING);
}
@V8Function
fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse {
return if(useAuth)
_packageClientAuth.requestWithBody(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING)
_packageClientAuth.requestWithBodyInternal(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING)
else
_packageClient.requestWithBody(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING);
_packageClient.requestWithBodyInternal(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING);
}
@V8Function
fun GET(url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse {
return if(useAuth)
_packageClientAuth.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING)
_packageClientAuth.GETInternal(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING)
else
_packageClient.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
_packageClient.GETInternal(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
}
@V8Function
fun POST(url: String, body: Any, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse {
@@ -136,15 +152,15 @@ class PackageHttp: V8Package {
val client = if(useAuth) _packageClientAuth else _packageClient;
if(body is V8ValueString)
return client.POST(url, body.value, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
return client.POSTInternal(url, body.value, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
else if(body is String)
return client.POST(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
return client.POSTInternal(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
else if(body is V8ValueTypedArray)
return client.POST(url, body.toBytes(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
return client.POSTInternal(url, body.toBytes(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
else if(body is ByteArray)
return client.POST(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
return client.POSTInternal(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
else if(body is ArrayList<*>) //Avoid this case, used purely for testing
return client.POST(url, body.map { (it as Double).toInt().toByte() }.toByteArray(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
return client.POSTInternal(url, body.map { (it as Double).toInt().toByte() }.toByteArray(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
else
throw NotImplementedError("Body type " + body?.javaClass?.name?.toString() + " not implemented for POST");
}
@@ -276,9 +292,9 @@ class PackageHttp: V8Package {
if(it.second.method == "DUMMY")
return@autoParallelPool null;
if(it.second.body != null)
return@autoParallelPool it.first.requestWithBody(it.second.method, it.second.url, it.second.body!!, it.second.headers, it.second.respType);
return@autoParallelPool it.first.requestWithBodyInternal(it.second.method, it.second.url, it.second.body!!, it.second.headers, it.second.respType);
else
return@autoParallelPool it.first.request(it.second.method, it.second.url, it.second.headers, it.second.respType);
return@autoParallelPool it.first.requestInternal(it.second.method, it.second.url, it.second.headers, it.second.respType);
}.map {
if(it.second != null)
throw it.second!!;
@@ -345,7 +361,9 @@ class PackageHttp: V8Package {
}
@V8Function
fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse {
fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
= requestInternal(method, url, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING);
fun requestInternal(method: String, url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse {
applyDefaultHeaders(headers);
return logExceptions {
return@logExceptions catchHttp {
@@ -364,7 +382,9 @@ class PackageHttp: V8Package {
};
}
@V8Function
fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse {
fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
= requestWithBodyInternal(method, url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING)
fun requestWithBodyInternal(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse {
applyDefaultHeaders(headers);
return logExceptions {
catchHttp {
@@ -385,7 +405,9 @@ class PackageHttp: V8Package {
}
@V8Function
fun GET(url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
fun GET(url: String, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
= GETInternal(url, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING)
fun GETInternal(url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
applyDefaultHeaders(headers);
return logExceptions {
catchHttp {
@@ -407,7 +429,9 @@ class PackageHttp: V8Package {
};
}
@V8Function
fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
= POSTInternal(url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING)
fun POSTInternal(url: String, body: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
applyDefaultHeaders(headers);
return logExceptions {
catchHttp {
@@ -429,7 +453,9 @@ class PackageHttp: V8Package {
};
}
@V8Function
fun POST(url: String, body: ByteArray, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
fun POST(url: String, body: ByteArray, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
= POSTInternal(url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING)
fun POSTInternal(url: String, body: ByteArray, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
applyDefaultHeaders(headers);
return logExceptions {
catchHttp {
@@ -453,9 +479,16 @@ class PackageHttp: V8Package {
@V8Function
fun socket(url: String, headers: Map<String, String>? = null): SocketResult {
if(_package._cleanedUp)
throw IllegalStateException("Plugin shutdown");
val socketHeaders = headers?.toMutableMap() ?: HashMap();
applyDefaultHeaders(socketHeaders);
return SocketResult(this, _client, url, socketHeaders);
val socket = SocketResult(_package, this, _client, url, socketHeaders);
Logger.w(TAG, "PackageHttp Socket opened");
synchronized(_package.aliveSockets) {
_package.aliveSockets.add(socket);
}
return socket;
}
private fun applyDefaultHeaders(headerMap: MutableMap<String, String>) {
@@ -561,13 +594,15 @@ class PackageHttp: V8Package {
private var _listeners: V8ValueObject? = null;
private val _package: PackageHttp;
private val _packageClient: PackageHttpClient;
private val _client: ManagedHttpClient;
private val _url: String;
private val _headers: Map<String, String>;
constructor(pack: PackageHttpClient, client: ManagedHttpClient, url: String, headers: Map<String,String>) {
constructor(parent: PackageHttp, pack: PackageHttpClient, client: ManagedHttpClient, url: String, headers: Map<String,String>) {
_packageClient = pack;
_package = parent;
_client = client;
_url = url;
_headers = headers;
@@ -593,7 +628,7 @@ class PackageHttp: V8Package {
override fun open() {
Logger.i(TAG, "Websocket opened: " + _url);
_isOpen = true;
if(hasOpen) {
if(hasOpen && _listeners?.isClosed != true) {
try {
_listeners?.invokeVoid("open", arrayOf<Any>());
}
@@ -603,7 +638,7 @@ class PackageHttp: V8Package {
}
}
override fun message(msg: String) {
if(hasMessage) {
if(hasMessage && _listeners?.isClosed != true) {
try {
_listeners?.invokeVoid("message", msg);
}
@@ -611,7 +646,7 @@ class PackageHttp: V8Package {
}
}
override fun closing(code: Int, reason: String) {
if(hasClosing)
if(hasClosing && _listeners?.isClosed != true)
{
try {
_listeners?.invokeVoid("closing", code, reason);
@@ -623,7 +658,7 @@ class PackageHttp: V8Package {
}
override fun closed(code: Int, reason: String) {
_isOpen = false;
if(hasClosed) {
if(hasClosed && _listeners?.isClosed != true) {
try {
_listeners?.invokeVoid("closed", code, reason);
}
@@ -631,11 +666,15 @@ class PackageHttp: V8Package {
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex);
}
}
Logger.w(TAG, "PackageHttp Socket removed");
synchronized(_package.aliveSockets) {
_package.aliveSockets.remove(this@SocketResult);
}
}
override fun failure(exception: Throwable) {
_isOpen = false;
Logger.e(TAG, "Websocket failure: ${exception.message} (${_url})", exception);
if(hasFailure) {
if(hasFailure && _listeners?.isClosed != true) {
try {
_listeners?.invokeVoid("failure", exception.message);
}
@@ -23,7 +23,7 @@ open class MainActivityFragment : Fragment() {
fun navigate(frag: MainFragment, parameter: Any? = null, withHistory: Boolean = true) {
val a = activity
if (a is MainActivity)
(activity as MainActivity).navigate(frag, parameter, withHistory)
(activity as MainActivity).navigate(frag, parameter, withHistory, false)
else
Log.e(TAG, "Failed to navigate due to activity not being a main activity.")
}
@@ -330,7 +330,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
}
if (!StatePayment.instance.hasPaid) {
newCurrentButtonDefinitions.add(ButtonDefinition(98, R.drawable.ic_paid, R.drawable.ic_paid_filled, R.string.buy, canToggle = false, { it.currentMain is BuyFragment }, { it.navigate<BuyFragment>() }))
newCurrentButtonDefinitions.add(ButtonDefinition(98, R.drawable.ic_paid, R.drawable.ic_paid_filled, R.string.buy, canToggle = false, { it.currentMain is BuyFragment }, { it.navigate<BuyFragment>(withHistory = true) }))
}
//Add conditional buttons here, when you add a conditional button, be sure to add the register and unregister events for when the button needs to be updated
@@ -383,20 +383,20 @@ class MenuBottomBarFragment : MainActivityFragment() {
currentMain.scrollToTop(false)
currentMain.reloadFeed()
} else {
it.navigate<HomeFragment>()
it.navigate<HomeFragment>(withHistory = false)
}
}),
ButtonDefinition(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate<SubscriptionsFeedFragment>() }),
ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate<CreatorsFragment>() }),
ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>() }),
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>() }),
ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate<HistoryFragment>() }),
ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>() }),
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>() }),
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>() }),
ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>() }),
ButtonDefinition(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate<SubscriptionsFeedFragment>(withHistory = false) }),
ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate<CreatorsFragment>(withHistory = false) }),
ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>(withHistory = false) }),
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>(withHistory = false) }),
ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate<HistoryFragment>(withHistory = false) }),
ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>(withHistory = false) }),
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>(withHistory = false) }),
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>(withHistory = false) }),
ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>(withHistory = false) }),
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, {
val c = it.context ?: return@ButtonDefinition;
val c = it.context ?: return@ButtonDefinition;
Logger.i(TAG, "settings preventPictureInPicture()");
it.requireFragment<VideoDetailFragment>().preventPictureInPicture();
val intent = Intent(c, SettingsActivity::class.java);
@@ -416,7 +416,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
}, UIDialogs.ActionStyle.PRIMARY));
}),
ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = true, { false }, {
it.navigate<BrowserFragment>(Settings.URL_FAQ);
it.navigate<BrowserFragment>(Settings.URL_FAQ, withHistory = false);
})
//96 is reserved for privacy button
//98 is reserved for buy button
@@ -0,0 +1,814 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.Animatable
import android.os.Bundle
import android.text.Html
import android.text.method.ScrollingMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewPropertyAnimator
import android.widget.Button
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.children
import androidx.core.view.isVisible
import androidx.core.view.setPadding
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails
import com.futo.platformplayer.api.media.models.post.TextType
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.models.JSArticleDetails
import com.futo.platformplayer.api.media.platforms.js.models.JSHeaderSegment
import com.futo.platformplayer.api.media.platforms.js.models.JSImagesSegment
import com.futo.platformplayer.api.media.platforms.js.models.JSNestedSegment
import com.futo.platformplayer.api.media.platforms.js.models.JSTextSegment
import com.futo.platformplayer.api.media.platforms.js.models.SegmentType
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp
import com.futo.platformplayer.fixHtmlWhitespace
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod
import com.futo.platformplayer.sp
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.feedtypes.PreviewLockedView
import com.futo.platformplayer.views.adapters.feedtypes.PreviewNestedVideoView
import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView
import com.futo.platformplayer.views.adapters.feedtypes.PreviewVideoView
import com.futo.platformplayer.views.comments.AddCommentView
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.overlays.RepliesOverlay
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.segments.CommentsList
import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Models
import com.futo.polycentric.core.Opinion
import com.futo.polycentric.core.PolycentricProfile
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
import com.google.android.flexbox.FlexboxLayout
import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.ShapeAppearanceModel
import com.google.protobuf.ByteString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import userpackage.Protocol
import java.lang.Integer.min
class ArticleDetailFragment : MainFragment {
override val isMainView: Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var _viewDetail: ArticleDetailView? = null;
constructor() : super() { }
override fun onBackPressed(): Boolean {
return false;
}
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = ArticleDetailView(inflater.context).applyFragment(this);
_viewDetail = view;
return view;
}
override fun onDestroyMainView() {
super.onDestroyMainView();
_viewDetail?.onDestroy();
_viewDetail = null;
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
if (parameter is IPlatformArticleDetails) {
_viewDetail?.clear();
_viewDetail?.setArticleDetails(parameter);
} else if (parameter is IPlatformArticle) {
_viewDetail?.setArticleOverview(parameter);
} else if(parameter is String) {
_viewDetail?.setPostUrl(parameter);
}
}
private class ArticleDetailView : ConstraintLayout {
private lateinit var _fragment: ArticleDetailFragment;
private var _url: String? = null;
private var _isLoading = false;
private var _article: IPlatformArticleDetails? = null;
private var _articleOverview: IPlatformArticle? = null;
private var _polycentricProfile: PolycentricProfile? = null;
private var _version = 0;
private var _isRepliesVisible: Boolean = false;
private var _repliesAnimator: ViewPropertyAnimator? = null;
private val _creatorThumbnail: CreatorThumbnail;
private val _buttonSubscribe: SubscribeButton;
private val _channelName: TextView;
private val _channelMeta: TextView;
private val _textTitle: TextView;
private val _textMeta: TextView;
private val _textSummary: TextView;
private val _containerSegments: LinearLayout;
private val _platformIndicator: PlatformIndicator;
private val _buttonShare: ImageButton;
private val _layoutRating: LinearLayout;
private val _imageLikeIcon: ImageView;
private val _textLikes: TextView;
private val _imageDislikeIcon: ImageView;
private val _textDislikes: TextView;
private val _addCommentView: AddCommentView;
private val _rating: PillRatingLikesDislikes;
private val _layoutLoadingOverlay: FrameLayout;
private val _imageLoader: ImageView;
private var _overlayContainer: FrameLayout
private val _repliesOverlay: RepliesOverlay;
private val _commentsList: CommentsList;
private var _commentType: Boolean? = null;
private val _buttonPolycentric: Button
private val _buttonPlatform: Button
private val _taskLoadPost = if(!isInEditMode) TaskHandler<String, IPlatformArticleDetails>(
StateApp.instance.scopeGetter,
{
val result = StatePlatform.instance.getContentDetails(it).await();
if(result !is IPlatformArticleDetails)
throw IllegalStateException(context.getString(R.string.expected_media_content_found) + " ${result.contentType}");
return@TaskHandler result;
})
.success { setArticleDetails(it) }
.exception<Throwable> {
Logger.w(ChannelFragment.TAG, context.getString(R.string.failed_to_load_post), it);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment);
} else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope };
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, {
if (!StatePolycentric.instance.enabled)
return@TaskHandler null
ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!)
})
.success { it -> setPolycentricProfile(it, animate = true) }
.exception<Throwable> {
Logger.w(TAG, "Failed to load claims.", it);
};
constructor(context: Context) : super(context) {
inflate(context, R.layout.fragview_article_detail, this);
val root = findViewById<FrameLayout>(R.id.root);
_creatorThumbnail = findViewById(R.id.creator_thumbnail);
_buttonSubscribe = findViewById(R.id.button_subscribe);
_channelName = findViewById(R.id.text_channel_name);
_channelMeta = findViewById(R.id.text_channel_meta);
_textTitle = findViewById(R.id.text_title);
_textMeta = findViewById(R.id.text_meta);
_textSummary = findViewById(R.id.text_summary);
_containerSegments = findViewById(R.id.container_segments);
_platformIndicator = findViewById(R.id.platform_indicator);
_buttonShare = findViewById(R.id.button_share);
_overlayContainer = findViewById(R.id.overlay_container);
_layoutRating = findViewById(R.id.layout_rating);
_imageLikeIcon = findViewById(R.id.image_like_icon);
_textLikes = findViewById(R.id.text_likes);
_imageDislikeIcon = findViewById(R.id.image_dislike_icon);
_textDislikes = findViewById(R.id.text_dislikes);
_commentsList = findViewById(R.id.comments_list);
_addCommentView = findViewById(R.id.add_comment_view);
_rating = findViewById(R.id.rating);
_layoutLoadingOverlay = findViewById(R.id.layout_loading_overlay);
_imageLoader = findViewById(R.id.image_loader);
_repliesOverlay = findViewById(R.id.replies_overlay);
_buttonPolycentric = findViewById(R.id.button_polycentric)
_buttonPlatform = findViewById(R.id.button_platform)
_buttonSubscribe.onSubscribed.subscribe {
//TODO: add overlay to layout
//UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
};
val layoutTop: LinearLayout = findViewById(R.id.layout_top);
root.removeView(layoutTop);
_commentsList.setPrependedView(layoutTop);
/*TODO: Why is this here?
_commentsList.onCommentsLoaded.subscribe {
updateCommentType(false);
};*/
_commentsList.onRepliesClick.subscribe { c ->
val replyCount = c.replyCount ?: 0;
var metadata = "";
if (replyCount > 0) {
metadata += "$replyCount " + context.getString(R.string.replies);
}
if (c is PolycentricPlatformComment) {
var parentComment: PolycentricPlatformComment = c;
_repliesOverlay.load(_commentType!!, metadata, c.contextUrl, c.reference, c,
{ StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) },
{
val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1);
_commentsList.replaceComment(parentComment, newComment);
parentComment = newComment;
});
} else {
_repliesOverlay.load(_commentType!!, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) });
}
setRepliesOverlayVisible(isVisible = true, animate = true);
};
if (StatePolycentric.instance.enabled) {
_buttonPolycentric.setOnClickListener {
updateCommentType(false)
}
} else {
_buttonPolycentric.visibility = View.GONE
}
_buttonPlatform.setOnClickListener {
updateCommentType(true)
}
_addCommentView.onCommentAdded.subscribe {
_commentsList.addComment(it);
};
_repliesOverlay.onClose.subscribe { setRepliesOverlayVisible(isVisible = false, animate = true); };
_buttonShare.setOnClickListener { share() };
_creatorThumbnail.onClick.subscribe { openChannel() };
_channelName.setOnClickListener { openChannel() };
_channelMeta.setOnClickListener { openChannel() };
}
private fun openChannel() {
val author = _article?.author ?: _articleOverview?.author ?: return;
_fragment.navigate<ChannelFragment>(author);
}
private fun share() {
try {
Logger.i(PreviewPostView.TAG, "sharePost")
val url = _article?.shareUrl ?: _articleOverview?.shareUrl ?: _url;
_fragment.startActivity(Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND;
putExtra(Intent.EXTRA_TEXT, url);
type = "text/plain"; //TODO: Determine alt types?
}, null));
} catch (e: Throwable) {
//Ignored
Logger.e(PreviewPostView.TAG, "Failed to share.", e);
}
}
private fun updatePolycentricRating() {
_rating.visibility = View.GONE;
val ref = Models.referenceFromBuffer((_article?.url ?: _articleOverview?.url)?.toByteArray() ?: return)
val extraBytesRef = (_article?.id?.value ?: _articleOverview?.id?.value)?.let { if (it.isNotEmpty()) it.toByteArray() else null }
val version = _version;
_rating.onLikeDislikeUpdated.remove(this);
if (!StatePolycentric.instance.enabled)
return
_fragment.lifecycleScope.launch(Dispatchers.IO) {
if (version != _version) {
return@launch;
}
try {
val queryReferencesResponse = ApiMethods.getQueryReferences(ApiMethods.SERVER, ref, null,null,
arrayListOf(
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(
ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.like.data)).build(),
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(
ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.dislike.data)).build()
),
extraByteReferences = listOfNotNull(extraBytesRef)
);
if (version != _version) {
return@launch;
}
val likes = queryReferencesResponse.countsList[0];
val dislikes = queryReferencesResponse.countsList[1];
val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
withContext(Dispatchers.Main) {
if (version != _version) {
return@withContext;
}
_rating.visibility = VISIBLE;
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like);
} else if (args.hasDisliked) {
args.processHandle.opinion(ref, Opinion.dislike);
} else {
args.processHandle.opinion(ref, Opinion.neutral);
}
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
Logger.i(TAG, "Started backfill");
args.processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e)
}
}
StatePolycentric.instance.updateLikeMap(ref, args.hasLiked, args.hasDisliked)
};
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
_rating.visibility = View.GONE;
}
}
}
private fun setPlatformRating(rating: IRating?) {
if (rating == null) {
_layoutRating.visibility = View.GONE;
return;
}
_layoutRating.visibility = View.VISIBLE;
when (rating) {
is RatingLikeDislikes -> {
_textLikes.visibility = View.VISIBLE;
_imageLikeIcon.visibility = View.VISIBLE;
_textLikes.text = rating.likes.toHumanNumber();
_imageDislikeIcon.visibility = View.VISIBLE;
_textDislikes.visibility = View.VISIBLE;
_textDislikes.text = rating.dislikes.toHumanNumber();
}
is RatingLikes -> {
_textLikes.visibility = View.VISIBLE;
_imageLikeIcon.visibility = View.VISIBLE;
_textLikes.text = rating.likes.toHumanNumber();
_imageDislikeIcon.visibility = View.GONE;
_textDislikes.visibility = View.GONE;
}
else -> {
_textLikes.visibility = View.GONE;
_imageLikeIcon.visibility = View.GONE;
_imageDislikeIcon.visibility = View.GONE;
_textDislikes.visibility = View.GONE;
}
}
}
fun applyFragment(frag: ArticleDetailFragment): ArticleDetailView {
_fragment = frag;
return this;
}
fun clear() {
_commentsList.cancel();
_taskLoadPost.cancel();
_taskLoadPolycentricProfile.cancel();
_version++;
updateCommentType(null)
_url = null;
_article = null;
_articleOverview = null;
_creatorThumbnail.clear();
//_buttonSubscribe.setSubscribeChannel(null); TODO: clear button
_channelName.text = "";
setChannelMeta(null);
_textTitle.text = "";
_textMeta.text = "";
setPlatformRating(null);
_polycentricProfile = null;
_rating.visibility = View.GONE;
updatePolycentricRating();
setRepliesOverlayVisible(isVisible = false, animate = false);
_containerSegments.removeAllViews();
_addCommentView.setContext(null, null);
_platformIndicator.clearPlatform();
}
fun setArticleDetails(value: IPlatformArticleDetails) {
_url = value.url;
_article = value;
_creatorThumbnail.setThumbnail(value.author.thumbnail, false);
_buttonSubscribe.setSubscribeChannel(value.author.url);
_channelName.text = value.author.name;
setChannelMeta(value);
_textTitle.text = value.name;
_textMeta.text = value.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: "" //TODO: Include view count?
_textSummary.text = value.summary
_textSummary.isVisible = !value.summary.isNullOrEmpty()
_platformIndicator.setPlatformFromClientID(value.id.pluginId);
setPlatformRating(value.rating);
for(seg in value.segments) {
when(seg.type) {
SegmentType.HEADER -> {
if(seg is JSHeaderSegment) {
_containerSegments.addView(ArticleHeaderBlock(context, seg.content, seg.level))
}
}
SegmentType.TEXT -> {
if(seg is JSTextSegment) {
_containerSegments.addView(ArticleTextBlock(context, seg.content, seg.textType))
}
}
SegmentType.IMAGES -> {
if(seg is JSImagesSegment) {
if(seg.images.size > 0)
_containerSegments.addView(ArticleImageBlock(context, seg.images[0], seg.caption))
}
}
SegmentType.NESTED -> {
if(seg is JSNestedSegment) {
_containerSegments.addView(ArticleContentBlock(context, seg.nested, _fragment, _overlayContainer));
}
}
else ->{}
}
}
//Fetch only when not already called in setPostOverview
if (_articleOverview == null) {
fetchPolycentricProfile();
updatePolycentricRating();
_addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray()));
}
val commentType = !Settings.instance.other.polycentricEnabled || Settings.instance.comments.defaultCommentSection == 1
updateCommentType(commentType, true);
setLoading(false);
}
fun setArticleOverview(value: IPlatformArticle) {
clear();
_url = value.url;
_articleOverview = value;
_creatorThumbnail.setThumbnail(value.author.thumbnail, false);
_buttonSubscribe.setSubscribeChannel(value.author.url);
_channelName.text = value.author.name;
setChannelMeta(value);
_textTitle.text = value.name;
_textMeta.text = value.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: "" //TODO: Include view count?
_platformIndicator.setPlatformFromClientID(value.id.pluginId);
_addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray()));
updatePolycentricRating();
fetchPolycentricProfile();
fetchPost();
}
private fun setRepliesOverlayVisible(isVisible: Boolean, animate: Boolean) {
if (_isRepliesVisible == isVisible) {
return;
}
_isRepliesVisible = isVisible;
_repliesAnimator?.cancel();
if (isVisible) {
_repliesOverlay.visibility = View.VISIBLE;
if (animate) {
_repliesOverlay.translationY = _repliesOverlay.height.toFloat();
_repliesAnimator = _repliesOverlay.animate()
.setDuration(300)
.translationY(0f)
.withEndAction {
_repliesAnimator = null;
}.apply { start() };
}
} else {
if (animate) {
_repliesOverlay.translationY = 0f;
_repliesAnimator = _repliesOverlay.animate()
.setDuration(300)
.translationY(_repliesOverlay.height.toFloat())
.withEndAction {
_repliesOverlay.visibility = GONE;
_repliesAnimator = null;
}.apply { start(); }
} else {
_repliesOverlay.visibility = View.GONE;
_repliesOverlay.translationY = _repliesOverlay.height.toFloat();
}
}
}
private fun fetchPolycentricProfile() {
val author = _article?.author ?: _articleOverview?.author ?: return;
setPolycentricProfile(null, animate = false);
_taskLoadPolycentricProfile.run(author.id);
}
private fun setChannelMeta(value: IPlatformArticle?) {
val subscribers = value?.author?.subscribers;
if(subscribers != null && subscribers > 0) {
_channelMeta.visibility = View.VISIBLE;
_channelMeta.text = if((value.author.subscribers ?: 0) > 0) value.author.subscribers!!.toHumanNumber() + " " + context.getString(R.string.subscribers) else "";
} else {
_channelMeta.visibility = View.GONE;
_channelMeta.text = "";
}
}
fun setPostUrl(url: String) {
clear();
_url = url;
fetchPost();
}
fun onDestroy() {
_commentsList.cancel();
_taskLoadPost.cancel();
_repliesOverlay.cleanup();
}
private fun setPolycentricProfile(polycentricProfile: PolycentricProfile?, animate: Boolean) {
_polycentricProfile = polycentricProfile;
val pp = _polycentricProfile;
if (pp == null) {
_creatorThumbnail.setHarborAvailable(false, animate, null);
return;
}
_creatorThumbnail.setHarborAvailable(true, animate, pp.system.toProto());
}
private fun fetchPost() {
Logger.i(TAG, "fetchVideo")
_article = null;
val url = _url;
if (!url.isNullOrBlank()) {
setLoading(true);
_taskLoadPost.run(url);
}
}
private fun fetchComments() {
Logger.i(TAG, "fetchComments")
_article?.let {
_commentsList.load(true) { StatePlatform.instance.getComments(it); };
}
}
private fun fetchPolycentricComments() {
Logger.i(TAG, "fetchPolycentricComments")
val post = _article;
val ref = (_article?.url ?: _articleOverview?.url)?.toByteArray()?.let { Models.referenceFromBuffer(it) }
val extraBytesRef = (_article?.id?.value ?: _articleOverview?.id?.value)?.let { if (it.isNotEmpty()) it.toByteArray() else null }
if (ref == null) {
Logger.w(TAG, "Failed to fetch polycentric comments because url was not set null")
_commentsList.clear();
return
}
_commentsList.load(false) { StatePolycentric.instance.getCommentPager(post!!.url, ref, listOfNotNull(extraBytesRef)); };
}
private fun updateCommentType(commentType: Boolean?, forceReload: Boolean = false) {
val changed = commentType != _commentType
_commentType = commentType
if (commentType == null) {
_buttonPlatform.setTextColor(resources.getColor(R.color.gray_ac))
_buttonPolycentric.setTextColor(resources.getColor(R.color.gray_ac))
} else {
_buttonPlatform.setTextColor(resources.getColor(if (commentType) R.color.white else R.color.gray_ac))
_buttonPolycentric.setTextColor(resources.getColor(if (!commentType) R.color.white else R.color.gray_ac))
if (commentType) {
_addCommentView.visibility = View.GONE;
if (forceReload || changed) {
fetchComments();
}
} else {
_addCommentView.visibility = View.VISIBLE;
if (forceReload || changed) {
fetchPolycentricComments()
}
}
}
}
private fun setLoading(isLoading : Boolean) {
if (_isLoading == isLoading) {
return;
}
_isLoading = isLoading;
if(isLoading) {
(_imageLoader.drawable as Animatable?)?.start()
_layoutLoadingOverlay.visibility = View.VISIBLE;
}
else {
_layoutLoadingOverlay.visibility = View.GONE;
(_imageLoader.drawable as Animatable?)?.stop()
}
}
class ArticleHeaderBlock : LinearLayout {
constructor(context: Context?, content: String, level: Int) : super(context){
inflate(context, R.layout.view_segment_text, this);
findViewById<TextView>(R.id.text_content)?.let {
it.text = content;
val sp = when(level) {
1 -> 6.sp(resources);
2 -> 8.sp(resources);
3 -> 10.sp(resources);
4 -> 12.sp(resources);
5 -> 14.sp(resources);
else -> 6.sp(resources);
}
it.setTextColor(Color.WHITE);
it.setTypeface(Typeface.create(null, 600, false));
it.textSize = sp.toFloat();
}
}
}
class ArticleTextBlock : LinearLayout {
constructor(context: Context?, content: String, textType: TextType) : super(context){
inflate(context, R.layout.view_segment_text, this);
findViewById<TextView>(R.id.text_content)?.let {
if(textType == TextType.HTML)
it.text = Html.fromHtml(content, Html.FROM_HTML_MODE_COMPACT);
else if(textType == TextType.CODE) {
it.text = content;
it.setPadding(15.dp(resources));
it.setHorizontallyScrolling(true);
it.movementMethod = ScrollingMovementMethod();
it.setTypeface(Typeface.MONOSPACE);
it.setBackgroundResource(R.drawable.background_videodetail_description)
}
else
it.text = content;
}
}
}
class ArticleImageBlock: LinearLayout {
constructor(context: Context?, image: String, caption: String? = null) : super(context){
inflate(context, R.layout.view_segment_image, this);
findViewById<ImageView>(R.id.image_content)?.let {
Glide.with(it)
.load(image)
.crossfade()
.into(it);
}
findViewById<TextView>(R.id.text_content)?.let {
if(caption?.isNullOrEmpty() == true)
it.isVisible = false;
else
it.text = caption;
}
}
}
class ArticleContentBlock: LinearLayout {
constructor(context: Context, content: IPlatformContent?, fragment: ArticleDetailFragment? = null, overlayContainer: FrameLayout? = null): super(context) {
if(content != null) {
var view: View? = null;
if(content is IPlatformNestedContent) {
view = PreviewNestedVideoView(context, FeedStyle.THUMBNAIL, null);
view.bind(content);
view.onContentUrlClicked.subscribe { a,b -> }
}
else if(content is IPlatformVideo) {
view = PreviewVideoView(context, FeedStyle.THUMBNAIL, null, true);
view.bind(content);
view.onVideoClicked.subscribe { a,b -> fragment?.navigate<VideoDetailFragment>(a) }
view.onChannelClicked.subscribe { a -> fragment?.navigate<ChannelFragment>(a) }
if(overlayContainer != null) {
view.onAddToClicked.subscribe { a -> UISlideOverlays.showVideoOptionsOverlay(a, overlayContainer) };
}
view.onAddToQueueClicked.subscribe { a -> StatePlayer.instance.addToQueue(a) }
view.onAddToWatchLaterClicked.subscribe { a ->
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true))
UIDialogs.toast("Added to watch later\n[${content.name}]")
}
}
else if(content is IPlatformPost) {
view = PreviewPostView(context, FeedStyle.THUMBNAIL);
view.bind(content);
view.onContentClicked.subscribe { a -> fragment?.navigate<PostDetailFragment>(a) }
view.onChannelClicked.subscribe { a -> fragment?.navigate<ChannelFragment>(a) }
}
else if(content is IPlatformArticle) {
view = PreviewPostView(context, FeedStyle.THUMBNAIL);
view.bind(content);
view.onContentClicked.subscribe { a -> fragment?.navigate<ArticleDetailFragment>(a) }
view.onChannelClicked.subscribe { a -> fragment?.navigate<ChannelFragment>(a) }
}
else if(content is IPlatformLockedContent) {
view = PreviewLockedView(context, FeedStyle.THUMBNAIL);
view.bind(content);
}
if(view != null)
addView(view);
}
}
}
companion object {
const val TAG = "PostDetailFragment"
}
}
companion object {
fun newInstance() = ArticleDetailFragment().apply {}
}
}
@@ -1,6 +1,8 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -66,8 +68,7 @@ class BuyFragment : MainFragment() {
_paymentManager = PaymentManager(StatePayment.instance, fragment, _overlayPaying) { success, _, exception ->
if(success) {
UIDialogs.showDialog(context, R.drawable.ic_check, context.getString(R.string.payment_succeeded), context.getString(R.string.thanks_for_your_purchase_a_key_will_be_sent_to_your_email_after_your_payment_has_been_received), null, 0,
UIDialogs.Action("Ok", {}, UIDialogs.ActionStyle.PRIMARY));
UIDialogs.showDialog(context, R.drawable.ic_check, context.getString(R.string.payment_succeeded), context.getString(R.string.thanks_for_your_purchase_a_key_will_be_sent_to_your_email_after_your_payment_has_been_received), null, 0, UIDialogs.Action("Ok", {}, UIDialogs.ActionStyle.PRIMARY));
_fragment.close(true);
}
else {
@@ -115,11 +116,14 @@ class BuyFragment : MainFragment() {
val licenseInput = SlideUpMenuTextInput(context, context.getString(R.string.license));
val productLicenseDialog = SlideUpMenuOverlay(context, findViewById<FrameLayout>(R.id.overlay_paid), context.getString(R.string.enter_license_key), context.getString(R.string.ok), true, licenseInput);
productLicenseDialog.onOK.subscribe {
licenseInput.deactivate();
val licenseText = licenseInput.text;
if (licenseText.isNullOrEmpty()) {
UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, context.getString(R.string.invalid_license_key));
return@subscribe;
}
licenseInput.clear();
productLicenseDialog.hide(true);
_fragment.lifecycleScope.launch(Dispatchers.IO) {
@@ -127,17 +131,18 @@ class BuyFragment : MainFragment() {
val activationResult = StatePayment.instance.setPaymentLicense(licenseText);
withContext(Dispatchers.Main) {
if(activationResult) {
licenseInput.deactivate();
licenseInput.clear();
productLicenseDialog.hide(true);
UIDialogs.showDialogOk(context, R.drawable.ic_check, context.getString(R.string.your_license_key_has_been_set_an_app_restart_might_be_required));
_fragment.close(true);
}
else
{
UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, context.getString(R.string.invalid_license_key));
try {
if(activationResult) {
UIDialogs.showDialogOk(context, R.drawable.ic_check, context.getString(R.string.your_license_key_has_been_set_an_app_restart_might_be_required)) {
_fragment.close(true)
}
}
else
{
UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, context.getString(R.string.invalid_license_key));
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to update UI after buy complete", e)
}
}
}
@@ -158,5 +163,6 @@ class BuyFragment : MainFragment() {
companion object {
fun newInstance() = BuyFragment().apply {}
private val TAG = "BuyFragment"
}
}
@@ -10,12 +10,14 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
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.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.models.JSWeb
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateMeta
@@ -196,7 +198,14 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
fragment.navigate<RemotePlaylistFragment>(content);
} else if (content is IPlatformPost) {
fragment.navigate<PostDetailFragment>(content);
} else if(content is IPlatformArticle) {
fragment.navigate<ArticleDetailFragment>(content);
}
else if(content is JSWeb) {
fragment.navigate<WebDetailFragment>(content);
}
else
UIDialogs.appToast("Unknown content type [" + content.contentType.name + "]");
}
protected open fun onContentUrlClicked(url: String, contentType: ContentType) {
when(contentType) {
@@ -16,6 +16,8 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.views.adapters.SubscriptionAdapter
class CreatorsFragment : MainFragment() {
@@ -29,6 +31,8 @@ class CreatorsFragment : MainFragment() {
private var _editSearch: EditText? = null;
private var _textMeta: TextView? = null;
private var _buttonClearSearch: ImageButton? = null
private var _ordering = FragmentedStorage.get<StringStorage>("creators_ordering")
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = inflater.inflate(R.layout.fragment_creators, container, false);
@@ -44,7 +48,7 @@ class CreatorsFragment : MainFragment() {
_buttonClearSearch?.visibility = View.INVISIBLE;
}
val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription)) { subs ->
val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription), _ordering?.value?.toIntOrNull() ?: 5) { subs ->
_textMeta?.let {
it.text = "${subs.size} creator${if(subs.size > 1) "s" else ""}";
}
@@ -61,6 +65,7 @@ class CreatorsFragment : MainFragment() {
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
adapter.sortBy = pos;
_ordering.setAndSave(pos.toString())
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
};
@@ -150,7 +150,7 @@ class DownloadsFragment : MainFragment() {
spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.downloads_sortby_array)).also {
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
};
val options = listOf("nameAsc", "nameDesc", "downloadDateAsc", "downloadDateDesc", "releasedAsc", "releasedDesc");
val options = listOf("nameAsc", "nameDesc", "downloadDateAsc", "downloadDateDesc", "releasedAsc", "releasedDesc", "sizeAsc", "sizeDesc");
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
when(pos) {
@@ -160,6 +160,8 @@ class DownloadsFragment : MainFragment() {
3 -> ordering.setAndSave("downloadDateDesc")
4 -> ordering.setAndSave("releasedAsc")
5 -> ordering.setAndSave("releasedDesc")
6 -> ordering.setAndSave("sizeAsc")
7 -> ordering.setAndSave("sizeDesc")
else -> ordering.setAndSave("")
}
updateContentFilters()
@@ -257,6 +259,8 @@ class DownloadsFragment : MainFragment() {
"nameDesc" -> vidsToReturn.sortedByDescending { it.name.lowercase() }
"releasedAsc" -> vidsToReturn.sortedBy { it.datetime ?: OffsetDateTime.MAX }
"releasedDesc" -> vidsToReturn.sortedByDescending { it.datetime ?: OffsetDateTime.MIN }
"sizeAsc" -> vidsToReturn.sortedBy { it.videoSource.sumOf { it.fileSize } + it.audioSource.sumOf { it.fileSize } }
"sizeDesc" -> vidsToReturn.sortedByDescending { it.videoSource.sumOf { it.fileSize } + it.audioSource.sumOf { it.fileSize } }
else -> vidsToReturn
}
}
@@ -10,7 +10,6 @@ import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.constructs.TaskHandler
@@ -165,14 +164,24 @@ class PlaylistFragment : MainFragment() {
};
}
private fun copyPlaylist(playlist: Playlist) {
private fun savePlaylist(playlist: Playlist) {
StatePlaylists.instance.playlistStore.save(playlist)
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(
arrayListOf()
)
UIDialogs.toast("Playlist saved")
}
private fun copyPlaylist(playlist: Playlist) {
var copyNumber = 1
var newName = "${playlist.name} (Copy)"
val playlists = StatePlaylists.instance.playlistStore.getItems()
while (playlists.any { it.name == newName }) {
copyNumber += 1
newName = "${playlist.name} (Copy $copyNumber)"
}
StatePlaylists.instance.playlistStore.save(playlist.makeCopy(newName))
_fragment.navigate<PlaylistsFragment>(withHistory = false)
UIDialogs.toast("Playlist copied")
}
fun onShown(parameter: Any?) {
_taskLoadPlaylist.cancel()
@@ -188,12 +197,14 @@ class PlaylistFragment : MainFragment() {
setButtonExportVisible(false)
setButtonEditVisible(true)
if (!StatePlaylists.instance.playlistStore.hasItem { it.id == parameter.id }) {
_fragment.topBar?.assume<NavigationTopBarFragment>()
?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) {
_fragment.topBar?.assume<NavigationTopBarFragment>()
?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) {
if (StatePlaylists.instance.playlistStore.hasItem { it.id == parameter.id }) {
copyPlaylist(parameter)
}))
}
} else {
savePlaylist(parameter)
}
}))
} else {
setName(null)
setVideos(null, false)
@@ -259,7 +270,7 @@ class PlaylistFragment : MainFragment() {
val playlist = _playlist ?: return
if (!StatePlaylists.instance.playlistStore.hasItem { it.id == playlist.id }) {
UIDialogs.showConfirmationDialog(context, "Playlist must be saved to download", {
copyPlaylist(playlist)
savePlaylist(playlist)
download()
})
return
@@ -292,7 +303,7 @@ class PlaylistFragment : MainFragment() {
val playlist = _playlist ?: return
if (!StatePlaylists.instance.playlistStore.hasItem { it.id == playlist.id }) {
UIDialogs.showConfirmationDialog(context, "Playlist must be saved to edit the name", {
copyPlaylist(playlist)
savePlaylist(playlist)
onEditClick()
})
return
@@ -290,8 +290,8 @@ class SubscriptionGroupFragment : MainFragment() {
image.setImageView(_imageGroup);
}
else {
_imageGroupBackground.setImageResource(0);
_imageGroup.setImageResource(0);
_imageGroupBackground.setImageDrawable(null);
_imageGroup.setImageDrawable(null);
}
updateMeta();
reloadCreators(group);
@@ -191,7 +191,7 @@ class SubscriptionsFeedFragment : MainFragment() {
private var _bypassRateLimit = false;
private val _lastExceptions: List<Throwable>? = null;
private val _taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({StateApp.instance.scope}, { withRefresh ->
private val _taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({fragment.lifecycleScope}, { withRefresh ->
val group = subGroup;
if(!_bypassRateLimit) {
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount(group);
@@ -202,7 +202,7 @@ class SubscriptionsFeedFragment : MainFragment() {
throw RateLimitException(rateLimitPlugins.map { it.key.id });
}
_bypassRateLimit = false;
val resp = StateSubscriptions.instance.getGlobalSubscriptionFeed(StateApp.instance.scope, withRefresh, group);
val resp = StateSubscriptions.instance.getGlobalSubscriptionFeed(fragment.lifecycleScope, withRefresh, group);
val feed = StateSubscriptions.instance.getFeed(group?.id);
val currentExs = feed?.exceptions ?: listOf();
@@ -465,10 +465,14 @@ class VideoDetailFragment() : MainFragment() {
activity?.enterPictureInPictureMode(params);
}
fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, isStop: Boolean, newConfig: Configuration) {
if (isInPictureInPictureMode) {
_viewDetail?.startPictureInPicture();
} else if (isInPictureInPicture) {
leavePictureInPictureMode(isStop);
try {
if (isInPictureInPictureMode) {
_viewDetail?.startPictureInPicture();
} else if (isInPictureInPicture) {
leavePictureInPictureMode(isStop);
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle onPictureInPictureModeChanged", e)
}
}
fun leavePictureInPictureMode(isStop: Boolean) {
@@ -2,6 +2,8 @@ package com.futo.platformplayer.fragment.mainactivity.main
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
@@ -174,6 +176,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import userpackage.Protocol
import java.time.OffsetDateTime
import java.util.Locale
import kotlin.math.abs
import kotlin.math.roundToLong
@@ -433,6 +436,15 @@ class VideoDetailView : ConstraintLayout {
onShouldEnterPictureInPictureChanged.emit()
}
_title.setOnLongClickListener {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager;
val clip = ClipData.newPlainText("Video Title", (it as TextView).text);
clipboard.setPrimaryClip(clip);
UIDialogs.toast(context, "Copied", false)
// let other interactions happen based on the touch
false
}
_buttonSubscribe.onSubscribed.subscribe {
_slideUpOverlay = UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
};
@@ -648,6 +660,7 @@ class VideoDetailView : ConstraintLayout {
loadCurrentVideo(lastPositionMilliseconds);
updatePillButtonVisibilities();
setCastEnabled(false);
}
else -> {}
}
@@ -676,6 +689,15 @@ class VideoDetailView : ConstraintLayout {
_timeBar.setDuration(video?.duration ?: 0);
}
};
_cast.onTimeJobTimeChanged_s.subscribe {
if (_isCasting) {
setLastPositionMilliseconds((it * 1000.0).toLong(), true);
_timeBar.setPosition(it);
_timeBar.setBufferedPosition(0);
_timeBar.setDuration(video?.duration ?: 0);
}
}
}
_playerProgress.player = _player.exoPlayer?.player;
@@ -810,6 +832,7 @@ class VideoDetailView : ConstraintLayout {
_lastAudioSource = null;
_lastSubtitleSource = null;
video = null;
_container_content_liveChat?.close();
_player.clear();
cleanupPlaybackTracker();
Logger.i(TAG, "Keep screen on unset onClose")
@@ -1133,7 +1156,7 @@ class VideoDetailView : ConstraintLayout {
when (Settings.instance.playback.backgroundPlay) {
0 -> handlePause();
1 -> {
if(!(video?.isLive ?: false) && Settings.instance.playback.backgroundSwitchToAudio)
if(!(video?.isLive ?: false))
_player.switchToAudioMode();
StatePlayer.instance.startOrUpdateMediaSession(context, video);
}
@@ -1417,8 +1440,8 @@ class VideoDetailView : ConstraintLayout {
onVideoChanged.emit(0, 0)
}
val me = this;
if (video is JSVideoDetails) {
val me = this;
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
//TODO: Implement video.getContentChapters()
@@ -1475,6 +1498,32 @@ class VideoDetailView : ConstraintLayout {
}
};
}
else {
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
if (!StateApp.instance.privateMode) {
val stopwatch = com.futo.platformplayer.debug.Stopwatch()
var tracker = video.getPlaybackTracker()
Logger.i(TAG, "video.getPlaybackTracker took ${stopwatch.elapsedMs}ms")
if (tracker == null) {
stopwatch.reset()
tracker = StatePlatform.instance.getPlaybackTracker(video.url);
Logger.i(
TAG,
"StatePlatform.instance.getPlaybackTracker took ${stopwatch.elapsedMs}ms"
)
}
if (me.video?.url == video.url && !video.url.isNullOrBlank())
me._playbackTracker = tracker;
} else if (me.video == video)
me._playbackTracker = null;
} catch (ex: Throwable) {
Logger.e(TAG, "Playback tracker failed", ex);
}
}
}
val ref = Models.referenceFromBuffer(video.url.toByteArray())
val extraBytesRef = video.id.value?.let { if (it.isNotEmpty()) it.toByteArray() else null }
@@ -2167,23 +2216,40 @@ class VideoDetailView : ConstraintLayout {
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,
if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate)) } else null,
qualityPlaybackSpeedTitle,
if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply {
setButtons(listOf("0.25", "0.5", "0.75", "1.0", "1.25", "1.5", "1.75", "2.0", "2.25"), currentPlaybackRate!!.toString());
val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds();
val format = if(playbackSpeeds.size < 20) "%.2f" else "%.1f";
val playbackLabels = playbackSpeeds.map { String.format(Locale.US, format, it) }.toMutableList();
playbackLabels.add("+");
playbackLabels.add(0, "-");
setButtons(playbackLabels, String.format(Locale.US, format, currentPlaybackRate));
onClick.subscribe { v ->
val currentPlaybackSpeed = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate();
var playbackSpeedString = v;
val stepSpeed = Settings.instance.playback.getPlaybackSpeedStep();
if(v == "+")
playbackSpeedString = String.format(Locale.US, "%.2f", Math.min((currentPlaybackSpeed?.toDouble() ?: 1.0) + stepSpeed, 5.0)).toString();
else if(v == "-")
playbackSpeedString = String.format(Locale.US, "%.2f", Math.max(0.1, (currentPlaybackSpeed?.toDouble() ?: 1.0) - stepSpeed)).toString();
val newPlaybackSpeed = playbackSpeedString.toDouble();
if (_isCasting) {
val ad = StateCasting.instance.activeDevice ?: return@subscribe
if (!ad.canSetSpeed) {
return@subscribe
}
ad.changeSpeed(v.toDouble())
setSelected(v);
qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})");
ad.changeSpeed(newPlaybackSpeed)
setSelected(playbackSpeedString);
} else {
_player.setPlaybackRate(v.toFloat());
setSelected(v);
qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})");
_player.setPlaybackRate(playbackSpeedString.toFloat());
setSelected(playbackSpeedString);
}
};
} else null,
@@ -2542,7 +2608,9 @@ class VideoDetailView : ConstraintLayout {
}
fun saveBrightness() {
_player.gestureControl.saveBrightness()
if (Settings.instance.gestureControls.useSystemBrightness) {
_player.gestureControl.saveBrightness()
}
}
fun restoreBrightness() {
_player.gestureControl.restoreBrightness()
@@ -224,7 +224,8 @@ abstract class VideoListEditorView : LinearLayout {
fun updateVideoFilters() {
val videos = _loadedVideos ?: return;
_videoListEditorView.setVideos(filterVideos(videos), _loadedVideosCanEdit);
val filteredVideos = filterVideos(videos)
_videoListEditorView.setVideos(filteredVideos, _loadedVideosCanEdit && filteredVideos.size == videos.size);
}
protected fun setButtonDownloadVisible(isVisible: Boolean) {
@@ -0,0 +1,223 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Animatable
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewPropertyAnimator
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.children
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.platforms.js.models.JSWeb
import com.futo.platformplayer.api.media.platforms.js.models.JSWebDetails
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp
import com.futo.platformplayer.fixHtmlWhitespace
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.views.adapters.ChannelTab
import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView
import com.futo.platformplayer.views.comments.AddCommentView
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.overlays.RepliesOverlay
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.segments.CommentsList
import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Models
import com.futo.polycentric.core.Opinion
import com.futo.polycentric.core.PolycentricProfile
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
import com.google.android.flexbox.FlexboxLayout
import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.ShapeAppearanceModel
import com.google.protobuf.ByteString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import userpackage.Protocol
import java.lang.Integer.min
class WebDetailFragment : MainFragment {
override val isMainView: Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var _viewDetail: WebDetailView? = null;
constructor() : super() { }
override fun onBackPressed(): Boolean {
return false;
}
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = WebDetailView(inflater.context).applyFragment(this);
_viewDetail = view;
return view;
}
override fun onDestroyMainView() {
super.onDestroyMainView();
_viewDetail?.onDestroy();
_viewDetail = null;
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
if (parameter is JSWeb) {
_viewDetail?.clear();
_viewDetail?.setWeb(parameter);
}
if (parameter is JSWebDetails) {
_viewDetail?.clear();
_viewDetail?.setWebDetails(parameter);
}
}
private class WebDetailView : ConstraintLayout {
private lateinit var _fragment: WebDetailFragment;
private var _url: String? = null;
private var _isLoading = false;
private var _web: JSWebDetails? = null;
private val _layoutLoadingOverlay: FrameLayout;
private val _imageLoader: ImageView;
private val _webview: WebView;
private val _taskLoadPost = if(!isInEditMode) TaskHandler<String, JSWebDetails>(
StateApp.instance.scopeGetter,
{
val result = StatePlatform.instance.getContentDetails(it).await();
if(result !is JSWebDetails)
throw IllegalStateException(context.getString(R.string.expected_media_content_found) + " ${result.contentType}");
return@TaskHandler result;
})
.success { setWebDetails(it) }
.exception<Throwable> {
Logger.w(ChannelFragment.TAG, context.getString(R.string.failed_to_load_post), it);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment);
} else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope };
constructor(context: Context) : super(context) {
inflate(context, R.layout.fragview_web_detail, this);
val root = findViewById<FrameLayout>(R.id.root);
_layoutLoadingOverlay = findViewById(R.id.layout_loading_overlay);
_imageLoader = findViewById(R.id.image_loader);
_webview = findViewById(R.id.webview);
_webview.webViewClient = object: WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url);
if(url != "about:blank")
setLoading(false);
}
}
}
fun applyFragment(frag: WebDetailFragment): WebDetailView {
_fragment = frag;
return this;
}
fun clear() {
_webview.loadUrl("about:blank");
}
fun setWeb(value: JSWeb) {
_url = value.url;
setLoading(true);
clear();
fetchPost();
}
fun setWebDetails(value: JSWebDetails) {
_web = value;
setLoading(true);
_webview.loadUrl("about:blank");
if(!value.html.isNullOrEmpty())
_webview.loadData(value.html, "text/html", null);
else
_webview.loadUrl(value.url ?: "about:blank");
}
private fun fetchPost() {
Logger.i(WebDetailView.TAG, "fetchWeb")
_web = null;
val url = _url;
if (!url.isNullOrBlank()) {
setLoading(true);
_taskLoadPost.run(url);
}
}
fun onDestroy() {
_webview.loadUrl("about:blank");
}
private fun setLoading(isLoading : Boolean) {
if (_isLoading == isLoading) {
return;
}
_isLoading = isLoading;
if(isLoading) {
(_imageLoader.drawable as Animatable?)?.start()
_layoutLoadingOverlay.visibility = View.VISIBLE;
}
else {
_layoutLoadingOverlay.visibility = View.GONE;
(_imageLoader.drawable as Animatable?)?.stop()
}
}
companion object {
const val TAG = "WebDetailFragment"
}
}
companion object {
fun newInstance() = WebDetailFragment().apply {}
}
}
@@ -35,6 +35,9 @@ class Playlist {
this.videos = ArrayList(list);
}
fun makeCopy(newName: String? = null): Playlist {
return Playlist(newName ?: name, videos)
}
companion object {
fun fromV8(config: SourcePluginConfig, obj: V8ValueObject?): Playlist? {
@@ -8,11 +8,14 @@ import android.text.method.LinkMovementMethod
import android.text.style.URLSpan
import android.view.MotionEvent
import android.widget.TextView
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.receivers.MediaControlReceiver
import com.futo.platformplayer.timestampRegex
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class PlatformLinkMovementMethod(private val _context: Context) : LinkMovementMethod() {
@@ -60,31 +63,39 @@ class PlatformLinkMovementMethod(private val _context: Context) : LinkMovementMe
val dx = event.x - downX
val dy = event.y - downY
if (Math.abs(dx) <= touchSlop && Math.abs(dy) <= touchSlop && isTouchInside(widget, event)) {
runBlocking {
for (link in pressedLinks!!) {
Logger.i(TAG) { "Link clicked '${link.url}'." }
for (link in pressedLinks!!) {
Logger.i(TAG) { "Link clicked '${link.url}'." }
if (_context is MainActivity) {
if (_context.handleUrl(link.url)) continue
if (timestampRegex.matches(link.url)) {
val tokens = link.url.split(':')
var time_s = -1L
when (tokens.size) {
2 -> time_s = tokens[0].toLong() * 60 + tokens[1].toLong()
3 -> time_s = tokens[0].toLong() * 3600 +
tokens[1].toLong() * 60 +
tokens[2].toLong()
}
val c = _context
if (c is MainActivity) {
c.lifecycleScope.launch(Dispatchers.IO) {
if (c.handleUrl(link.url)) {
return@launch
}
if (timestampRegex.matches(link.url)) {
val tokens = link.url.split(':')
var time_s = -1L
when (tokens.size) {
2 -> time_s = tokens[0].toLong() * 60 + tokens[1].toLong()
3 -> time_s = tokens[0].toLong() * 3600 +
tokens[1].toLong() * 60 +
tokens[2].toLong()
}
if (time_s != -1L) {
if (time_s != -1L) {
withContext(Dispatchers.Main) {
MediaControlReceiver.onSeekToReceived.emit(time_s * 1000)
continue
}
return@launch
}
}
_context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
withContext(Dispatchers.Main) {
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
}
}
}
}
pressedLinks = null
linkPressed = false
return true
@@ -156,6 +156,8 @@ class StateApp {
return thisContext;
}
private var _mainId: String? = null;
//Files
private var _tempDirectory: File? = null;
private var _cacheDirectory: File? = null;
@@ -295,9 +297,12 @@ class StateApp {
}
//Lifecycle
fun setGlobalContext(context: Context, coroutineScope: CoroutineScope? = null) {
fun setGlobalContext(context: Context, coroutineScope: CoroutineScope? = null, mainId: String? = null) {
_mainId = mainId;
_context = context;
_scope = coroutineScope
Logger.w(TAG, "Scope initialized ${(coroutineScope != null)}\n ${Log.getStackTraceString(Throwable())}")
}
fun initializeFiles(force: Boolean = false) {
@@ -719,7 +724,9 @@ class StateApp {
migrateStores(context, managedStores, index + 1);
}
fun mainAppDestroyed(context: Context) {
fun mainAppDestroyed(context: Context, mainId: String? = null) {
if (mainId != null && (_mainId != mainId || _mainId == null))
return
Logger.i(TAG, "App ended");
_receiverBecomingNoisy?.let {
_receiverBecomingNoisy = null;
@@ -743,7 +750,8 @@ class StateApp {
fun dispose(){
_context = null;
_scope = null;
// _scope = null;
Logger.w(TAG, "StateApp disposed: ${Log.getStackTraceString(Throwable())}")
}
private val _connectivityEvents = object : ConnectivityManager.NetworkCallback() {
@@ -383,7 +383,7 @@ class StateDownloads {
}
private fun validateDownload(videoState: VideoDownload) {
if(_downloading.hasItem { it.videoEither.url == videoState.videoEither.url })
throw IllegalStateException("Video [${videoState.name}] is already queued for dowload");
throw IllegalStateException("Video [${videoState.name}] is already queued for download");
val existing = getCachedVideo(videoState.id);
if(existing != null) {
@@ -131,8 +131,13 @@ class StateHistory {
fun getHistoryPager(): IPager<HistoryVideo> {
return _historyDBStore.getObjectPager();
}
fun getHistorySearchPager(query: String): IPager<HistoryVideo> {
return _historyDBStore.queryLikeObjectPager(DBHistory.Index::name, "%${query}%", 10);
fun getHistorySearchPager(query: String, withAuthor: Boolean = false): IPager<HistoryVideo> {
return if(!withAuthor)
_historyDBStore.queryLikeObjectPager(DBHistory.Index::name, "%${query}%", 10)
else
_historyDBStore.queryLikeObjectPager(DBHistory.Index::name, "%${query}%", 10)
//_historyDBStore.queryLike2ObjectPager(DBHistory.Index::name, DBHistory.Index::auth,"%${query}%", 10)
//TODO: See if we can include author name?
}
fun getHistoryIndexByUrl(url: String): DBHistory.Index? {
return historyIndex[url];
@@ -5,7 +5,6 @@ import androidx.collection.LruCache
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.IPlatformClient
import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.PlatformMultiClientPool
@@ -46,7 +45,6 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.views.ToastView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
@@ -56,7 +54,6 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.internal.concat
import java.lang.Thread.sleep
import java.time.OffsetDateTime
import kotlin.streams.asSequence
@@ -669,7 +666,7 @@ class StatePlatform {
//Video
fun hasEnabledVideoClient(url: String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) };
fun hasEnabledContentClient(url: String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) };
fun getContentClient(url: String) : IPlatformClient = getContentClientOrNull(url)
?: throw NoPlatformClientException("No client enabled that supports this content url (${url})");
fun getContentClientOrNull(url: String) : IPlatformClient? = getEnabledClients().find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) };
@@ -423,17 +423,25 @@ class StatePlaylists {
class PlaylistBackup: ReconstructStore<Playlist>() {
override fun toReconstruction(obj: Playlist): String {
val items = ArrayList<String>();
items.add(obj.name);
items.add(obj.name + ":::" + obj.id);
items.addAll(obj.videos.map { it.url });
return items.map { it.replace("\n","") }.joinToString("\n");
}
override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder, importCache: ImportCache?): Playlist {
var idToUse = id;
val items = backup.split("\n");
if(items.size <= 0) {
throw IllegalStateException("Cannot reconstructor playlist ${id}");
}
val name = items[0];
var name = items[0];
if(name.contains(":::")){
val splitIndex = name.indexOf(":::");
val foundId = name.substring(splitIndex + 3);
if(!foundId.isNullOrEmpty())
idToUse = foundId;
name = name.substring(0, splitIndex);
}
val videos = items.drop(1).filter { it.isNotEmpty() }.map {
try {
val videoUrl = it;
@@ -465,7 +473,7 @@ class StatePlaylists {
throw ReconstructionException(name, "${name}:[${it}] ${ex.message}", ex);
}
}.filter { it != null }.map { it!! }
return Playlist(id, name, videos);
return Playlist(idToUse, name, videos);
}
}
}
@@ -329,8 +329,19 @@ class StateSubscriptions {
}
}
if(StateSubscriptionGroups.instance.hasSubscriptionGroup(sub.channel.url))
getSubscriptionOtherOrCreate(sub.channel.url, sub.channel.name, sub.channel.thumbnail);
if(StateSubscriptionGroups.instance.hasSubscriptionGroup(sub.channel.url)) {
val subGroups = StateSubscriptionGroups.instance.getSubscriptionGroups().filter { it.urls.contains(sub.channel.url) };
for(group in subGroups) {
group.urls.remove(sub.channel.url);
StateSubscriptionGroups.instance.updateSubscriptionGroup(group);
}
/*
getSubscriptionOtherOrCreate(
sub.channel.url,
sub.channel.name,
sub.channel.thumbnail
); */
}
}
return sub;
}
@@ -84,16 +84,20 @@ class StateSync {
onUnauthorized = { sess ->
StateApp.instance.scope.launch(Dispatchers.Main) {
UIDialogs.showConfirmationDialog(
context,
"Device Unauthorized: ${sess.displayName}",
action = {
Logger.i(TAG, "${sess.remotePublicKey} unauthorized received")
removeAuthorizedDevice(sess.remotePublicKey)
deviceRemoved.emit(sess.remotePublicKey)
},
cancelAction = {}
)
try {
UIDialogs.showConfirmationDialog(
context,
"Device Unauthorized: ${sess.displayName}",
action = {
Logger.i(TAG, "${sess.remotePublicKey} unauthorized received")
removeAuthorizedDevice(sess.remotePublicKey)
deviceRemoved.emit(sess.remotePublicKey)
},
cancelAction = {}
)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to show unauthorized dialog.", e)
}
}
}
@@ -118,30 +122,38 @@ class StateSync {
if (scope != null && activity != null) {
scope.launch(Dispatchers.Main) {
UIDialogs.showConfirmationDialog(activity, "Allow connection from $remotePublicKey?",
action = {
scope.launch(Dispatchers.IO) {
try {
callback(true)
Logger.i(TAG, "Connection authorized for $remotePublicKey by confirmation")
try {
UIDialogs.showConfirmationDialog(
activity, "Allow connection from $remotePublicKey?",
action = {
scope.launch(Dispatchers.IO) {
try {
callback(true)
Logger.i(
TAG,
"Connection authorized for $remotePublicKey by confirmation"
)
activity.finish()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to send authorize", e)
activity.finish()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to send authorize", e)
}
}
},
cancelAction = {
scope.launch(Dispatchers.IO) {
try {
callback(false)
Logger.i(TAG, "$remotePublicKey unauthorized received")
} catch (e: Throwable) {
Logger.w(TAG, "Failed to send unauthorize", e)
}
}
}
},
cancelAction = {
scope.launch(Dispatchers.IO) {
try {
callback(false)
Logger.i(TAG, "$remotePublicKey unauthorized received")
} catch (e: Throwable) {
Logger.w(TAG, "Failed to send unauthorize", e)
}
}
}
)
)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to show authorized dialog.", e)
}
}
} else {
callback(false)
@@ -224,6 +236,11 @@ class StateSync {
}
}
private val _lockSubscriptions = Any();
private val _lockSubscriptionGroups = Any();
private val _lockPlaylists = Any();
private val _lockWatchlater = Any();
private fun handleData(session: SyncSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
val remotePublicKey = session.remotePublicKey
when (subOpcode) {
@@ -307,7 +324,9 @@ class StateSync {
data.get(dataBody);
val json = String(dataBody, Charsets.UTF_8);
val subPackage = Serializer.json.decodeFromString<SyncSubscriptionsPackage>(json);
handleSyncSubscriptionPackage(session, subPackage);
synchronized(_lockSubscriptions) {
handleSyncSubscriptionPackage(session, subPackage);
}
if(subPackage.subscriptions.size > 0) {
val newestSub = subPackage.subscriptions.maxOf { it.creationTime };
@@ -327,21 +346,23 @@ class StateSync {
val pack = Serializer.json.decodeFromString<SyncSubscriptionGroupsPackage>(json);
var lastSubgroupChange = OffsetDateTime.MIN;
for(group in pack.groups){
if(group.lastChange > lastSubgroupChange)
lastSubgroupChange = group.lastChange;
synchronized(_lockSubscriptionGroups) {
for(group in pack.groups){
if(group.lastChange > lastSubgroupChange)
lastSubgroupChange = group.lastChange;
val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id);
val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id);
if(existing == null)
StateSubscriptionGroups.instance.updateSubscriptionGroup(group, false, true);
else if(existing.lastChange < group.lastChange) {
existing.name = group.name;
existing.urls = group.urls;
existing.image = group.image;
existing.priority = group.priority;
existing.lastChange = group.lastChange;
StateSubscriptionGroups.instance.updateSubscriptionGroup(existing, false, true);
if(existing == null)
StateSubscriptionGroups.instance.updateSubscriptionGroup(group, false, true);
else if(existing.lastChange < group.lastChange) {
existing.name = group.name;
existing.urls = group.urls;
existing.image = group.image;
existing.priority = group.priority;
existing.lastChange = group.lastChange;
StateSubscriptionGroups.instance.updateSubscriptionGroup(existing, false, true);
}
}
}
for(removal in pack.groupRemovals) {
@@ -358,18 +379,20 @@ class StateSync {
val json = String(dataBody, Charsets.UTF_8);
val pack = Serializer.json.decodeFromString<SyncPlaylistsPackage>(json);
for(playlist in pack.playlists) {
val existing = StatePlaylists.instance.getPlaylist(playlist.id);
synchronized(_lockPlaylists) {
for(playlist in pack.playlists) {
val existing = StatePlaylists.instance.getPlaylist(playlist.id);
if(existing == null)
StatePlaylists.instance.createOrUpdatePlaylist(playlist, false);
else if(existing.dateUpdate < playlist.dateUpdate) {
existing.dateUpdate = playlist.dateUpdate;
existing.name = playlist.name;
existing.videos = playlist.videos;
existing.dateCreation = playlist.dateCreation;
existing.datePlayed = playlist.datePlayed;
StatePlaylists.instance.createOrUpdatePlaylist(existing, false);
if(existing == null)
StatePlaylists.instance.createOrUpdatePlaylist(playlist, false);
else if(existing.dateUpdate < playlist.dateUpdate) {
existing.dateUpdate = playlist.dateUpdate;
existing.name = playlist.name;
existing.videos = playlist.videos;
existing.dateCreation = playlist.dateCreation;
existing.datePlayed = playlist.datePlayed;
StatePlaylists.instance.createOrUpdatePlaylist(existing, false);
}
}
}
for(removal in pack.playlistRemovals) {
@@ -390,14 +413,16 @@ class StateSync {
Logger.i(TAG, "SyncWatchLater received ${pack.videos.size} (${pack.videoAdds?.size}, ${pack.videoRemovals?.size})");
val allExisting = StatePlaylists.instance.getWatchLater();
for(video in pack.videos) {
val existing = allExisting.firstOrNull { it.url == video.url };
val time = if(pack.videoAdds != null && pack.videoAdds.containsKey(video.url)) (pack.videoAdds[video.url] ?: 0).sToOffsetDateTimeUTC() else OffsetDateTime.MIN;
val removalTime = StatePlaylists.instance.getWatchLaterRemovalTime(video.url) ?: OffsetDateTime.MIN;
if(existing == null && time > removalTime) {
StatePlaylists.instance.addToWatchLater(video, false);
if(time > OffsetDateTime.MIN)
StatePlaylists.instance.setWatchLaterAddTime(video.url, time);
synchronized(_lockWatchlater) {
for(video in pack.videos) {
val existing = allExisting.firstOrNull { it.url == video.url };
val time = if(pack.videoAdds != null && pack.videoAdds.containsKey(video.url)) (pack.videoAdds[video.url] ?: 0).sToOffsetDateTimeUTC() else OffsetDateTime.MIN;
val removalTime = StatePlaylists.instance.getWatchLaterRemovalTime(video.url) ?: OffsetDateTime.MIN;
if(existing == null && time > removalTime) {
StatePlaylists.instance.addToWatchLater(video, false);
if(time > OffsetDateTime.MIN)
StatePlaylists.instance.setWatchLaterAddTime(video.url, time);
}
}
}
for(removal in pack.videoRemovals) {
@@ -274,10 +274,17 @@ class ManagedDBStore<I: ManagedDBIndex<T>, T, D: ManagedDBDatabase<T, I, DA>, DA
val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} LIKE ? ${_orderSQL} LIMIT ? OFFSET ?";
val query = SimpleSQLiteQuery(queryStr, arrayOf(obj, pageSize, page * pageSize));
return deserializeIndexes(dbDaoBase.getMultiple(query));
}fun queryLike2Page(field: String, field2: String, obj: String, page: Int, pageSize: Int): List<I> {
val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} LIKE ? OR ${field2} LIKE ? ${_orderSQL} LIMIT ? OFFSET ?";
val query = SimpleSQLiteQuery(queryStr, arrayOf(obj, obj, pageSize, page * pageSize));
return deserializeIndexes(dbDaoBase.getMultiple(query));
}
fun queryLikeObjectPage(field: String, obj: String, page: Int, pageSize: Int): List<T> {
return convertObjects(queryLikePage(field, obj, page, pageSize));
}
fun queryLike2ObjectPage(field: String, field2: String, obj: String, page: Int, pageSize: Int): List<T> {
return convertObjects(queryLike2Page(field, field2, obj, page, pageSize));
}
//Query Page Objects
@@ -336,6 +343,13 @@ class ManagedDBStore<I: ManagedDBIndex<T>, T, D: ManagedDBDatabase<T, I, DA>, DA
queryLikePage(field, obj, it - 1, pageSize);
});
}
fun queryLike2Pager(field: KProperty<*>, field2: KProperty<*>, obj: String, pageSize: Int): IPager<I> = queryLike2Pager(validateFieldName(field), validateFieldName(field2), obj, pageSize);
fun queryLike2Pager(field: String, field2: String, obj: String, pageSize: Int): IPager<I> {
return AdhocPager({
Logger.i("ManagedDBStore", "Next Page [query: ${obj}](${it}) ${pageSize}");
queryLike2Page(field, field2, obj, it - 1, pageSize);
});
}
fun queryLikeObjectPager(field: KProperty<*>, obj: String, pageSize: Int): IPager<T> = queryLikeObjectPager(validateFieldName(field), obj, pageSize);
fun queryLikeObjectPager(field: String, obj: String, pageSize: Int): IPager<T> {
return AdhocPager({
@@ -344,6 +358,14 @@ class ManagedDBStore<I: ManagedDBIndex<T>, T, D: ManagedDBDatabase<T, I, DA>, DA
});
}
fun queryLike2ObjectPager(field: KProperty<*>, field2: KProperty<*>, obj: String, pageSize: Int): IPager<T> = queryLike2ObjectPager(validateFieldName(field), validateFieldName(field2), obj, pageSize);
fun queryLike2ObjectPager(field: String, field2: String, obj: String, pageSize: Int): IPager<T> {
return AdhocPager({
Logger.i("ManagedDBStore", "Next Page [query: ${obj}](${it}) ${pageSize}");
queryLike2ObjectPage(field, field2, obj, it - 1, pageSize);
});
}
//Query Pager with convert
fun <X> queryPager(field: KProperty<*>, obj: Any, pageSize: Int, convert: (I)->X): IPager<X> = queryPager(validateFieldName(field), obj, pageSize, convert);
fun <X> queryPager(field: String, obj: Any, pageSize: Int, convert: (I)->X): IPager<X> {
@@ -75,7 +75,7 @@ class ChannelRelayed(
private var handshakeState: HandshakeState? = if (initiator) {
HandshakeState(SyncService.protocolName, HandshakeState.INITIATOR).apply {
localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair)
remotePublicKey.setPublicKey(Base64.getDecoder().decode(publicKey), 0)
remotePublicKey.setPublicKey(publicKey.base64ToByteArray(), 0)
}
} else {
HandshakeState(SyncService.protocolName, HandshakeState.RESPONDER).apply {
@@ -177,7 +177,7 @@ class ChannelRelayed(
this.remoteVersion = remoteVersion
val remoteKeyBytes = ByteArray(handshakeState!!.remotePublicKey.publicKeyLength)
handshakeState!!.remotePublicKey.getPublicKey(remoteKeyBytes, 0)
this.remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes)
this.remotePublicKey = remoteKeyBytes.toBase64()
handshakeState?.destroy()
handshakeState = null
this.transport = transport
@@ -316,7 +316,7 @@ class ChannelRelayed(
val channelMessage = ByteArray(1024)
val channelBytesWritten = handshakeState!!.writeMessage(channelMessage, 0, null, 0, 0)
val publicKeyBytes = Base64.getDecoder().decode(publicKey)
val publicKeyBytes = publicKey.base64ToByteArray()
if (publicKeyBytes.size != 32) throw IllegalArgumentException("Public key must be 32 bytes")
val (pairingMessageLength, pairingMessage) = if (pairingCode != null) {
@@ -13,6 +13,7 @@ import com.futo.platformplayer.noise.protocol.DHState
import com.futo.platformplayer.noise.protocol.Noise
import com.futo.platformplayer.states.StateSync
import com.futo.polycentric.core.base64ToByteArray
import com.futo.polycentric.core.base64UrlToByteArray
import com.futo.polycentric.core.toBase64
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -98,7 +99,7 @@ class SyncService(
override fun onServiceLost(service: NsdServiceInfo) {
Log.e(TAG, "service lost: $service")
val urlSafePkey = service.attributes["pk"]?.decodeToString() ?: return
val pkey = Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')).toBase64()
val pkey = urlSafePkey.base64UrlToByteArray().toBase64()
synchronized(_mdnsCache) {
_mdnsCache.remove(pkey)
}
@@ -128,7 +129,7 @@ class SyncService(
}
val urlSafePkey = attributes.get("pk")?.decodeToString() ?: return
val pkey = Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')).toBase64()
val pkey = urlSafePkey.base64UrlToByteArray().toBase64()
val syncDeviceInfo = SyncDeviceInfo(pkey, adrs.map { it.hostAddress }.toTypedArray(), port, null)
synchronized(_mdnsCache) {
@@ -157,7 +158,7 @@ class SyncService(
override fun onServiceLost() {
Log.v(TAG, "onServiceLost: $service")
val urlSafePkey = service.attributes["pk"]?.decodeToString() ?: return
val pkey = Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')).toBase64()
val pkey = urlSafePkey.base64UrlToByteArray().toBase64()
synchronized(_mdnsCache) {
_mdnsCache.remove(pkey)
}
@@ -327,7 +328,7 @@ class SyncService(
val now = System.currentTimeMillis()
synchronized(_mdnsCache) {
for ((pkey, info) in _mdnsCache) {
if (!database.isAuthorized(pkey) || isConnected(pkey)) continue
if (!database.isAuthorized(pkey) || getLinkType(pkey) == LinkType.Direct) continue
val last = synchronized(_lastConnectTimesMdns) {
_lastConnectTimesMdns[pkey] ?: 0L
@@ -359,8 +360,8 @@ class SyncService(
while (_started) {
val authorizedDevices = database.getAllAuthorizedDevices() ?: arrayOf()
val addressesToConnect = authorizedDevices.mapNotNull {
val connected = isConnected(it)
if (connected) {
val connectedDirectly = getLinkType(it) == LinkType.Direct
if (connectedDirectly) {
return@mapNotNull null
}
@@ -467,8 +468,13 @@ class SyncService(
while (_started && !socketClosed) {
val unconnectedAuthorizedDevices =
database.getAllAuthorizedDevices()
?.filter { !isConnected(it) }?.toTypedArray()
?: arrayOf()
?.filter {
if (Settings.instance.synchronization.connectLocalDirectThroughRelay) {
getLinkType(it) != LinkType.Direct
} else {
!isConnected(it)
}
}?.toTypedArray() ?: arrayOf()
relaySession.publishConnectionInformation(
unconnectedAuthorizedDevices,
settings.listenerPort,
@@ -496,7 +502,7 @@ class SyncService(
val potentialLocalAddresses =
connectionInfo.ipv4Addresses
.filter { it != connectionInfo.remoteIp }
if (connectionInfo.allowLocalDirect && Settings.instance.synchronization.connectLocalDirectThroughRelay) {
if (getLinkType(targetKey) != LinkType.Direct && connectionInfo.allowLocalDirect && Settings.instance.synchronization.connectLocalDirectThroughRelay) {
Thread {
try {
Log.v(
@@ -528,7 +534,7 @@ class SyncService(
// TODO: Implement hole punching if needed
}
if (connectionInfo.allowRemoteRelayed && Settings.instance.synchronization.connectThroughRelay) {
if (!isConnected(targetKey) && connectionInfo.allowRemoteRelayed && Settings.instance.synchronization.connectThroughRelay) {
try {
Logger.v(
TAG,
@@ -740,6 +746,7 @@ class SyncService(
)
}
fun getLinkType(publicKey: String): LinkType = synchronized(_sessions) { _sessions[publicKey]?.linkType ?: LinkType.None }
fun isConnected(publicKey: String): Boolean = synchronized(_sessions) { _sessions[publicKey]?.connected ?: false }
fun isAuthorized(publicKey: String): Boolean = database.isAuthorized(publicKey)
fun getSession(publicKey: String): SyncSession? = synchronized(_sessions) { _sessions[publicKey] }
@@ -796,8 +803,12 @@ class SyncService(
_relaySession = null
_serverSocket?.close()
_serverSocket = null
synchronized(_sessions) {
_sessions.values.toList()
}.forEach { it.close() }
synchronized(_sessions) {
_sessions.values.forEach { it.close() }
_sessions.clear()
}
}
@@ -2,6 +2,7 @@ package com.futo.platformplayer.sync.internal
import android.os.Build
import com.futo.platformplayer.ensureNotMainThread
import com.futo.platformplayer.findCandidateAddresses
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.noise.protocol.CipherStatePair
import com.futo.platformplayer.noise.protocol.DHState
@@ -123,7 +124,7 @@ class SyncSocketSession {
val localPublicKey = ByteArray(localKeyPair.publicKeyLength)
localKeyPair.getPublicKey(localPublicKey, 0)
_localPublicKey = Base64.getEncoder().encodeToString(localPublicKey)
_localPublicKey = localPublicKey.toBase64()
}
fun startAsInitiator(remotePublicKey: String, appId: UInt = 0u, pairingCode: String? = null) {
@@ -253,14 +254,14 @@ class SyncSocketSession {
val initiator = HandshakeState(SyncService.protocolName, HandshakeState.INITIATOR)
initiator.localKeyPair.copyFrom(_localKeyPair)
initiator.remotePublicKey.setPublicKey(Base64.getDecoder().decode(remotePublicKey), 0)
initiator.remotePublicKey.setPublicKey(remotePublicKey.base64ToByteArray(), 0)
initiator.start()
val pairingMessage: ByteArray
val pairingMessageLength: Int
if (pairingCode != null) {
val pairingHandshake = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.INITIATOR)
pairingHandshake.remotePublicKey.setPublicKey(Base64.getDecoder().decode(remotePublicKey), 0)
pairingHandshake.remotePublicKey.setPublicKey(remotePublicKey.base64ToByteArray(), 0)
pairingHandshake.start()
val pairingCodeBytes = pairingCode.toByteArray(Charsets.UTF_8)
val pairingBuffer = ByteArray(512)
@@ -299,7 +300,7 @@ class SyncSocketSession {
_cipherStatePair = initiator.split()
val remoteKeyBytes = ByteArray(initiator.remotePublicKey.publicKeyLength)
initiator.remotePublicKey.getPublicKey(remoteKeyBytes, 0)
_remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes).base64ToByteArray().toBase64()
_remotePublicKey = remoteKeyBytes.toBase64()
}
private fun handshakeAsResponder(): Boolean {
@@ -516,7 +517,7 @@ class SyncSocketSession {
return
}
val channelHandshakeMessage = ByteArray(channelMessageLength).also { data.get(it) }
val publicKey = Base64.getEncoder().encodeToString(publicKeyBytes)
val publicKey = publicKeyBytes.toBase64()
val pairingCode = if (pairingMessageLength > 0) {
val pairingProtocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.RESPONDER).apply {
localKeyPair.copyFrom(_localKeyPair)
@@ -671,7 +672,7 @@ class SyncSocketSession {
val records = mutableMapOf<String, Pair<ByteArray, Long>>()
repeat(recordCount) {
val publisherBytes = ByteArray(32).also { data.get(it) }
val publisher = Base64.getEncoder().encodeToString(publisherBytes)
val publisher = publisherBytes.toBase64()
val blobLength = data.int
val encryptedBlob = ByteArray(blobLength).also { data.get(it) }
val timestamp = data.long
@@ -712,7 +713,7 @@ class SyncSocketSession {
val numResponses = data.get().toInt()
val result = mutableMapOf<String, ConnectionInfo>()
repeat(numResponses) {
val publicKey = Base64.getEncoder().encodeToString(ByteArray(32).also { data.get(it) })
val publicKey = ByteArray(32).also { data.get(it) }.toBase64()
val status = data.get().toInt()
if (status == 0) {
val infoSize = data.int
@@ -813,7 +814,7 @@ class SyncSocketSession {
return
}
val decryptedPayload = channel.decrypt(data)
val errorCode = SyncErrorCode.entries.find { it.value == decryptedPayload.int } ?: SyncErrorCode.ConnectionClosed
val errorCode = decryptedPayload.int
Logger.e(TAG, "Received relayed error (errorCode = $errorCode) on connectionId $connectionId, closing")
channel.close()
_channels.remove(connectionId)
@@ -824,7 +825,7 @@ class SyncSocketSession {
return
}
val connectionId = data.long
val errorCode = SyncErrorCode.entries.find { it.value == data.int } ?: SyncErrorCode.ConnectionClosed
val errorCode = data.int
val channel = _channels[connectionId] ?: run {
Logger.e(TAG, "Received error code $errorCode for non-existent channel with connectionId $connectionId")
return
@@ -994,7 +995,7 @@ class SyncSocketSession {
val deferred = CompletableDeferred<ConnectionInfo?>()
_pendingConnectionInfoRequests[requestId] = deferred
try {
val publicKeyBytes = Base64.getDecoder().decode(publicKey)
val publicKeyBytes = publicKey.base64ToByteArray()
if (publicKeyBytes.size != 32) throw IllegalArgumentException("Public key must be 32 bytes")
val packet = ByteBuffer.allocate(4 + 32).order(ByteOrder.LITTLE_ENDIAN)
packet.putInt(requestId)
@@ -1017,7 +1018,7 @@ class SyncSocketSession {
packet.putInt(requestId)
packet.put(publicKeys.size.toByte())
for (pk in publicKeys) {
val pkBytes = Base64.getDecoder().decode(pk)
val pkBytes = pk.base64ToByteArray()
if (pkBytes.size != 32) throw IllegalArgumentException("Invalid public key length for $pk")
packet.put(pkBytes)
}
@@ -1078,20 +1079,9 @@ class SyncSocketSession {
) {
if (authorizedKeys.size > 255) throw IllegalArgumentException("Number of authorized keys exceeds 255")
val ipv4Addresses = mutableListOf<String>()
val ipv6Addresses = mutableListOf<String>()
for (nic in NetworkInterface.getNetworkInterfaces()) {
if (nic.isUp) {
for (addr in nic.inetAddresses) {
if (!addr.isLoopbackAddress) {
when (addr) {
is Inet4Address -> ipv4Addresses.add(addr.hostAddress)
is Inet6Address -> ipv6Addresses.add(addr.hostAddress)
}
}
}
}
}
val candidateAddresses = findCandidateAddresses()
val ipv4Addresses = candidateAddresses.filterIsInstance<Inet4Address>()
val ipv6Addresses = candidateAddresses.filterIsInstance<Inet6Address>()
val deviceName = getDeviceName()
val nameBytes = getLimitedUtf8Bytes(deviceName, 255)
@@ -1103,12 +1093,12 @@ class SyncSocketSession {
data.put(nameBytes)
data.put(ipv4Addresses.size.toByte())
for (addr in ipv4Addresses) {
val addrBytes = InetAddress.getByName(addr).address
val addrBytes = addr.address
data.put(addrBytes)
}
data.put(ipv6Addresses.size.toByte())
for (addr in ipv6Addresses) {
val addrBytes = InetAddress.getByName(addr).address
val addrBytes = addr.address
data.put(addrBytes)
}
data.put(if (allowLocalDirect) 1 else 0)
@@ -1125,7 +1115,7 @@ class SyncSocketSession {
publishBytes.put(authorizedKeys.size.toByte())
for (key in authorizedKeys) {
val publicKeyBytes = Base64.getDecoder().decode(key)
val publicKeyBytes = key.base64ToByteArray()
if (publicKeyBytes.size != 32) throw IllegalArgumentException("Public key must be 32 bytes")
val protocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.INITIATOR)
@@ -1183,7 +1173,7 @@ class SyncSocketSession {
packet.put(consumerPublicKeys.size.toByte())
for (consumer in consumerPublicKeys) {
val consumerBytes = Base64.getDecoder().decode(consumer)
val consumerBytes = consumer.base64ToByteArray()
if (consumerBytes.size != 32) throw IllegalArgumentException("Consumer public key must be 32 bytes")
packet.put(consumerBytes)
val protocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.INITIATOR).apply {
@@ -1222,7 +1212,7 @@ class SyncSocketSession {
val deferred = CompletableDeferred<Pair<ByteArray, Long>?>()
_pendingGetRecordRequests[requestId] = deferred
try {
val publisherBytes = Base64.getDecoder().decode(publisherPublicKey)
val publisherBytes = publisherPublicKey.base64ToByteArray()
if (publisherBytes.size != 32) throw IllegalArgumentException("Publisher public key must be 32 bytes")
val keyBytes = key.toByteArray(Charsets.UTF_8)
val packet = ByteBuffer.allocate(4 + 32 + 1 + keyBytes.size).order(ByteOrder.LITTLE_ENDIAN)
@@ -1253,7 +1243,7 @@ class SyncSocketSession {
packet.put(keyBytes)
packet.put(publisherPublicKeys.size.toByte())
for (publisher in publisherPublicKeys) {
val bytes = Base64.getDecoder().decode(publisher)
val bytes = publisher.base64ToByteArray()
if (bytes.size != 32) throw IllegalArgumentException("Publisher public key must be 32 bytes")
packet.put(bytes)
}
@@ -1272,9 +1262,9 @@ class SyncSocketSession {
val deferred = CompletableDeferred<Boolean>()
_pendingDeleteRequests[requestId] = deferred
try {
val publisherBytes = Base64.getDecoder().decode(publisherPublicKey)
val publisherBytes = publisherPublicKey.base64ToByteArray()
if (publisherBytes.size != 32) throw IllegalArgumentException("Publisher public key must be 32 bytes")
val consumerBytes = Base64.getDecoder().decode(consumerPublicKey)
val consumerBytes = consumerPublicKey.base64ToByteArray()
if (consumerBytes.size != 32) throw IllegalArgumentException("Consumer public key must be 32 bytes")
val packetSize = 4 + 32 + 32 + 1 + keys.sumOf { 1 + it.toByteArray(Charsets.UTF_8).size }
val packet = ByteBuffer.allocate(packetSize).order(ByteOrder.LITTLE_ENDIAN)
@@ -1301,9 +1291,9 @@ class SyncSocketSession {
val deferred = CompletableDeferred<List<Pair<String, Long>>>()
_pendingListKeysRequests[requestId] = deferred
try {
val publisherBytes = Base64.getDecoder().decode(publisherPublicKey)
val publisherBytes = publisherPublicKey.base64ToByteArray()
if (publisherBytes.size != 32) throw IllegalArgumentException("Publisher public key must be 32 bytes")
val consumerBytes = Base64.getDecoder().decode(consumerPublicKey)
val consumerBytes = consumerPublicKey.base64ToByteArray()
if (consumerBytes.size != 32) throw IllegalArgumentException("Consumer public key must be 32 bytes")
val packet = ByteBuffer.allocate(4 + 32 + 32).order(ByteOrder.LITTLE_ENDIAN)
packet.putInt(requestId)
@@ -10,10 +10,11 @@ class ItemMoveCallback : ItemTouchHelper.Callback {
var onRowMoved = Event2<Int, Int>();
var onRowSelected = Event1<ViewHolder>();
var onRowClear = Event1<ViewHolder>();
var canEdit = true
constructor() : super() { }
override fun isLongPressDragEnabled(): Boolean { return true; }
override fun isLongPressDragEnabled(): Boolean { return canEdit; }
override fun isItemViewSwipeEnabled(): Boolean { return false; }
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
@@ -126,7 +126,7 @@ open class PlaylistView : LinearLayout {
}
else {
currentPlaylist = null;
_imageThumbnail.setImageResource(0);
_imageThumbnail.setImageDrawable(null);
}
}
@@ -31,10 +31,11 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
updateDataset();
}
constructor(inflater: LayoutInflater, confirmationMessage: String, onDatasetChanged: ((List<Subscription>)->Unit)? = null) : super() {
constructor(inflater: LayoutInflater, confirmationMessage: String, sortByDefault: Int, onDatasetChanged: ((List<Subscription>)->Unit)? = null) : super() {
_inflater = inflater;
_confirmationMessage = confirmationMessage;
_onDatasetChanged = onDatasetChanged;
sortBy = sortByDefault
StateSubscriptions.instance.onSubscriptionsChanged.subscribe { _, _ -> if(Looper.myLooper() != Looper.getMainLooper())
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { updateDataset() }
@@ -1,6 +1,7 @@
package com.futo.platformplayer.views.adapters
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
@@ -79,6 +79,8 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
return when(contentType) {
ContentType.PLACEHOLDER -> createPlaceholderViewHolder(viewGroup);
ContentType.MEDIA -> createVideoPreviewViewHolder(viewGroup);
ContentType.ARTICLE -> createPostViewHolder(viewGroup);
ContentType.WEB -> createPostViewHolder(viewGroup);
ContentType.POST -> createPostViewHolder(viewGroup);
ContentType.PLAYLIST -> createPlaylistViewHolder(viewGroup);
ContentType.NESTED_VIDEO -> createNestedViewHolder(viewGroup);
@@ -76,8 +76,8 @@ class PreviewLockedView : LinearLayout {
_textLockedUrl.text = content.unlockUrl ?: "";
}
else {
_imageChannelThumbnail.setImageResource(0);
_imageVideoThumbnail.setImageResource(0);
_imageChannelThumbnail.setImageDrawable(null);
_imageVideoThumbnail.setImageDrawable(null);
_textLockedDescription.text = "";
_textLockedUrl.text = "";
}
@@ -21,9 +21,11 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails
import com.futo.platformplayer.api.media.platforms.js.models.JSWeb
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp
@@ -141,6 +143,16 @@ class PreviewPostView : LinearLayout {
content.content
else
""
} else if(content is IPlatformArticle) {
if(!content.summary.isNullOrEmpty())
content.summary ?: ""
else
""
} else if(content is JSWeb) {
if(!content.url.isNullOrEmpty())
"WEB:" + content.url
else
""
} else "";
if (content.name.isNullOrEmpty()) {
@@ -154,7 +166,14 @@ class PreviewPostView : LinearLayout {
if (content is IPlatformPost) {
setImages(content.thumbnails.filterNotNull());
} else {
}
else if(content is IPlatformArticle) {
if(content.thumbnails != null)
setImages(listOf(content.thumbnails!!));
else
setImages(null);
}
else {
setImages(null);
}
@@ -233,7 +233,7 @@ open class PreviewVideoView : LinearLayout {
}
else {
currentVideo = null;
_imageVideo.setImageResource(0);
_imageVideo.setImageDrawable(null);
_containerDuration.visibility = GONE;
_containerLive.visibility = GONE;
_timeBar?.visibility = GONE;
@@ -47,7 +47,7 @@ class SubscriptionGroupBarViewHolder(private val _viewGroup: ViewGroup) : AnyAda
if(img != null) {
img.setImageView(_image)
} else {
_image.setImageResource(0);
_image.setImageDrawable(null);
if(value is SubscriptionGroup.Add)
_image.setBackgroundColor(Color.DKGRAY);
@@ -88,7 +88,7 @@ class SubscriptionGroupListViewHolder(private val _viewGroup: ViewGroup) : AnyAd
if(img != null)
img.setImageView(_image)
else {
_image.setImageResource(0);
_image.setImageDrawable(null);
if(value is SubscriptionGroup.Add)
_image.setBackgroundColor(Color.DKGRAY);
@@ -39,6 +39,9 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.Locale
class GestureControlView : LinearLayout {
@@ -79,6 +82,9 @@ class GestureControlView : LinearLayout {
private var _adjustingFullscreenDown: Boolean = false;
private var _fullScreenFactorUp = 1.0f;
private var _fullScreenFactorDown = 1.0f;
private val _layoutHoldSpeed: LinearLayout
private val _textHoldFastForward: TextView
private val _imageHoldFastForward: ImageView
private var _scaleGestureDetector: ScaleGestureDetector
private var _scaleFactor = 1.0f
@@ -92,6 +98,11 @@ class GestureControlView : LinearLayout {
private var _surfaceView: View? = null
private var _layoutIndicatorFill: FrameLayout;
private var _layoutIndicatorFit: FrameLayout;
private var _speedHolding = false
private val _speedFormatter = DecimalFormat("#.##", DecimalFormatSymbols(Locale.US)).apply {
roundingMode = java.math.RoundingMode.HALF_UP
}
private val _gestureController: GestureDetectorCompat;
@@ -103,6 +114,8 @@ class GestureControlView : LinearLayout {
val onZoom = Event1<Float>();
val onSoundAdjusted = Event1<Float>();
val onToggleFullscreen = Event0();
val onSpeedHoldStart = Event0()
val onSpeedHoldEnd = Event0()
var fullScreenGestureEnabled = true
@@ -124,6 +137,9 @@ class GestureControlView : LinearLayout {
_layoutControlsFullscreen = findViewById(R.id.layout_controls_fullscreen);
_layoutIndicatorFill = findViewById(R.id.layout_indicator_fill);
_layoutIndicatorFit = findViewById(R.id.layout_indicator_fit);
_layoutHoldSpeed = findViewById(R.id.layout_controls_increased_speed)
_textHoldFastForward = findViewById(R.id.text_holdFastForward)
_imageHoldFastForward = findViewById(R.id.image_holdFastForward)
_scaleGestureDetector = ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean {
@@ -216,7 +232,20 @@ class GestureControlView : LinearLayout {
return true;
}
override fun onLongPress(p0: MotionEvent) = Unit
override fun onLongPress(p0: MotionEvent) {
if (!_isControlsLocked
&& !_skipping
&& !_adjustingBrightness
&& !_adjustingSound
&& !_adjustingFullscreenUp
&& !_adjustingFullscreenDown
&& !_isPanning
&& !_isZooming) {
_speedHolding = true
showHoldSpeedControls()
onSpeedHoldStart.emit()
}
}
});
_gestureController.setOnDoubleTapListener(object : GestureDetector.OnDoubleTapListener {
@@ -301,6 +330,17 @@ class GestureControlView : LinearLayout {
onPan.emit(_translationX, _translationY)
}
private fun showHoldSpeedControls() {
_layoutHoldSpeed.visibility = View.VISIBLE
_textHoldFastForward.text = _speedFormatter.format(Settings.instance.playback.getHoldPlaybackSpeed()) + "x"
(_imageHoldFastForward.drawable as? Animatable)?.start()
}
private fun hideHoldSpeedControls() {
_layoutHoldSpeed.visibility = View.GONE
(_imageHoldFastForward.drawable as? Animatable)?.stop()
}
fun setupTouchArea(layoutControls: ViewGroup? = null, background: View? = null) {
_layoutControls = layoutControls;
_background = background;
@@ -309,6 +349,12 @@ class GestureControlView : LinearLayout {
override fun onTouchEvent(event: MotionEvent?): Boolean {
val ev = event ?: return super.onTouchEvent(event);
if (ev.action == MotionEvent.ACTION_UP && _speedHolding) {
_speedHolding = false
hideHoldSpeedControls()
onSpeedHoldEnd.emit()
}
cancelHideJob();
if (_skipping) {
@@ -8,12 +8,16 @@ import android.text.Spannable
import android.text.style.URLSpan
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.PlatformLinkMovementMethod
import com.futo.platformplayer.receivers.MediaControlReceiver
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.timestampRegex
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class NonScrollingTextView : androidx.appcompat.widget.AppCompatTextView {
private var _lastTouchedLinks: Array<URLSpan>? = null
@@ -77,12 +81,14 @@ class NonScrollingTextView : androidx.appcompat.widget.AppCompatTextView {
val dx = event.x - downX
val dy = event.y - downY
if (Math.abs(dx) <= touchSlop && Math.abs(dy) <= touchSlop && isTouchInside(event)) {
runBlocking {
for (link in _lastTouchedLinks!!) {
Logger.i(PlatformLinkMovementMethod.TAG) { "Link clicked '${link.url}'." }
val c = context
if (c is MainActivity) {
if (c.handleUrl(link.url)) continue
for (link in _lastTouchedLinks!!) {
Logger.i(PlatformLinkMovementMethod.TAG) { "Link clicked '${link.url}'." }
val c = context
if (c is MainActivity) {
c.lifecycleScope.launch(Dispatchers.IO) {
if (c.handleUrl(link.url)) {
return@launch
}
if (timestampRegex.matches(link.url)) {
val tokens = link.url.split(':')
var time_s = -1L
@@ -92,13 +98,21 @@ class NonScrollingTextView : androidx.appcompat.widget.AppCompatTextView {
tokens[1].toLong() * 60 +
tokens[2].toLong()
}
if (time_s != -1L) {
MediaControlReceiver.onSeekToReceived.emit(time_s * 1000)
continue
withContext(Dispatchers.Main) {
MediaControlReceiver.onSeekToReceived.emit(time_s * 1000)
}
return@launch
}
}
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
} else {
withContext(Dispatchers.Main) {
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
}
}
} else {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
}
}
@@ -18,13 +18,17 @@ import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.TimeBar
import com.bumptech.glide.Glide
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.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.states.StatePlayer
import com.futo.platformplayer.views.behavior.GestureControlView
import kotlinx.coroutines.CoroutineScope
@@ -55,12 +59,15 @@ class CastView : ConstraintLayout {
private var _inPictureInPicture: Boolean = false;
private var _chapters: List<IChapter>? = null;
private var _currentChapter: IChapter? = null;
private var _speedHoldPrevRate = 1.0
private var _speedHoldWasPlaying = false
val onChapterChanged = Event2<IChapter?, Boolean>();
val onMinimizeClick = Event0();
val onSettingsClick = Event0();
val onPrevious = Event0();
val onNext = Event0();
val onTimeJobTimeChanged_s = Event1<Long>()
@OptIn(UnstableApi::class)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
@@ -83,6 +90,20 @@ class CastView : ConstraintLayout {
_gestureControlView = findViewById(R.id.gesture_control);
_gestureControlView.fullScreenGestureEnabled = false
_gestureControlView.setupTouchArea();
_gestureControlView.onSpeedHoldStart.subscribe {
val d = StateCasting.instance.activeDevice ?: return@subscribe;
_speedHoldWasPlaying = d.isPlaying
_speedHoldPrevRate = d.speed
if (d.canSetSpeed)
d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed())
d.resumeVideo()
}
_gestureControlView.onSpeedHoldEnd.subscribe {
val d = StateCasting.instance.activeDevice ?: return@subscribe;
if (!_speedHoldWasPlaying) d.pauseVideo()
d.changeSpeed(_speedHoldPrevRate)
}
_gestureControlView.onSeek.subscribe {
val d = StateCasting.instance.activeDevice ?: return@subscribe;
StateCasting.instance.videoSeekTo(d.expectedCurrentTime + it / 1000);
@@ -185,11 +206,11 @@ class CastView : ConstraintLayout {
}
fun setIsPlaying(isPlaying: Boolean) {
_updateTimeJob?.cancel();
stopTimeJob()
if(isPlaying) {
val d = StateCasting.instance.activeDevice;
if (d is AirPlayCastingDevice) {
if (d is AirPlayCastingDevice || d is ChromecastCastingDevice) {
_updateTimeJob = _scope.launch {
while (true) {
val device = StateCasting.instance.activeDevice;
@@ -198,7 +219,9 @@ class CastView : ConstraintLayout {
}
delay(1000);
setTime((device.expectedCurrentTime * 1000.0).toLong());
val time_ms = (device.expectedCurrentTime * 1000.0).toLong()
setTime(time_ms);
onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong())
}
}
}
@@ -41,6 +41,8 @@ class ButtonField : BigButton, IField {
return null;
};
override var isAdvanced: Boolean = false;
//private val _title : TextView;
//private val _subtitle : TextView;
@@ -89,7 +91,7 @@ class ButtonField : BigButton, IField {
return this;
}
override fun fromField(obj : Any, field : Field, formField: FormField?) : ButtonField {
override fun fromField(obj : Any, field : Field, formField: FormField?, advanced: Boolean) : ButtonField {
throw IllegalStateException("ButtonField should only be used for methods");
}
override fun setField() {
@@ -40,6 +40,8 @@ class DropdownField : TableRow, IField {
override var reference: Any? = null;
override var isAdvanced: Boolean = false;
override val onChanged = Event3<IField, Any, Any>();
override val value: Any? get() = _selected;
@@ -112,7 +114,7 @@ class DropdownField : TableRow, IField {
return this;
}
override fun fromField(obj: Any, field: Field, formField: FormField?) : DropdownField {
override fun fromField(obj: Any, field: Field, formField: FormField?, advanced: Boolean) : DropdownField {
this._field = field;
this._obj = obj;
@@ -133,6 +135,9 @@ class DropdownField : TableRow, IField {
_description.visibility = View.GONE;
}
val advancedFieldAttr = field.getAnnotation(AdvancedField::class.java)
if(advancedFieldAttr != null || advanced)
isAdvanced = true;
_options = (field.getAnnotation(DropdownFieldOptions::class.java)?.options ?:
field.getAnnotation(DropdownFieldOptionsId::class.java)?.optionsId?.let { resources.getStringArray(it) } ?:
@@ -4,6 +4,10 @@ import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.Event3
import java.lang.reflect.Field
@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class AdvancedField();
@Target(AnnotationTarget.FIELD, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
@@ -22,6 +26,8 @@ interface IField {
val obj : Any?;
val field : Field?;
val isAdvanced: Boolean;
val value: Any?;
val onChanged : Event3<IField, Any, Any>;
@@ -29,7 +35,7 @@ interface IField {
val searchContent: String?;
fun fromField(obj : Any, field : Field, formField: FormField? = null) : IField;
fun fromField(obj : Any, field : Field, formField: FormField? = null, advanced: Boolean = false) : IField;
fun setField();
fun setValue(value: Any);
@@ -37,6 +37,8 @@ class FieldForm : LinearLayout {
private var _fields : List<IField> = arrayListOf();
private var _showAdvancedSettings: Boolean = false;
constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.field_form, this);
_containerSearch = findViewById(R.id.container_search);
@@ -58,11 +60,17 @@ class FieldForm : LinearLayout {
if(field is GroupField) {
updateSettingsVisibility(field);
} else if(field is View && field.descriptor != null) {
val txt = field.searchContent?.lowercase();
if(txt != null) {
val visible = isGroupMatch || txt.contains(query);
field.visibility = if (visible) View.VISIBLE else View.GONE;
groupVisible = groupVisible || visible;
if(field.isAdvanced && !_showAdvancedSettings)
{
field.visibility = View.GONE;
}
else {
val txt = field.searchContent?.lowercase();
if (txt != null) {
val visible = isGroupMatch || txt.contains(query);
field.visibility = if (visible) View.VISIBLE else View.GONE;
groupVisible = groupVisible || visible;
}
}
}
}
@@ -71,6 +79,10 @@ class FieldForm : LinearLayout {
}
}
fun setShowAdvancedSettings(show: Boolean) {
_showAdvancedSettings = show;
updateSettingsVisibility();
}
fun setSearchQuery(query: String) {
_editSearch.setText(query);
updateSettingsVisibility();
@@ -92,13 +104,22 @@ class FieldForm : LinearLayout {
throw java.lang.IllegalStateException("Only views can be IFields");
}
if(field is ToggleField && field.descriptor?.id == "advancedSettings") {
_showAdvancedSettings = field.value as Boolean;
}
_fieldsContainer.addView(field as View);
field.onChanged.subscribe { a1, a2, _ ->
if(field is ToggleField && field.descriptor?.id == "advancedSettings") {
setShowAdvancedSettings((a2 as Boolean));
}
onChanged.emit(a1, a2);
};
}
_fields = newFields;
updateSettingsVisibility();
onLoaded?.invoke();
}
}
@@ -267,10 +288,12 @@ class FieldForm : LinearLayout {
for(prop in objFields) {
prop.first.javaField!!.isAccessible = true;
val advanced = prop.first.hasAnnotation<AdvancedField>();
val field = when(prop.second.type) {
GROUP -> GroupField(context).fromField(obj, prop.first.javaField!!, prop.second);
DROPDOWN -> DropdownField(context).fromField(obj, prop.first.javaField!!, prop.second);
TOGGLE -> ToggleField(context).fromField(obj, prop.first.javaField!!, prop.second);
DROPDOWN -> DropdownField(context).fromField(obj, prop.first.javaField!!, prop.second, advanced);
TOGGLE -> ToggleField(context).fromField(obj, prop.first.javaField!!, prop.second, advanced);
READONLYTEXT -> ReadOnlyTextField(context).fromField(obj, prop.first.javaField!!, prop.second);
else -> throw java.lang.IllegalStateException("Unknown field type ${prop.second.type} for ${prop.second.title}")
}
@@ -34,6 +34,7 @@ class GroupField : LinearLayout, IField {
private val _container : LinearLayout;
override var reference: Any? = null;
override var isAdvanced: Boolean = false;
override val value: Any? = null;
@@ -100,7 +101,7 @@ class GroupField : LinearLayout, IField {
return this;
}
override fun fromField(obj: Any, field: Field, formField: FormField?) : GroupField {
override fun fromField(obj: Any, field: Field, formField: FormField?, advanced: Boolean) : GroupField {
this._field = field;
this._obj = obj;
@@ -31,6 +31,7 @@ class ReadOnlyTextField : TableRow, IField {
override val onChanged = Event3<IField, Any, Any>();
override var reference: Any? = null;
override var isAdvanced: Boolean = false;
override val value: Any? = null;
@@ -45,7 +46,7 @@ class ReadOnlyTextField : TableRow, IField {
override fun setValue(value: Any) {}
override fun fromField(obj : Any, field : Field, formField: FormField?) : ReadOnlyTextField {
override fun fromField(obj : Any, field : Field, formField: FormField?, advanced: Boolean) : ReadOnlyTextField {
this._field = field;
this._obj = obj;
@@ -33,6 +33,7 @@ class ToggleField : TableRow, IField {
private var _lastValue: Boolean = false;
override var reference: Any? = null;
override var isAdvanced: Boolean = false;
override val onChanged = Event3<IField, Any, Any>();
@@ -75,7 +76,7 @@ class ToggleField : TableRow, IField {
return this;
}
override fun fromField(obj : Any, field : Field, formField: FormField?) : ToggleField {
override fun fromField(obj : Any, field : Field, formField: FormField?, advanced: Boolean) : ToggleField {
this._field = field;
this._obj = obj;
@@ -87,6 +88,11 @@ class ToggleField : TableRow, IField {
else
_title.text = field.name;
val advancedFieldAttr = field.getAnnotation(AdvancedField::class.java)
if(advancedFieldAttr != null || advanced) {
isAdvanced = true;
}
if(attrField == null || attrField.subtitle == -1)
_description.visibility = View.GONE;
else {
@@ -26,6 +26,7 @@ class VideoListEditorView : FrameLayout {
val onVideoOptions = Event1<IPlatformVideo>();
val onVideoClicked = Event1<IPlatformVideo>();
val isEmpty get() = _videos.isEmpty();
val itemMoveCallback: ItemMoveCallback
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
val recyclerPlaylist = RecyclerView(context, attrs);
@@ -34,14 +35,14 @@ class VideoListEditorView : FrameLayout {
recyclerPlaylist.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
addView(recyclerPlaylist);
val callback = ItemMoveCallback();
val touchHelper = ItemTouchHelper(callback);
itemMoveCallback = ItemMoveCallback();
val touchHelper = ItemTouchHelper(itemMoveCallback);
val adapterVideos = VideoListEditorAdapter(touchHelper);
recyclerPlaylist.adapter = adapterVideos;
recyclerPlaylist.layoutManager = LinearLayoutManager(context);
touchHelper.attachToRecyclerView(recyclerPlaylist);
callback.onRowMoved.subscribe { fromPosition, toPosition ->
itemMoveCallback.onRowMoved.subscribe { fromPosition, toPosition ->
synchronized(_videos) {
if (fromPosition < toPosition) {
for (i in fromPosition until toPosition)
@@ -94,6 +95,7 @@ class VideoListEditorView : FrameLayout {
synchronized(_videos) {
_videos.clear();
_videos.addAll(videos ?: listOf());
itemMoveCallback.canEdit = canEdit
_adapterVideos?.setVideos(_videos, canEdit);
}
}
@@ -31,7 +31,7 @@ class SlideUpMenuButtonList : LinearLayout {
fun setButtons(texts: List<String>, activeText: String? = null) {
_root.removeAllViews();
val marginLeft = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3.0f, resources.displayMetrics).toInt();
val marginLeft = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1.5f, resources.displayMetrics).toInt();
val marginRight = marginLeft;
buttons.clear();
@@ -9,28 +9,28 @@ class PlatformIndicator : androidx.appcompat.widget.AppCompatImageView {
}
fun clearPlatform() {
setImageResource(0);
setImageDrawable(null);
}
fun setPlatformFromClientID(platformType : String?) {
if(platformType == null)
setImageResource(0);
setImageDrawable(null);
else {
val result = StatePlatform.instance.getPlatformIcon(platformType);
if (result != null)
result.setImageView(this);
else
setImageResource(0);
setImageDrawable(null);
}
}
fun setPlatformFromClientName(name: String?) {
if(name == null)
setImageResource(0);
setImageDrawable(null);
else {
val result = StatePlatform.instance.getPlatformIconByName(name);
if (result != null)
result.setImageView(this);
else
setImageResource(0);
setImageDrawable(null);
}
}
}
@@ -118,6 +118,9 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
private var _isControlsLocked: Boolean = false;
private var _speedHoldPrevRate = 1f
private var _speedHoldWasPlaying = false
private val _time_bar_listener: TimeBar.OnScrubListener;
var isFitMode : Boolean = false
@@ -255,6 +258,20 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
gestureControl = findViewById(R.id.gesture_control);
gestureControl.setupTouchArea(_layoutControls, background);
gestureControl.onSpeedHoldStart.subscribe {
exoPlayer?.player?.let { player ->
_speedHoldWasPlaying = player.isPlaying
_speedHoldPrevRate = getPlaybackRate()
setPlaybackRate(Settings.instance.playback.getHoldPlaybackSpeed().toFloat())
player.play()
}
}
gestureControl.onSpeedHoldEnd.subscribe {
exoPlayer?.player?.let { player ->
if (!_speedHoldWasPlaying) player.pause()
setPlaybackRate(_speedHoldPrevRate)
}
}
gestureControl.onSeek.subscribe { seekFromCurrent(it); };
gestureControl.onSoundAdjusted.subscribe {
if (Settings.instance.gestureControls.useSystemVolume) {
@@ -252,12 +252,22 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
fun switchToVideoMode() {
Logger.i(TAG, "Switching to Video Mode");
isAudioMode = false;
loadSelectedSources(playing, true);
val player = exoPlayer ?: return
player.player.trackSelectionParameters =
player.player.trackSelectionParameters
.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, isAudioMode)
.build()
}
fun switchToAudioMode() {
Logger.i(TAG, "Switching to Audio Mode");
isAudioMode = true;
loadSelectedSources(playing, true);
val player = exoPlayer ?: return
player.player.trackSelectionParameters =
player.player.trackSelectionParameters
.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, isAudioMode)
.build()
}
fun seekTo(ms: Long) {
@@ -0,0 +1,317 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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:orientation="vertical"
android:fitsSystemWindows="false"
android:background="@drawable/bottom_menu_border"
android:id="@+id/root"
android:clickable="true">
<LinearLayout
android:id="@+id/layout_top"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:layout_marginLeft="14dp"
android:layout_marginRight="14dp"
android:layout_marginTop="16dp">
<LinearLayout
android:id="@+id/layout_channel_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toLeftOf="@id/button_subscribe">
<com.futo.platformplayer.views.others.CreatorThumbnail
android:id="@+id/creator_thumbnail"
android:layout_width="27dp"
android:layout_height="27dp"
android:contentDescription="@string/cd_creator_thumbnail" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="6dp"
android:orientation="vertical">
<TextView
android:id="@+id/text_channel_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/white"
android:layout_gravity="center"
android:layout_marginTop="-4dp"
android:ellipsize="end"
android:maxLines="1"
tools:text="Channel Name" />
<TextView
android:id="@+id/text_channel_meta"
android:layout_width="match_parent"
android:layout_weight="1"
android:layout_height="wrap_content"
android:textColor="#ACACAC"
android:textSize="9sp"
android:layout_gravity="center"
android:ellipsize="end"
android:maxLines="1"
tools:text="" />
</LinearLayout>
</LinearLayout>
<com.futo.platformplayer.views.subscriptions.SubscribeButton
android:id="@+id/button_subscribe"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/text_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="24 Things I Wish I Had Done Sooner (or my biggest regrets)"
android:fontFamily="@font/inter_medium"
android:textColor="@color/white"
android:textSize="17sp"
android:textIsSelectable="true"
android:layout_marginTop="6dp"
android:layout_marginLeft="14dp"
android:layout_marginRight="14dp" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="14dp"
android:layout_marginRight="14dp"
android:layout_marginTop="4dp">
<TextView
android:id="@+id/text_meta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="51K views • 3 years ago"
android:fontFamily="@font/inter_regular"
android:textColor="@color/gray_ac"
android:textSize="10dp"
android:layout_gravity="center_vertical"/>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_gravity="end|center_vertical"
android:gravity="center_vertical">
<LinearLayout
android:id="@+id/layout_rating"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:gravity="center_vertical">
<ImageView
android:id="@+id/image_like_icon"
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@string/cd_image_like_icon"
app:srcCompat="@drawable/ic_thumb_up" />
<TextView
android:id="@+id/text_likes"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:layout_marginStart="8dp"
tools:text="500K"
android:textColor="@color/white"
android:textSize="10dp" />
<ImageView
android:id="@+id/image_dislike_icon"
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@string/cd_image_dislike_icon"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
app:srcCompat="@drawable/ic_thumb_down" />
<TextView
android:id="@+id/text_dislikes"
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center_vertical"
android:layout_marginStart="8dp"
tools:text="500K"
android:textColor="@color/white"
android:textSize="10dp" />
</LinearLayout>
<com.futo.platformplayer.views.platform.PlatformIndicator
android:id="@+id/platform_indicator"
android:layout_width="25dp"
android:layout_height="25dp"
android:scaleType="centerInside"
tools:src="@drawable/ic_peertube"
android:layout_marginStart="8dp"
app:layout_constraintTop_toTopOf="@id/image_author_thumbnail"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="@id/image_author_thumbnail" />
</LinearLayout>
</FrameLayout>
<TextView
android:id="@+id/text_summary"
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center_vertical"
android:layout_marginStart="14dp"
android:layout_marginEnd="5dp"
android:textFontWeight="400"
tools:text="This is the summary of the article"
android:textColor="@color/white"
android:textSize="13sp" />
<LinearLayout
android:id="@+id/container_segments"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="10dp">
<com.futo.platformplayer.views.pills.PillRatingLikesDislikes
android:id="@+id/rating"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="15dp" />
<Space android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<ImageButton
android:id="@+id/button_share"
android:layout_width="32dp"
android:layout_height="32dp"
android:contentDescription="@string/cd_button_share"
android:background="@drawable/background_button_round"
android:gravity="center"
android:layout_marginStart="5dp"
android:orientation="horizontal"
app:srcCompat="@drawable/ic_share"
app:tint="@color/white"
android:padding="8dp"
android:layout_marginEnd="15dp"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:scaleType="fitCenter" />
</LinearLayout>
<LinearLayout
android:id="@+id/layout_change_bottom_section"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="@drawable/background_videodetail_description"
android:layout_marginTop="10dp"
android:layout_marginBottom="10dp"
android:layout_marginLeft="14dp"
android:layout_marginRight="14dp">
<Button
android:id="@+id/button_polycentric"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="?android:attr/selectableItemBackground"
android:text="Polycentric"
android:textColor="#fff"
android:textSize="10dp"
android:lines="1"
android:ellipsize="marquee"
android:padding="10dp" />
<Button
android:id="@+id/button_platform"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="?android:attr/selectableItemBackground"
android:text="Platform"
android:textColor="#fff"
android:textSize="10dp"
android:lines="1"
android:ellipsize="marquee"
android:padding="10dp" />
</LinearLayout>
<com.futo.platformplayer.views.comments.AddCommentView
android:id="@+id/add_comment_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:layout_marginBottom="12dp"
android:layout_marginStart="28dp"
android:layout_marginEnd="28dp" />
</LinearLayout>
<com.futo.platformplayer.views.segments.CommentsList
android:id="@+id/comments_list"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<FrameLayout
android:id="@+id/layout_loading_overlay"
android:visibility="gone"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#77000000"
android:elevation="4dp">
<ImageView
android:id="@+id/image_loader"
android:layout_width="80dp"
android:layout_height="80dp"
app:srcCompat="@drawable/ic_loader_animated"
android:layout_gravity="top|center_horizontal"
android:alpha="0.7"
android:layout_marginTop="80dp"
android:contentDescription="@string/loading" />
</FrameLayout>
<com.futo.platformplayer.views.overlays.RepliesOverlay
android:id="@+id/replies_overlay"
android:visibility="gone"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<FrameLayout
android:id="@+id/overlay_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
android:elevation="15dp">
</FrameLayout>
</FrameLayout>
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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:orientation="vertical"
android:fitsSystemWindows="false"
android:background="@drawable/bottom_menu_border"
android:id="@+id/root"
android:clickable="true">
<WebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#111" />
<FrameLayout
android:id="@+id/layout_loading_overlay"
android:visibility="gone"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#77000000"
android:elevation="4dp">
<ImageView
android:id="@+id/image_loader"
android:layout_width="80dp"
android:layout_height="80dp"
app:srcCompat="@drawable/ic_loader_animated"
android:layout_gravity="top|center_horizontal"
android:alpha="0.7"
android:layout_marginTop="80dp"
android:contentDescription="@string/loading" />
</FrameLayout>
</FrameLayout>
+1 -1
View File
@@ -80,7 +80,7 @@
android:isScrollContainer="false"
android:textColor="#CCCCCC"
android:textSize="13sp"
android:maxLines="100"
android:maxLines="150"
app:layout_constraintTop_toBottomOf="@id/text_metadata"
app:layout_constraintLeft_toRightOf="@id/image_thumbnail"
app:layout_constraintRight_toRightOf="parent"
+3 -1
View File
@@ -19,6 +19,7 @@
android:scaleType="fitCenter"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:paddingStart="12dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="@id/layout_video_thumbnail"
app:layout_constraintBottom_toBottomOf="@id/layout_video_thumbnail" />
@@ -30,7 +31,8 @@
app:layout_constraintLeft_toRightOf="@id/image_drag_drop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintDimensionRatio="W,1.7:1" >
app:layout_constraintDimensionRatio="W,1.7:1"
android:layout_marginStart="12dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/image_video_thumbnail"
@@ -5,7 +5,7 @@
android:layout_marginTop="10dp"
android:id="@+id/root"
android:orientation="horizontal"
android:paddingStart="6dp"
android:paddingEnd="6dp">
android:paddingStart="0dp"
android:paddingEnd="0dp">
</LinearLayout>
@@ -1,6 +1,6 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="65dp"
android:layout_width="wrap_content"
android:layout_height="52dp"
android:orientation="vertical"
android:gravity="center"
@@ -9,14 +9,14 @@
<ImageView
android:id="@+id/image_button"
android:layout_width="25dp"
android:layout_width="wrap_content"
android:layout_height="25dp"
android:scaleType="fitCenter"
tools:srcCompat="@drawable/ic_sources"/>
<TextView
android:id="@+id/text_button"
android:layout_width="65dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="Home"
android:gravity="center"
@@ -195,4 +195,39 @@
app:layout_constraintEnd_toEndOf="parent"
android:visibility="gone"/>
<LinearLayout
android:id="@+id/layout_controls_increased_speed"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="@drawable/background_pill_black"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:layout_marginTop="20dp"
android:visibility="gone">
<TextView
android:id="@+id/text_holdFastForward"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="2x"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular" />
<ImageView
android:id="@+id/image_holdFastForward"
android:layout_width="wrap_content"
android:layout_height="8dp"
android:adjustViewBounds="true"
app:srcCompat="@drawable/ic_fastforward_animated"
android:layout_marginStart="4dp"/>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:paddingStart="7dp"
android:paddingEnd="7dp"
android:orientation="vertical"
android:id="@+id/root">
<ImageView
android:id="@+id/image_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_marginRight="10dp"
android:layout_marginTop="5dp"
android:layout_marginBottom="5dp" />
<TextView
android:id="@+id/text_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="center"
android:textSize="10sp"
android:textColor="#999"
android:layout_marginBottom="5dp" />
</LinearLayout>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:paddingStart="10dp"
android:paddingEnd="10dp"
android:id="@+id/root">
<TextView
android:id="@+id/text_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="12sp"
android:layout_weight="400"
android:textColor="#BBB"
android:layout_marginBottom="5dp"
android:layout_marginTop="5dp" />
</LinearLayout>
+43 -1
View File
@@ -12,6 +12,8 @@
<string name="channel">Channel</string>
<string name="home">Home</string>
<string name="progress_bar">Progress Bar</string>
<string name="advanced_settings">Advanced Settings</string>
<string name="advanced_settings_description">If advanced settings should be shown, this exposes additional settings to finetune your experience.</string>
<string name="progress_bar_description">If a historical progress bar should be shown</string>
<string name="hide_hidden_from_search">Hide hidden from home in search</string>
<string name="hide_hidden_from_search_description">Hide videos and creators hidden from home also in search results</string>
@@ -74,6 +76,8 @@
<string name="always_proxy_requests_description">Always proxy requests when casting data through the device.</string>
<string name="allow_ipv6">Allow IPV6</string>
<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="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>
@@ -425,9 +429,16 @@
<string name="delete_watchlist_on_finish">Delete from WatchLater when watched</string>
<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="seek_offset">Seek duration</string>
<string name="min_playback_speed">Minimum Playback Speed</string>
<string name="min_playback_speed_description">Minimum Available Speed</string>
<string name="max_playback_speed">Maximum Playback Speed</string>
<string name="max_playback_speed_description">Maximum Available Speed</string>
<string name="hold_playback_speed">Hold playback speed</string>
<string name="hold_playback_speed_description">Playback speed when pressing down on the video</string>
<string name="step_playback_speed">Playback Speed Step Size</string>
<string name="step_playback_speed_description">The step size of playback speeds, may not affect higher playback speeds.</string>
<string name="seek_offset_description">Fast-Forward / Fast-Rewind duration</string>
<string name="background_switch_audio">Switch to Audio in Background</string>
<string name="background_switch_audio_description">Optimize bandwidth usage by switching to audio-only stream in background if available, may cause stutter</string>
<string name="subscription_group_menu">Groups</string>
<string name="show_subscription_group">Show Subscription Groups</string>
<string name="use_subscription_exchange">Use Subscription Exchange (Experimental)</string>
@@ -441,6 +452,8 @@
<string name="show_home_filters_plugin_names_description">If home filters should show full plugin names or just icons</string>
<string name="log_level">Log Level</string>
<string name="logging">Logging</string>
<string name="license_status">License status</string>
<string name="view_license_status">View license status</string>
<string name="sync_grayjay">Sync Grayjay</string>
<string name="sync_grayjay_description">Sync your data across multiple devices</string>
<string name="manage_polycentric_identity">Manage Polycentric identity</string>
@@ -994,6 +1007,8 @@
<item>Download Date (Newest)</item>
<item>Release Date (Oldest)</item>
<item>Release Date (Newest)</item>
<item>Size (Smallest)</item>
<item>Size (Largest)</item>
</string-array>
<string-array name="playlists_sortby_array">
<item>Name (Ascending)</item>
@@ -1086,6 +1101,33 @@
<item>30 seconds</item>
<item>60 seconds</item>
</string-array>
<string-array name="max_playback_speed">
<item>2.0</item>
<item>2.25</item>
<item>3.0</item>
<item>4.0</item>
<item>5.0</item>
</string-array>
<string-array name="hold_playback_speeds">
<item>1.25</item>
<item>1.5</item>
<item>1.75</item>
<item>2.0</item>
<item>2.25</item>
<item>2.5</item>
<item>2.75</item>
<item>3.0</item>
</string-array>
<string-array name="min_playback_speed">
<item>0.25</item>
<item>0.5</item>
<item>1.0</item>
</string-array>
<string-array name="step_playback_speed">
<item>0.05</item>
<item>0.1</item>
<item>0.25</item>
</string-array>
<string-array name="rotation_zone">
<item>15</item>
<item>30</item>

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