mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 13:02:39 +02:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4164b1a3f8 | |||
| a9dc038190 | |||
| 2825db88a5 | |||
| 363099b303 | |||
| 5e25a5054f | |||
| 2bc6127f6b | |||
| 064824aedf | |||
| 52044edb2e | |||
| fb12073a82 | |||
| de39451f67 | |||
| 8f28653b28 | |||
| 389798457b | |||
| dd1c04bea1 | |||
| e6159117f6 | |||
| 0d9e1cd3c5 | |||
| 10753eb879 | |||
| 29aec21095 | |||
| a810f82ce2 | |||
| 2c454a0ec5 | |||
| d3dca00482 | |||
| d08dffd9e2 | |||
| 5b50ac926e | |||
| 57a3be35d0 | |||
| 70f36e69e6 | |||
| 8e70f1b865 | |||
| f86fb0ee44 | |||
| fe0aac7c6e | |||
| b93447f712 | |||
| 84a5103526 | |||
| c94c2721d7 | |||
| 0428c1191a | |||
| 8208f92802 | |||
| 3d33c4b8e0 | |||
| d3210ec12a | |||
| c959b973fc | |||
| 40c195d4a0 | |||
| f4f1470153 | |||
| 401999b5ea | |||
| 7b53315046 | |||
| 4d170db5e0 | |||
| fa8d175101 | |||
| cbef605f22 | |||
| cf95791dcc | |||
| 919567dbdb | |||
| 8ca317a38a | |||
| ccc686ed50 | |||
| e3e7b0c345 | |||
| 5b0f359944 | |||
| 29f1bef099 | |||
| 418f34c7e8 | |||
| 21c2ab21b2 | |||
| 1ace7318f3 | |||
| 48052b88db | |||
| 715c60dc6e | |||
| 8202513993 |
@@ -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.
|
||||
+5
-2
@@ -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)
|
||||
|
||||
+4
-1
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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,59 @@ 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.comments, "group", R.string.comments_description, 6)
|
||||
@@ -530,6 +599,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 +636,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 +671,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 +753,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 +958,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 +992,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 }
|
||||
|
||||
@@ -115,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
|
||||
|
||||
|
||||
@@ -184,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;
|
||||
|
||||
@@ -218,6 +219,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
val mainId = UUID.randomUUID().toString().substring(0, 5)
|
||||
|
||||
constructor() : super() {
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.setVmPolicy(
|
||||
@@ -269,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);
|
||||
@@ -671,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()
|
||||
@@ -686,7 +689,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
Logger.v(TAG, "_wasStopped = true");
|
||||
Logger.w(TAG, "onStop [$mainId]");
|
||||
_wasStopped = true;
|
||||
}
|
||||
|
||||
@@ -1103,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 {
|
||||
@@ -1184,7 +1187,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
if (segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory)
|
||||
fragBeforeOverlay = fragCurrent;
|
||||
|
||||
|
||||
fragCurrent = segment;
|
||||
_parameterCurrent = parameter;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -344,6 +344,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 +503,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 +608,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.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 + 1).toString());
|
||||
|
||||
val data = if(executor != null)
|
||||
executor.executeRequest("GET", url, null, mapOf());
|
||||
|
||||
+1
-1
@@ -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.")
|
||||
}
|
||||
|
||||
+2
-2
@@ -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>(withHistory = false) }))
|
||||
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
|
||||
@@ -396,7 +396,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
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);
|
||||
|
||||
+19
-13
@@ -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"
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -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
|
||||
};
|
||||
|
||||
+5
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+23
-12
@@ -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
|
||||
|
||||
+2
-2
@@ -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();
|
||||
|
||||
+8
-4
@@ -467,10 +467,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) {
|
||||
|
||||
+74
-9
@@ -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
|
||||
@@ -408,6 +410,14 @@ class VideoDetailView : ConstraintLayout {
|
||||
showChaptersUI();
|
||||
};
|
||||
|
||||
_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);
|
||||
@@ -619,6 +629,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
loadCurrentVideo(lastPositionMilliseconds);
|
||||
updatePillButtonVisibilities();
|
||||
setCastEnabled(false);
|
||||
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
@@ -647,6 +658,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;
|
||||
@@ -1105,7 +1125,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);
|
||||
}
|
||||
@@ -1389,8 +1409,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()
|
||||
@@ -1447,6 +1467,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 }
|
||||
@@ -2139,23 +2185,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(format, it) }.toMutableList();
|
||||
playbackLabels.add("+");
|
||||
playbackLabels.add(0, "-");
|
||||
|
||||
setButtons(playbackLabels, String.format(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("%.2f", Math.min((currentPlaybackSpeed?.toDouble() ?: 1.0) + stepSpeed, 5.0)).toString();
|
||||
else if(v == "-")
|
||||
playbackSpeedString = String.format("%.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("%.2f", newPlaybackSpeed)})");
|
||||
ad.changeSpeed(newPlaybackSpeed)
|
||||
setSelected(playbackSpeedString);
|
||||
} else {
|
||||
_player.setPlaybackRate(v.toFloat());
|
||||
setSelected(v);
|
||||
qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", newPlaybackSpeed)})");
|
||||
_player.setPlaybackRate(playbackSpeedString.toFloat());
|
||||
setSelected(playbackSpeedString);
|
||||
}
|
||||
};
|
||||
} else null,
|
||||
@@ -2512,7 +2575,9 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
|
||||
fun saveBrightness() {
|
||||
_player.gestureControl.saveBrightness()
|
||||
if (Settings.instance.gestureControls.useSystemBrightness) {
|
||||
_player.gestureControl.saveBrightness()
|
||||
}
|
||||
}
|
||||
fun restoreBrightness() {
|
||||
_player.gestureControl.restoreBrightness()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,13 @@ import com.futo.platformplayer.R
|
||||
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
|
||||
@@ -61,6 +64,7 @@ class CastView : ConstraintLayout {
|
||||
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) {
|
||||
@@ -185,11 +189,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 +202,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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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,14 @@
|
||||
<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="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 +450,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 +1005,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 +1099,23 @@
|
||||
<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="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>
|
||||
|
||||
Submodule app/src/stable/assets/sources/crunchyroll updated: 1aa91f216c...534bded369
Submodule app/src/stable/assets/sources/dailymotion updated: ffd40f2006...d115430011
Submodule app/src/stable/assets/sources/nebula updated: 97a5ad5a37...880da6a015
Submodule app/src/stable/assets/sources/odysee updated: 215cd9bd70...6ea9fa7e4c
Submodule app/src/stable/assets/sources/patreon updated: e5dce87c9d...d98c7f8aee
Submodule app/src/stable/assets/sources/peertube updated: 6e7f943b0b...56bff39123
Submodule app/src/stable/assets/sources/rumble updated: 932fdf78de...401274b1ec
Submodule app/src/stable/assets/sources/soundcloud updated: f8234d6af8...5456431268
Submodule app/src/stable/assets/sources/spotify updated: 47e76a96e5...d025804364
Submodule app/src/stable/assets/sources/youtube updated: a297a0a788...2e25829494
@@ -7,7 +7,7 @@
|
||||
<application>
|
||||
<receiver android:name=".receivers.InstallReceiver" />
|
||||
|
||||
<activity android:name=".activities.MainActivity">
|
||||
<activity android:name=".activities.MainActivity" android:launchMode="singleInstance">
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
|
||||
|
||||
Submodule app/src/unstable/assets/sources/apple-podcasts updated: 9aa31c5e87...089987f007
Submodule app/src/unstable/assets/sources/bitchute updated: b31ced36b9...b213f91c0b
Submodule app/src/unstable/assets/sources/crunchyroll updated: 1aa91f216c...534bded369
Submodule app/src/unstable/assets/sources/dailymotion updated: ffd40f2006...d115430011
Submodule app/src/unstable/assets/sources/nebula updated: 97a5ad5a37...880da6a015
Submodule app/src/unstable/assets/sources/odysee updated: 215cd9bd70...6ea9fa7e4c
Submodule app/src/unstable/assets/sources/patreon updated: e5dce87c9d...d98c7f8aee
Submodule app/src/unstable/assets/sources/peertube updated: 6e7f943b0b...56bff39123
Submodule app/src/unstable/assets/sources/rumble updated: 932fdf78de...401274b1ec
Submodule app/src/unstable/assets/sources/soundcloud updated: f8234d6af8...5456431268
Submodule app/src/unstable/assets/sources/spotify updated: 47e76a96e5...d025804364
Submodule app/src/unstable/assets/sources/youtube updated: a297a0a788...2e25829494
Reference in New Issue
Block a user