mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-05-29 13:00:21 +02:00
Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into db-store
This commit is contained in:
commit
f3c9e0196e
@ -9,6 +9,8 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="com.android.alarm.permission.SET_ALARM"/>
|
||||||
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@ -39,6 +41,7 @@
|
|||||||
|
|
||||||
<receiver android:name=".receivers.MediaControlReceiver" />
|
<receiver android:name=".receivers.MediaControlReceiver" />
|
||||||
<receiver android:name=".receivers.AudioNoisyReceiver" />
|
<receiver android:name=".receivers.AudioNoisyReceiver" />
|
||||||
|
<receiver android:name=".receivers.PlannedNotificationReceiver" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.MainActivity"
|
android:name=".activities.MainActivity"
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
package com.futo.platformplayer
|
package com.futo.platformplayer
|
||||||
|
|
||||||
import com.google.common.base.CharMatcher
|
import com.google.common.base.CharMatcher
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
import java.net.Inet4Address
|
import java.net.Inet4Address
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.charset.Charset
|
||||||
|
|
||||||
|
|
||||||
private const val IPV4_PART_COUNT = 4;
|
private const val IPV4_PART_COUNT = 4;
|
||||||
@ -273,3 +277,46 @@ fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
|
|||||||
|
|
||||||
return connectedSocket;
|
return connectedSocket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun InputStream.readHttpHeaderBytes() : ByteArray {
|
||||||
|
val headerBytes = ByteArrayOutputStream()
|
||||||
|
var crlfCount = 0
|
||||||
|
|
||||||
|
while (crlfCount < 4) {
|
||||||
|
val b = read()
|
||||||
|
if (b == -1) {
|
||||||
|
throw IOException("Unexpected end of stream while reading headers")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (b == 0x0D || b == 0x0A) { // CR or LF
|
||||||
|
crlfCount++
|
||||||
|
} else {
|
||||||
|
crlfCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
headerBytes.write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
return headerBytes.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun InputStream.readLine() : String? {
|
||||||
|
val line = ByteArrayOutputStream()
|
||||||
|
var crlfCount = 0
|
||||||
|
|
||||||
|
while (crlfCount < 2) {
|
||||||
|
val b = read()
|
||||||
|
if (b == -1) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (b == 0x0D || b == 0x0A) { // CR or LF
|
||||||
|
crlfCount++
|
||||||
|
} else {
|
||||||
|
crlfCount = 0
|
||||||
|
line.write(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(line.toByteArray(), Charsets.UTF_8)
|
||||||
|
}
|
@ -1,6 +1,11 @@
|
|||||||
package com.futo.platformplayer
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.AnnouncementType
|
||||||
|
import com.futo.platformplayer.states.StateAnnouncement
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
|
import com.futo.platformplayer.views.adapters.CommentViewHolder
|
||||||
|
import com.futo.polycentric.core.ProcessHandle
|
||||||
import userpackage.Protocol
|
import userpackage.Protocol
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
@ -39,4 +44,21 @@ fun Protocol.Claim.resolveChannelUrl(): String? {
|
|||||||
|
|
||||||
fun Protocol.Claim.resolveChannelUrls(): List<String> {
|
fun Protocol.Claim.resolveChannelUrls(): List<String> {
|
||||||
return StatePlatform.instance.resolveChannelUrlsByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
|
return StatePlatform.instance.resolveChannelUrlsByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun ProcessHandle.fullyBackfillServersAnnounceExceptions() {
|
||||||
|
val exceptions = fullyBackfillServers()
|
||||||
|
for (pair in exceptions) {
|
||||||
|
val server = pair.key
|
||||||
|
val exception = pair.value
|
||||||
|
|
||||||
|
StateAnnouncement.instance.registerAnnouncement(
|
||||||
|
"backfill-failed",
|
||||||
|
"Backfill failed",
|
||||||
|
"Failed to backfill server $server. $exception",
|
||||||
|
AnnouncementType.SESSION_RECURRING
|
||||||
|
);
|
||||||
|
|
||||||
|
Logger.e("Backfill", "Failed to backfill server $server.", exception)
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,5 +1,10 @@
|
|||||||
package com.futo.platformplayer
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import java.net.URI
|
||||||
|
import java.net.URISyntaxException
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
//Syntax sugaring
|
//Syntax sugaring
|
||||||
inline fun <reified T> Any.assume(): T?{
|
inline fun <reified T> Any.assume(): T?{
|
||||||
if(this is T)
|
if(this is T)
|
||||||
@ -12,4 +17,12 @@ inline fun <reified T, R> Any.assume(cb: (T) -> R): R? {
|
|||||||
if(result != null)
|
if(result != null)
|
||||||
return cb(result);
|
return cb(result);
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String?.yesNoToBoolean(): Boolean {
|
||||||
|
return this?.uppercase() == "YES"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Boolean?.toYesNo(): String {
|
||||||
|
return if (this == true) "YES" else "NO"
|
||||||
}
|
}
|
@ -158,7 +158,11 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
var previewFeedItems: Boolean = true;
|
var previewFeedItems: Boolean = true;
|
||||||
|
|
||||||
|
|
||||||
@FormField(R.string.clear_hidden, FieldForm.BUTTON, R.string.clear_hidden_description, 7)
|
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||||
|
var progressBar: Boolean = false;
|
||||||
|
|
||||||
|
|
||||||
|
@FormField(R.string.clear_hidden, FieldForm.BUTTON, R.string.clear_hidden_description, 8)
|
||||||
@FormFieldButton(R.drawable.ic_visibility_off)
|
@FormFieldButton(R.drawable.ic_visibility_off)
|
||||||
fun clearHidden() {
|
fun clearHidden() {
|
||||||
StateMeta.instance.removeAllHiddenCreators();
|
StateMeta.instance.removeAllHiddenCreators();
|
||||||
@ -185,6 +189,8 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
|
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
|
||||||
var previewFeedItems: Boolean = true;
|
var previewFeedItems: Boolean = true;
|
||||||
|
|
||||||
|
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||||
|
var progressBar: Boolean = false;
|
||||||
|
|
||||||
|
|
||||||
fun getSearchFeedStyle(): FeedStyle {
|
fun getSearchFeedStyle(): FeedStyle {
|
||||||
@ -195,7 +201,17 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.subscriptions, "group", R.string.configure_how_your_subscriptions_works_and_feels, 3)
|
|
||||||
|
@FormField(R.string.channel, "group", -1, 3)
|
||||||
|
var channel = ChannelSettings();
|
||||||
|
@Serializable
|
||||||
|
class ChannelSettings {
|
||||||
|
|
||||||
|
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||||
|
var progressBar: Boolean = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@FormField(R.string.subscriptions, "group", R.string.configure_how_your_subscriptions_works_and_feels, 4)
|
||||||
var subscriptions = SubscriptionsSettings();
|
var subscriptions = SubscriptionsSettings();
|
||||||
@Serializable
|
@Serializable
|
||||||
class SubscriptionsSettings {
|
class SubscriptionsSettings {
|
||||||
@ -213,14 +229,17 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
|
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
|
||||||
var previewFeedItems: Boolean = true;
|
var previewFeedItems: Boolean = true;
|
||||||
|
|
||||||
@FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 6)
|
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||||
|
var progressBar: Boolean = false;
|
||||||
|
|
||||||
|
@FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 7)
|
||||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||||
var fetchOnAppBoot: Boolean = true;
|
var fetchOnAppBoot: Boolean = true;
|
||||||
|
|
||||||
@FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 6)
|
@FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 8)
|
||||||
var fetchOnTabOpen: Boolean = true;
|
var fetchOnTabOpen: Boolean = true;
|
||||||
|
|
||||||
@FormField(R.string.background_update, FieldForm.DROPDOWN, R.string.experimental_background_update_for_subscriptions_cache, 7)
|
@FormField(R.string.background_update, FieldForm.DROPDOWN, R.string.experimental_background_update_for_subscriptions_cache, 9)
|
||||||
@DropdownFieldOptionsId(R.array.background_interval)
|
@DropdownFieldOptionsId(R.array.background_interval)
|
||||||
var subscriptionsBackgroundUpdateInterval: Int = 0;
|
var subscriptionsBackgroundUpdateInterval: Int = 0;
|
||||||
|
|
||||||
@ -236,7 +255,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@FormField(R.string.subscription_concurrency, FieldForm.DROPDOWN, R.string.specify_how_many_threads_are_used_to_fetch_channels, 8)
|
@FormField(R.string.subscription_concurrency, FieldForm.DROPDOWN, R.string.specify_how_many_threads_are_used_to_fetch_channels, 10)
|
||||||
@DropdownFieldOptionsId(R.array.thread_count)
|
@DropdownFieldOptionsId(R.array.thread_count)
|
||||||
var subscriptionConcurrency: Int = 3;
|
var subscriptionConcurrency: Int = 3;
|
||||||
|
|
||||||
@ -244,17 +263,17 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
return threadIndexToCount(subscriptionConcurrency);
|
return threadIndexToCount(subscriptionConcurrency);
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.show_watch_metrics, FieldForm.TOGGLE, R.string.show_watch_metrics_description, 9)
|
@FormField(R.string.show_watch_metrics, FieldForm.TOGGLE, R.string.show_watch_metrics_description, 11)
|
||||||
var showWatchMetrics: Boolean = false;
|
var showWatchMetrics: Boolean = false;
|
||||||
|
|
||||||
@FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 10)
|
@FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 12)
|
||||||
var allowPlaytimeTracking: Boolean = true;
|
var allowPlaytimeTracking: Boolean = true;
|
||||||
|
|
||||||
|
|
||||||
@FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 11)
|
@FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 13)
|
||||||
var alwaysReloadFromCache: Boolean = false;
|
var alwaysReloadFromCache: Boolean = false;
|
||||||
|
|
||||||
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 12)
|
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 14)
|
||||||
fun clearChannelCache() {
|
fun clearChannelCache() {
|
||||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing..");
|
UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing..");
|
||||||
ChannelContentCache.instance.clear();
|
ChannelContentCache.instance.clear();
|
||||||
@ -262,7 +281,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.player, "group", R.string.change_behavior_of_the_player, 4)
|
@FormField(R.string.player, "group", R.string.change_behavior_of_the_player, 5)
|
||||||
var playback = PlaybackSettings();
|
var playback = PlaybackSettings();
|
||||||
@Serializable
|
@Serializable
|
||||||
class PlaybackSettings {
|
class PlaybackSettings {
|
||||||
@ -288,29 +307,29 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
else -> 1.0f;
|
else -> 1.0f;
|
||||||
};
|
};
|
||||||
|
|
||||||
@FormField(R.string.preferred_quality, FieldForm.DROPDOWN, -1, 2)
|
@FormField(R.string.preferred_quality, FieldForm.DROPDOWN, R.string.preferred_quality_description, 2)
|
||||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||||
var preferredQuality: Int = 0;
|
var preferredQuality: Int = 0;
|
||||||
|
|
||||||
@FormField(R.string.preferred_metered_quality, FieldForm.DROPDOWN, -1, 2)
|
@FormField(R.string.preferred_metered_quality, FieldForm.DROPDOWN, R.string.preferred_metered_quality_description, 3)
|
||||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||||
var preferredMeteredQuality: Int = 0;
|
var preferredMeteredQuality: Int = 0;
|
||||||
fun getPreferredQualityPixelCount(): Int = preferedQualityToPixels(preferredQuality);
|
fun getPreferredQualityPixelCount(): Int = preferedQualityToPixels(preferredQuality);
|
||||||
fun getPreferredMeteredQualityPixelCount(): Int = preferedQualityToPixels(preferredMeteredQuality);
|
fun getPreferredMeteredQualityPixelCount(): Int = preferedQualityToPixels(preferredMeteredQuality);
|
||||||
fun getCurrentPreferredQualityPixelCount(): Int = if(!StateApp.instance.isCurrentMetered()) getPreferredQualityPixelCount() else getPreferredMeteredQualityPixelCount();
|
fun getCurrentPreferredQualityPixelCount(): Int = if(!StateApp.instance.isCurrentMetered()) getPreferredQualityPixelCount() else getPreferredMeteredQualityPixelCount();
|
||||||
|
|
||||||
@FormField(R.string.preferred_preview_quality, FieldForm.DROPDOWN, -1, 3)
|
@FormField(R.string.preferred_preview_quality, FieldForm.DROPDOWN, R.string.preferred_preview_quality_description, 4)
|
||||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||||
var preferredPreviewQuality: Int = 5;
|
var preferredPreviewQuality: Int = 5;
|
||||||
fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality);
|
fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality);
|
||||||
|
|
||||||
@FormField(R.string.auto_rotate, FieldForm.DROPDOWN, -1, 4)
|
@FormField(R.string.auto_rotate, FieldForm.DROPDOWN, -1, 5)
|
||||||
@DropdownFieldOptionsId(R.array.system_enabled_disabled_array)
|
@DropdownFieldOptionsId(R.array.system_enabled_disabled_array)
|
||||||
var autoRotate: Int = 2;
|
var autoRotate: Int = 2;
|
||||||
|
|
||||||
fun isAutoRotate() = autoRotate == 1 || (autoRotate == 2 && StateApp.instance.getCurrentSystemAutoRotate());
|
fun isAutoRotate() = autoRotate == 1 || (autoRotate == 2 && StateApp.instance.getCurrentSystemAutoRotate());
|
||||||
|
|
||||||
@FormField(R.string.auto_rotate_dead_zone, FieldForm.DROPDOWN, R.string.this_prevents_the_device_from_rotating_within_the_given_amount_of_degrees, 5)
|
@FormField(R.string.auto_rotate_dead_zone, FieldForm.DROPDOWN, R.string.this_prevents_the_device_from_rotating_within_the_given_amount_of_degrees, 6)
|
||||||
@DropdownFieldOptionsId(R.array.auto_rotate_dead_zone)
|
@DropdownFieldOptionsId(R.array.auto_rotate_dead_zone)
|
||||||
var autoRotateDeadZone: Int = 0;
|
var autoRotateDeadZone: Int = 0;
|
||||||
|
|
||||||
@ -318,7 +337,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
return autoRotateDeadZone * 5;
|
return autoRotateDeadZone * 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 6)
|
@FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 7)
|
||||||
@DropdownFieldOptionsId(R.array.player_background_behavior)
|
@DropdownFieldOptionsId(R.array.player_background_behavior)
|
||||||
var backgroundPlay: Int = 2;
|
var backgroundPlay: Int = 2;
|
||||||
|
|
||||||
@ -360,7 +379,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
var backgroundSwitchToAudio: Boolean = true;
|
var backgroundSwitchToAudio: Boolean = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.comments, "group", R.string.comments_description, 4)
|
@FormField(R.string.comments, "group", R.string.comments_description, 6)
|
||||||
var comments = CommentSettings();
|
var comments = CommentSettings();
|
||||||
@Serializable
|
@Serializable
|
||||||
class CommentSettings {
|
class CommentSettings {
|
||||||
@ -369,7 +388,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
var defaultCommentSection: Int = 0;
|
var defaultCommentSection: Int = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.downloads, "group", R.string.configure_downloading_of_videos, 5)
|
@FormField(R.string.downloads, "group", R.string.configure_downloading_of_videos, 7)
|
||||||
var downloads = Downloads();
|
var downloads = Downloads();
|
||||||
@Serializable
|
@Serializable
|
||||||
class Downloads {
|
class Downloads {
|
||||||
@ -409,7 +428,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.browsing, "group", R.string.configure_browsing_behavior, 6)
|
@FormField(R.string.browsing, "group", R.string.configure_browsing_behavior, 8)
|
||||||
var browsing = Browsing();
|
var browsing = Browsing();
|
||||||
@Serializable
|
@Serializable
|
||||||
class Browsing {
|
class Browsing {
|
||||||
@ -418,7 +437,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
var videoCache: Boolean = true;
|
var videoCache: Boolean = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.casting, "group", R.string.configure_casting, 7)
|
@FormField(R.string.casting, "group", R.string.configure_casting, 9)
|
||||||
var casting = Casting();
|
var casting = Casting();
|
||||||
@Serializable
|
@Serializable
|
||||||
class Casting {
|
class Casting {
|
||||||
@ -446,8 +465,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}*/
|
}*/
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@FormField(R.string.logging, FieldForm.GROUP, -1, 10)
|
||||||
@FormField(R.string.logging, FieldForm.GROUP, -1, 8)
|
|
||||||
var logging = Logging();
|
var logging = Logging();
|
||||||
@Serializable
|
@Serializable
|
||||||
class Logging {
|
class Logging {
|
||||||
@ -471,9 +489,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@FormField(R.string.announcement, FieldForm.GROUP, -1, 11)
|
||||||
|
|
||||||
@FormField(R.string.announcement, FieldForm.GROUP, -1, 10)
|
|
||||||
var announcementSettings = AnnouncementSettings();
|
var announcementSettings = AnnouncementSettings();
|
||||||
@Serializable
|
@Serializable
|
||||||
class AnnouncementSettings {
|
class AnnouncementSettings {
|
||||||
@ -484,7 +500,15 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.plugins, FieldForm.GROUP, -1, 11)
|
@FormField(R.string.notifications, FieldForm.GROUP, -1, 12)
|
||||||
|
var notifications = NotificationSettings();
|
||||||
|
@Serializable
|
||||||
|
class NotificationSettings {
|
||||||
|
@FormField(R.string.planned_content_notifications, FieldForm.TOGGLE, R.string.planned_content_notifications_description, 1)
|
||||||
|
var plannedContentNotification: Boolean = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@FormField(R.string.plugins, FieldForm.GROUP, -1, 13)
|
||||||
@Transient
|
@Transient
|
||||||
var plugins = Plugins();
|
var plugins = Plugins();
|
||||||
@Serializable
|
@Serializable
|
||||||
@ -521,7 +545,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@FormField(R.string.external_storage, FieldForm.GROUP, -1, 12)
|
@FormField(R.string.external_storage, FieldForm.GROUP, -1, 14)
|
||||||
var storage = Storage();
|
var storage = Storage();
|
||||||
@Serializable
|
@Serializable
|
||||||
class Storage {
|
class Storage {
|
||||||
@ -555,7 +579,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@FormField(R.string.auto_update, "group", R.string.configure_the_auto_updater, 12)
|
@FormField(R.string.auto_update, "group", R.string.configure_the_auto_updater, 15)
|
||||||
var autoUpdate = AutoUpdate();
|
var autoUpdate = AutoUpdate();
|
||||||
@Serializable
|
@Serializable
|
||||||
class AutoUpdate {
|
class AutoUpdate {
|
||||||
@ -637,7 +661,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.backup, FieldForm.GROUP, -1, 13)
|
@FormField(R.string.backup, FieldForm.GROUP, -1, 16)
|
||||||
var backup = Backup();
|
var backup = Backup();
|
||||||
@Serializable
|
@Serializable
|
||||||
class Backup {
|
class Backup {
|
||||||
@ -690,7 +714,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}*/
|
}*/
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.payment, FieldForm.GROUP, -1, 14)
|
@FormField(R.string.payment, FieldForm.GROUP, -1, 17)
|
||||||
var payment = Payment();
|
var payment = Payment();
|
||||||
@Serializable
|
@Serializable
|
||||||
class Payment {
|
class Payment {
|
||||||
@ -707,7 +731,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.other, FieldForm.GROUP, -1, 15)
|
@FormField(R.string.other, FieldForm.GROUP, -1, 18)
|
||||||
var other = Other();
|
var other = Other();
|
||||||
@Serializable
|
@Serializable
|
||||||
class Other {
|
class Other {
|
||||||
@ -716,7 +740,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
var bypassRotationPrevention: Boolean = false;
|
var bypassRotationPrevention: Boolean = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.info, FieldForm.GROUP, -1, 16)
|
@FormField(R.string.info, FieldForm.GROUP, -1, 19)
|
||||||
var info = Info();
|
var info = Info();
|
||||||
@Serializable
|
@Serializable
|
||||||
class Info {
|
class Info {
|
||||||
|
@ -20,6 +20,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
|||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.background.BackgroundWorker
|
import com.futo.platformplayer.background.BackgroundWorker
|
||||||
|
import com.futo.platformplayer.cache.ChannelContentCache
|
||||||
import com.futo.platformplayer.engine.V8Plugin
|
import com.futo.platformplayer.engine.V8Plugin
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
|
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
|
||||||
@ -111,6 +112,14 @@ class SettingsDev : FragmentedStorageFileJson() {
|
|||||||
.build();
|
.build();
|
||||||
wm.enqueue(req);
|
wm.enqueue(req);
|
||||||
}
|
}
|
||||||
|
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON,
|
||||||
|
R.string.test_background_worker_description, 3)
|
||||||
|
fun clearChannelContentCache() {
|
||||||
|
UIDialogs.toast(SettingsActivity.getActivity()!!, "Clearing cache");
|
||||||
|
ChannelContentCache.instance.clearToday();
|
||||||
|
UIDialogs.toast(SettingsActivity.getActivity()!!, "Cleared");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@Contextual
|
@Contextual
|
||||||
@Transient
|
@Transient
|
||||||
|
@ -10,6 +10,7 @@ import android.widget.ImageButton
|
|||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
|
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
||||||
@ -54,7 +55,6 @@ class UISlideOverlays {
|
|||||||
|
|
||||||
fun showSubscriptionOptionsOverlay(subscription: Subscription, container: ViewGroup) {
|
fun showSubscriptionOptionsOverlay(subscription: Subscription, container: ViewGroup) {
|
||||||
val items = arrayListOf<View>();
|
val items = arrayListOf<View>();
|
||||||
var menu: SlideUpMenuOverlay? = null;
|
|
||||||
|
|
||||||
val originalNotif = subscription.doNotifications;
|
val originalNotif = subscription.doNotifications;
|
||||||
val originalLive = subscription.doFetchLive;
|
val originalLive = subscription.doFetchLive;
|
||||||
@ -62,54 +62,69 @@ class UISlideOverlays {
|
|||||||
val originalVideo = subscription.doFetchVideos;
|
val originalVideo = subscription.doFetchVideos;
|
||||||
val originalPosts = subscription.doFetchPosts;
|
val originalPosts = subscription.doFetchPosts;
|
||||||
|
|
||||||
items.addAll(listOf(
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){
|
||||||
SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", {
|
val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url);
|
||||||
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
|
val capabilities = plugin.getChannelCapabilities();
|
||||||
}, false),
|
|
||||||
SlideUpMenuGroup(container.context, "Fetch Settings",
|
|
||||||
"Depending on the platform you might not need to enable a type for it to be available.",
|
|
||||||
-1, listOf()),
|
|
||||||
SlideUpMenuItem(container.context, R.drawable.ic_live_tv, "Livestreams", "Check for livestreams", "fetchLive", {
|
|
||||||
subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive;
|
|
||||||
}, false),
|
|
||||||
SlideUpMenuItem(container.context, R.drawable.ic_play, "Streams", "Check for finished streams", "fetchStreams", {
|
|
||||||
subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchLive;
|
|
||||||
}, false),
|
|
||||||
SlideUpMenuItem(container.context, R.drawable.ic_play, "Videos", "Check for videos", "fetchVideos", {
|
|
||||||
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchLive;
|
|
||||||
}, false),
|
|
||||||
SlideUpMenuItem(container.context, R.drawable.ic_chat, "Posts", "Check for posts", "fetchPosts", {
|
|
||||||
subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchLive;
|
|
||||||
}, false)));
|
|
||||||
|
|
||||||
menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, items);
|
withContext(Dispatchers.Main) {
|
||||||
|
|
||||||
if(subscription.doNotifications)
|
var menu: SlideUpMenuOverlay? = null;
|
||||||
menu.selectOption(null, "notifications", true, true);
|
|
||||||
if(subscription.doFetchLive)
|
|
||||||
menu.selectOption(null, "fetchLive", true, true);
|
|
||||||
if(subscription.doFetchStreams)
|
|
||||||
menu.selectOption(null, "fetchStreams", true, true);
|
|
||||||
if(subscription.doFetchVideos)
|
|
||||||
menu.selectOption(null, "fetchVideos", true, true);
|
|
||||||
if(subscription.doFetchPosts)
|
|
||||||
menu.selectOption(null, "fetchPosts", true, true);
|
|
||||||
|
|
||||||
menu.onOK.subscribe {
|
|
||||||
subscription.save();
|
|
||||||
menu.hide(true);
|
|
||||||
};
|
|
||||||
menu.onCancel.subscribe {
|
|
||||||
subscription.doNotifications = originalNotif;
|
|
||||||
subscription.doFetchLive = originalLive;
|
|
||||||
subscription.doFetchStreams = originalStream;
|
|
||||||
subscription.doFetchVideos = originalVideo;
|
|
||||||
subscription.doFetchPosts = originalPosts;
|
|
||||||
};
|
|
||||||
|
|
||||||
menu.setOk("Save");
|
items.addAll(listOf(
|
||||||
|
SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", {
|
||||||
|
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
|
||||||
|
}, false),
|
||||||
|
SlideUpMenuGroup(container.context, "Fetch Settings",
|
||||||
|
"Depending on the platform you might not need to enable a type for it to be available.",
|
||||||
|
-1, listOf()),
|
||||||
|
if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(container.context, R.drawable.ic_live_tv, "Livestreams", "Check for livestreams", "fetchLive", {
|
||||||
|
subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive;
|
||||||
|
}, false) else null,
|
||||||
|
if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(container.context, R.drawable.ic_play, "Streams", "Check for streams", "fetchStreams", {
|
||||||
|
subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams;
|
||||||
|
}, false) else null,
|
||||||
|
if(capabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
|
||||||
|
SlideUpMenuItem(container.context, R.drawable.ic_play, "Videos", "Check for videos", "fetchVideos", {
|
||||||
|
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
|
||||||
|
}, false) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
|
||||||
|
SlideUpMenuItem(container.context, R.drawable.ic_play, "Content", "Check for content", "fetchVideos", {
|
||||||
|
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
|
||||||
|
}, false) else null,
|
||||||
|
if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(container.context, R.drawable.ic_chat, "Posts", "Check for posts", "fetchPosts", {
|
||||||
|
subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts;
|
||||||
|
}, false) else null).filterNotNull());
|
||||||
|
|
||||||
menu.show();
|
menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, items);
|
||||||
|
|
||||||
|
if(subscription.doNotifications)
|
||||||
|
menu.selectOption(null, "notifications", true, true);
|
||||||
|
if(subscription.doFetchLive)
|
||||||
|
menu.selectOption(null, "fetchLive", true, true);
|
||||||
|
if(subscription.doFetchStreams)
|
||||||
|
menu.selectOption(null, "fetchStreams", true, true);
|
||||||
|
if(subscription.doFetchVideos)
|
||||||
|
menu.selectOption(null, "fetchVideos", true, true);
|
||||||
|
if(subscription.doFetchPosts)
|
||||||
|
menu.selectOption(null, "fetchPosts", true, true);
|
||||||
|
|
||||||
|
menu.onOK.subscribe {
|
||||||
|
subscription.save();
|
||||||
|
menu.hide(true);
|
||||||
|
};
|
||||||
|
menu.onCancel.subscribe {
|
||||||
|
subscription.doNotifications = originalNotif;
|
||||||
|
subscription.doFetchLive = originalLive;
|
||||||
|
subscription.doFetchStreams = originalStream;
|
||||||
|
subscription.doFetchVideos = originalVideo;
|
||||||
|
subscription.doFetchPosts = originalPosts;
|
||||||
|
};
|
||||||
|
|
||||||
|
menu.setOk("Save");
|
||||||
|
|
||||||
|
menu.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showDownloadVideoOverlay(video: IPlatformVideoDetails, container: ViewGroup, contentResolver: ContentResolver? = null): SlideUpMenuOverlay? {
|
fun showDownloadVideoOverlay(video: IPlatformVideoDetails, container: ViewGroup, contentResolver: ContentResolver? = null): SlideUpMenuOverlay? {
|
||||||
|
@ -8,6 +8,7 @@ import android.content.res.Configuration
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.preference.PreferenceManager
|
import android.preference.PreferenceManager
|
||||||
|
import android.util.Log
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
@ -884,15 +885,20 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
|
|
||||||
if((fragment?.isOverlay ?: false) && fragBeforeOverlay != null) {
|
if((fragment?.isOverlay ?: false) && fragBeforeOverlay != null) {
|
||||||
navigate(fragBeforeOverlay!!, null, false, true);
|
navigate(fragBeforeOverlay!!, null, false, true);
|
||||||
|
} else {
|
||||||
}
|
|
||||||
else {
|
|
||||||
val last = _queue.lastOrNull();
|
val last = _queue.lastOrNull();
|
||||||
if (last != null) {
|
if (last != null) {
|
||||||
_queue.remove(last);
|
_queue.remove(last);
|
||||||
navigate(last.first, last.second, false, true);
|
navigate(last.first, last.second, false, true);
|
||||||
} else
|
} else {
|
||||||
finish();
|
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
|
||||||
|
finish();
|
||||||
|
} else {
|
||||||
|
UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", {
|
||||||
|
finish();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -10,6 +10,7 @@ import androidx.appcompat.app.AppCompatActivity
|
|||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
@ -82,7 +83,7 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
Logger.i(TAG, "Started backfill");
|
Logger.i(TAG, "Started backfill");
|
||||||
processHandle.fullyBackfillServers();
|
processHandle.fullyBackfillServersAnnounceExceptions();
|
||||||
Logger.i(TAG, "Finished backfill");
|
Logger.i(TAG, "Finished backfill");
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.e(TAG, getString(R.string.failed_to_fully_backfill_servers), e);
|
Logger.e(TAG, getString(R.string.failed_to_fully_backfill_servers), e);
|
||||||
|
@ -19,6 +19,7 @@ import com.futo.platformplayer.R
|
|||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.dialogs.CommentDialog
|
import com.futo.platformplayer.dialogs.CommentDialog
|
||||||
import com.futo.platformplayer.dp
|
import com.futo.platformplayer.dp
|
||||||
|
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
||||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.selectBestImage
|
import com.futo.platformplayer.selectBestImage
|
||||||
@ -194,7 +195,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
|||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
try {
|
try {
|
||||||
Logger.i(TAG, "Started backfill");
|
Logger.i(TAG, "Started backfill");
|
||||||
processHandle.fullyBackfillServers();
|
processHandle.fullyBackfillServersAnnounceExceptions();
|
||||||
Logger.i(TAG, "Finished backfill");
|
Logger.i(TAG, "Finished backfill");
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.changes_have_been_saved));
|
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.changes_have_been_saved));
|
||||||
|
@ -69,9 +69,11 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun reloadSettings() {
|
fun reloadSettings() {
|
||||||
|
_form.setSearchVisible(false);
|
||||||
_loader.start();
|
_loader.start();
|
||||||
_form.fromObject(lifecycleScope, Settings.instance) {
|
_form.fromObject(lifecycleScope, Settings.instance) {
|
||||||
_loader.stop();
|
_loader.stop();
|
||||||
|
_form.setSearchVisible(true);
|
||||||
|
|
||||||
var devCounter = 0;
|
var devCounter = 0;
|
||||||
_form.findField("code")?.assume<ReadOnlyTextField>()?.setOnClickListener {
|
_form.findField("code")?.assume<ReadOnlyTextField>()?.setOnClickListener {
|
||||||
|
@ -197,8 +197,13 @@ class HttpContext : AutoCloseable {
|
|||||||
}
|
}
|
||||||
fun respondCode(status: Int, headers: HttpHeaders, body: String? = null) {
|
fun respondCode(status: Int, headers: HttpHeaders, body: String? = null) {
|
||||||
val bytes = body?.toByteArray(Charsets.UTF_8);
|
val bytes = body?.toByteArray(Charsets.UTF_8);
|
||||||
if(body != null && headers.get("content-length").isNullOrEmpty())
|
if(headers.get("content-length").isNullOrEmpty()) {
|
||||||
headers.put("content-length", bytes!!.size.toString());
|
if (body != null) {
|
||||||
|
headers.put("content-length", bytes!!.size.toString());
|
||||||
|
} else {
|
||||||
|
headers.put("content-length", "0")
|
||||||
|
}
|
||||||
|
}
|
||||||
respond(status, headers) { responseStream ->
|
respond(status, headers) { responseStream ->
|
||||||
if(body != null) {
|
if(body != null) {
|
||||||
responseStream.write(bytes!!);
|
responseStream.write(bytes!!);
|
||||||
@ -219,8 +224,7 @@ class HttpContext : AutoCloseable {
|
|||||||
headersToRespond.put("keep-alive", "timeout=5, max=1000");
|
headersToRespond.put("keep-alive", "timeout=5, max=1000");
|
||||||
}
|
}
|
||||||
|
|
||||||
val responseHeader = HttpResponse(status, headers);
|
val responseHeader = HttpResponse(status, headersToRespond);
|
||||||
|
|
||||||
responseStream.write(responseHeader.getHttpHeaderBytes());
|
responseStream.write(responseHeader.getHttpHeaderBytes());
|
||||||
|
|
||||||
if(method != "HEAD") {
|
if(method != "HEAD") {
|
||||||
|
@ -5,6 +5,7 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
|
|||||||
import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException
|
import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException
|
||||||
import com.futo.platformplayer.api.http.server.handlers.HttpFuntionHandler
|
import com.futo.platformplayer.api.http.server.handlers.HttpFuntionHandler
|
||||||
import com.futo.platformplayer.api.http.server.handlers.HttpHandler
|
import com.futo.platformplayer.api.http.server.handlers.HttpHandler
|
||||||
|
import com.futo.platformplayer.api.http.server.handlers.HttpOptionsAllowHandler
|
||||||
import java.io.BufferedInputStream
|
import java.io.BufferedInputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.lang.reflect.Field
|
import java.lang.reflect.Field
|
||||||
@ -17,6 +18,7 @@ import java.util.*
|
|||||||
import java.util.concurrent.ExecutorService
|
import java.util.concurrent.ExecutorService
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import java.util.stream.IntStream.range
|
import java.util.stream.IntStream.range
|
||||||
|
import kotlin.collections.HashMap
|
||||||
|
|
||||||
class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
||||||
private val _client : ManagedHttpClient = ManagedHttpClient();
|
private val _client : ManagedHttpClient = ManagedHttpClient();
|
||||||
@ -28,7 +30,8 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
|||||||
var port = 0
|
var port = 0
|
||||||
private set;
|
private set;
|
||||||
|
|
||||||
private val _handlers = mutableListOf<HttpHandler>();
|
private val _handlers = hashMapOf<String, HashMap<String, HttpHandler>>()
|
||||||
|
private val _headHandlers = hashMapOf<String, HttpHandler>()
|
||||||
private var _workerPool: ExecutorService? = null;
|
private var _workerPool: ExecutorService? = null;
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@ -114,32 +117,78 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
|||||||
|
|
||||||
fun getHandler(method: String, path: String) : HttpHandler? {
|
fun getHandler(method: String, path: String) : HttpHandler? {
|
||||||
synchronized(_handlers) {
|
synchronized(_handlers) {
|
||||||
//TODO: Support regex paths?
|
if (method == "HEAD") {
|
||||||
if(method == "HEAD")
|
return _headHandlers[path]
|
||||||
return _handlers.firstOrNull { it.path == path && (it.allowHEAD || it.method == "HEAD") }
|
}
|
||||||
return _handlers.firstOrNull { it.method == method && it.path == path };
|
|
||||||
|
val handlerMap = _handlers[method] ?: return null
|
||||||
|
return handlerMap[path]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun addHandler(handler: HttpHandler, withHEAD: Boolean = false) : HttpHandler {
|
fun addHandler(handler: HttpHandler, withHEAD: Boolean = false) : HttpHandler {
|
||||||
synchronized(_handlers) {
|
synchronized(_handlers) {
|
||||||
_handlers.add(handler);
|
|
||||||
handler.allowHEAD = withHEAD;
|
handler.allowHEAD = withHEAD;
|
||||||
|
|
||||||
|
var handlerMap: HashMap<String, HttpHandler>? = _handlers[handler.method];
|
||||||
|
if (handlerMap == null) {
|
||||||
|
handlerMap = hashMapOf()
|
||||||
|
_handlers[handler.method] = handlerMap
|
||||||
|
}
|
||||||
|
|
||||||
|
handlerMap[handler.path] = handler;
|
||||||
|
if (handler.allowHEAD || handler.method == "HEAD") {
|
||||||
|
_headHandlers[handler.path] = handler
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return handler;
|
return handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun addHandlerWithAllowAllOptions(handler: HttpHandler, withHEAD: Boolean = false) : HttpHandler {
|
||||||
|
val allowedMethods = arrayListOf(handler.method, "OPTIONS")
|
||||||
|
if (withHEAD) {
|
||||||
|
allowedMethods.add("HEAD")
|
||||||
|
}
|
||||||
|
|
||||||
|
val tag = handler.tag
|
||||||
|
if (tag != null) {
|
||||||
|
addHandler(HttpOptionsAllowHandler(handler.path, allowedMethods).withTag(tag))
|
||||||
|
} else {
|
||||||
|
addHandler(HttpOptionsAllowHandler(handler.path, allowedMethods))
|
||||||
|
}
|
||||||
|
|
||||||
|
return addHandler(handler, withHEAD)
|
||||||
|
}
|
||||||
|
|
||||||
fun removeHandler(method: String, path: String) {
|
fun removeHandler(method: String, path: String) {
|
||||||
synchronized(_handlers) {
|
synchronized(_handlers) {
|
||||||
val handler = getHandler(method, path);
|
val handlerMap = _handlers[method] ?: return
|
||||||
if(handler != null)
|
val handler = handlerMap.remove(path) ?: return
|
||||||
_handlers.remove(handler);
|
if (method == "HEAD" || handler.allowHEAD) {
|
||||||
|
_headHandlers.remove(path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun removeAllHandlers(tag: String? = null) {
|
fun removeAllHandlers(tag: String? = null) {
|
||||||
synchronized(_handlers) {
|
synchronized(_handlers) {
|
||||||
if(tag == null)
|
if(tag == null)
|
||||||
_handlers.clear();
|
_handlers.clear();
|
||||||
else
|
else {
|
||||||
_handlers.removeIf { it.tag == tag };
|
for (pair in _handlers) {
|
||||||
|
val toRemove = ArrayList<String>()
|
||||||
|
for (innerPair in pair.value) {
|
||||||
|
if (innerPair.value.tag == tag) {
|
||||||
|
toRemove.add(innerPair.key)
|
||||||
|
|
||||||
|
if (pair.key == "HEAD" || innerPair.value.allowHEAD) {
|
||||||
|
_headHandlers.remove(innerPair.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (x in toRemove)
|
||||||
|
pair.value.remove(x)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun addBridgeHandlers(obj: Any, tag: String? = null) {
|
fun addBridgeHandlers(obj: Any, tag: String? = null) {
|
||||||
|
@ -15,6 +15,7 @@ abstract class HttpHandler(val method: String, val path: String) {
|
|||||||
headers.put(key, value);
|
headers.put(key, value);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
fun withContentType(contentType: String) = withHeader("Content-Type", contentType);
|
fun withContentType(contentType: String) = withHeader("Content-Type", contentType);
|
||||||
|
|
||||||
fun withTag(tag: String) : HttpHandler {
|
fun withTag(tag: String) : HttpHandler {
|
||||||
|
@ -2,19 +2,18 @@ package com.futo.platformplayer.api.http.server.handlers
|
|||||||
|
|
||||||
import com.futo.platformplayer.api.http.server.HttpContext
|
import com.futo.platformplayer.api.http.server.HttpContext
|
||||||
|
|
||||||
class HttpOptionsAllowHandler(path: String) : HttpHandler("OPTIONS", path) {
|
class HttpOptionsAllowHandler(path: String, val allowedMethods: List<String> = listOf()) : HttpHandler("OPTIONS", path) {
|
||||||
override fun handle(httpContext: HttpContext) {
|
override fun handle(httpContext: HttpContext) {
|
||||||
//Just allow whatever is requested
|
val newHeaders = headers.clone()
|
||||||
|
newHeaders.put("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
val requestedOrigin = httpContext.headers.getOrDefault("Access-Control-Request-Origin", "");
|
if (allowedMethods.isNotEmpty()) {
|
||||||
val requestedMethods = httpContext.headers.getOrDefault("Access-Control-Request-Method", "");
|
newHeaders.put("Access-Control-Allow-Methods", allowedMethods.map { it.uppercase() }.joinToString(", "))
|
||||||
val requestedHeaders = httpContext.headers.getOrDefault("Access-Control-Request-Headers", "");
|
} else {
|
||||||
|
newHeaders.put("Access-Control-Allow-Methods", "*")
|
||||||
val newHeaders = headers.clone();
|
}
|
||||||
newHeaders.put("Allow", requestedMethods);
|
|
||||||
newHeaders.put("Access-Control-Allow-Methods", requestedMethods);
|
|
||||||
newHeaders.put("Access-Control-Allow-Headers", "*");
|
|
||||||
|
|
||||||
|
newHeaders.put("Access-Control-Allow-Headers", "*")
|
||||||
httpContext.respondCode(200, newHeaders);
|
httpContext.respondCode(200, newHeaders);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,12 +1,20 @@
|
|||||||
package com.futo.platformplayer.api.http.server.handlers
|
package com.futo.platformplayer.api.http.server.handlers
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
import com.futo.platformplayer.api.http.server.HttpContext
|
import com.futo.platformplayer.api.http.server.HttpContext
|
||||||
import com.futo.platformplayer.api.http.server.HttpHeaders
|
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.parsers.HttpResponseParser
|
||||||
|
import com.futo.platformplayer.readLine
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.lang.Exception
|
||||||
|
import java.net.Socket
|
||||||
|
import javax.net.ssl.SSLSocketFactory
|
||||||
|
|
||||||
class HttpProxyHandler(method: String, path: String, val targetUrl: String): HttpHandler(method, path) {
|
class HttpProxyHandler(method: String, path: String, val targetUrl: String, private val useTcp: Boolean = false): HttpHandler(method, path) {
|
||||||
var content: String? = null;
|
var content: String? = null;
|
||||||
var contentType: String? = null;
|
var contentType: String? = null;
|
||||||
|
|
||||||
@ -18,10 +26,17 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
|
|||||||
private var _injectHost = false;
|
private var _injectHost = false;
|
||||||
private var _injectReferer = false;
|
private var _injectReferer = false;
|
||||||
|
|
||||||
|
|
||||||
private val _client = ManagedHttpClient();
|
private val _client = ManagedHttpClient();
|
||||||
|
|
||||||
override fun handle(context: HttpContext) {
|
override fun handle(context: HttpContext) {
|
||||||
|
if (useTcp) {
|
||||||
|
handleWithTcp(context)
|
||||||
|
} else {
|
||||||
|
handleWithOkHttp(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleWithOkHttp(context: HttpContext) {
|
||||||
val proxyHeaders = HashMap<String, String>();
|
val proxyHeaders = HashMap<String, String>();
|
||||||
for (header in context.headers.filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) })
|
for (header in context.headers.filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) })
|
||||||
proxyHeaders[header.key] = header.value;
|
proxyHeaders[header.key] = header.value;
|
||||||
@ -35,8 +50,8 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
|
|||||||
proxyHeaders.put("Referer", targetUrl);
|
proxyHeaders.put("Referer", targetUrl);
|
||||||
|
|
||||||
val useMethod = if (method == "inherit") context.method else method;
|
val useMethod = if (method == "inherit") context.method else method;
|
||||||
Logger.i(TAG, "Proxied Request ${useMethod}: ${targetUrl}");
|
Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${targetUrl}");
|
||||||
Logger.i(TAG, "Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
|
Logger.i(TAG, "handleWithOkHttp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
|
||||||
|
|
||||||
val resp = when (useMethod) {
|
val resp = when (useMethod) {
|
||||||
"GET" -> _client.get(targetUrl, proxyHeaders);
|
"GET" -> _client.get(targetUrl, proxyHeaders);
|
||||||
@ -46,7 +61,7 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
|
|||||||
};
|
};
|
||||||
|
|
||||||
Logger.i(TAG, "Proxied Response [${resp.code}]");
|
Logger.i(TAG, "Proxied Response [${resp.code}]");
|
||||||
val headersFiltered = HttpHeaders(resp.getHeadersFlat().filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) });
|
val headersFiltered = HttpHeaders(resp.getHeadersFlat().filter { !_ignoreResponseHeaders.contains(it.key.lowercase()) });
|
||||||
for(newHeader in headers)
|
for(newHeader in headers)
|
||||||
headersFiltered.put(newHeader.key, newHeader.value);
|
headersFiltered.put(newHeader.key, newHeader.value);
|
||||||
|
|
||||||
@ -66,6 +81,140 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleWithTcp(context: HttpContext) {
|
||||||
|
if (content != null)
|
||||||
|
throw NotImplementedError("Content body is not supported")
|
||||||
|
|
||||||
|
val proxyHeaders = HashMap<String, String>();
|
||||||
|
for (header in context.headers.filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) })
|
||||||
|
proxyHeaders[header.key] = header.value;
|
||||||
|
for (injectHeader in _injectRequestHeader)
|
||||||
|
proxyHeaders[injectHeader.first] = injectHeader.second;
|
||||||
|
|
||||||
|
val parsed = Uri.parse(targetUrl);
|
||||||
|
if(_injectHost)
|
||||||
|
proxyHeaders.put("Host", parsed.host!!);
|
||||||
|
if(_injectReferer)
|
||||||
|
proxyHeaders.put("Referer", targetUrl);
|
||||||
|
|
||||||
|
val useMethod = if (method == "inherit") context.method else method;
|
||||||
|
Logger.i(TAG, "handleWithTcp Proxied Request ${useMethod}: ${parsed}");
|
||||||
|
Logger.i(TAG, "handleWithTcp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
|
||||||
|
|
||||||
|
makeTcpRequest(proxyHeaders, useMethod, parsed, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeTcpRequest(proxyHeaders: HashMap<String, String>, useMethod: String, parsed: Uri, context: HttpContext) {
|
||||||
|
val requestBuilder = StringBuilder()
|
||||||
|
requestBuilder.append("$useMethod $parsed HTTP/1.1\r\n")
|
||||||
|
proxyHeaders.forEach { (key, value) -> requestBuilder.append("$key: $value\r\n") }
|
||||||
|
requestBuilder.append("\r\n")
|
||||||
|
|
||||||
|
val port = if (parsed.port == -1) {
|
||||||
|
when (parsed.scheme) {
|
||||||
|
"https" -> 443
|
||||||
|
"http" -> 80
|
||||||
|
else -> throw Exception("Unhandled scheme")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parsed.port
|
||||||
|
}
|
||||||
|
|
||||||
|
val socket = if (parsed.scheme == "https") {
|
||||||
|
val sslSocketFactory = SSLSocketFactory.getDefault() as SSLSocketFactory
|
||||||
|
sslSocketFactory.createSocket(parsed.host, port)
|
||||||
|
} else {
|
||||||
|
Socket(parsed.host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.use { s ->
|
||||||
|
s.getOutputStream().write(requestBuilder.toString().encodeToByteArray())
|
||||||
|
|
||||||
|
val inputStream = s.getInputStream()
|
||||||
|
val resp = HttpResponseParser(inputStream)
|
||||||
|
if (resp.statusCode == 302) {
|
||||||
|
val location = resp.location!!
|
||||||
|
Logger.i(TAG, "handleWithTcp Proxied ${resp.statusCode} following redirect to $location");
|
||||||
|
makeTcpRequest(proxyHeaders, useMethod, Uri.parse(location)!!, context)
|
||||||
|
} else {
|
||||||
|
val isChunked = resp.transferEncoding.equals("chunked", ignoreCase = true)
|
||||||
|
val contentLength = resp.contentLength.toInt()
|
||||||
|
|
||||||
|
val headersFiltered = HttpHeaders(resp.headers.filter { !_ignoreResponseHeaders.contains(it.key.lowercase()) });
|
||||||
|
for (newHeader in headers)
|
||||||
|
headersFiltered.put(newHeader.key, newHeader.value);
|
||||||
|
|
||||||
|
context.respond(resp.statusCode, headersFiltered) { responseStream ->
|
||||||
|
if (isChunked) {
|
||||||
|
Logger.i(TAG, "handleWithTcp handleChunkedTransfer");
|
||||||
|
handleChunkedTransfer(inputStream, responseStream)
|
||||||
|
} else if (contentLength > 0) {
|
||||||
|
Logger.i(TAG, "handleWithTcp transferFixedLengthContent $contentLength");
|
||||||
|
transferFixedLengthContent(inputStream, responseStream, contentLength)
|
||||||
|
} else if (contentLength == -1) {
|
||||||
|
Logger.i(TAG, "handleWithTcp transferUntilEndOfStream");
|
||||||
|
transferUntilEndOfStream(inputStream, responseStream)
|
||||||
|
} else {
|
||||||
|
Logger.i(TAG, "handleWithTcp no content");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleChunkedTransfer(inputStream: InputStream, responseStream: OutputStream) {
|
||||||
|
var line: String?
|
||||||
|
val buffer = ByteArray(8192)
|
||||||
|
|
||||||
|
while (inputStream.readLine().also { line = it } != null) {
|
||||||
|
val size = line!!.trim().toInt(16)
|
||||||
|
|
||||||
|
responseStream.write(line!!.encodeToByteArray())
|
||||||
|
responseStream.write("\r\n".encodeToByteArray())
|
||||||
|
|
||||||
|
if (size == 0) {
|
||||||
|
inputStream.skip(2)
|
||||||
|
responseStream.write("\r\n".encodeToByteArray())
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalRead = 0
|
||||||
|
while (totalRead < size) {
|
||||||
|
val read = inputStream.read(buffer, 0, minOf(buffer.size, size - totalRead))
|
||||||
|
if (read == -1) break
|
||||||
|
responseStream.write(buffer, 0, read)
|
||||||
|
totalRead += read
|
||||||
|
}
|
||||||
|
|
||||||
|
inputStream.skip(2)
|
||||||
|
responseStream.write("\r\n".encodeToByteArray())
|
||||||
|
responseStream.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun transferFixedLengthContent(inputStream: InputStream, responseStream: OutputStream, contentLength: Int) {
|
||||||
|
val buffer = ByteArray(8192)
|
||||||
|
var totalRead = 0
|
||||||
|
while (totalRead < contentLength) {
|
||||||
|
val read = inputStream.read(buffer, 0, minOf(buffer.size, contentLength - totalRead))
|
||||||
|
if (read == -1) break
|
||||||
|
responseStream.write(buffer, 0, read)
|
||||||
|
totalRead += read
|
||||||
|
}
|
||||||
|
|
||||||
|
responseStream.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun transferUntilEndOfStream(inputStream: InputStream, responseStream: OutputStream) {
|
||||||
|
val buffer = ByteArray(8192)
|
||||||
|
var read: Int
|
||||||
|
while (inputStream.read(buffer).also { read = it } >= 0) {
|
||||||
|
responseStream.write(buffer, 0, read)
|
||||||
|
}
|
||||||
|
|
||||||
|
responseStream.flush()
|
||||||
|
}
|
||||||
|
|
||||||
fun withContent(body: String) : HttpProxyHandler {
|
fun withContent(body: String) : HttpProxyHandler {
|
||||||
this.content = body;
|
this.content = body;
|
||||||
return this;
|
return this;
|
||||||
|
@ -6,10 +6,13 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
|
|||||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourceAuth
|
import com.futo.platformplayer.api.media.platforms.js.SourceAuth
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
|
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
|
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||||
import com.futo.platformplayer.matchesDomain
|
import com.futo.platformplayer.matchesDomain
|
||||||
|
|
||||||
class JSHttpClient : ManagedHttpClient {
|
class JSHttpClient : ManagedHttpClient {
|
||||||
private val _jsClient: JSClient?;
|
private val _jsClient: JSClient?;
|
||||||
|
private val _jsConfig: SourcePluginConfig?;
|
||||||
private val _auth: SourceAuth?;
|
private val _auth: SourceAuth?;
|
||||||
private val _captcha: SourceCaptchaData?;
|
private val _captcha: SourceCaptchaData?;
|
||||||
|
|
||||||
@ -20,8 +23,9 @@ class JSHttpClient : ManagedHttpClient {
|
|||||||
|
|
||||||
private var _currentCookieMap: HashMap<String, HashMap<String, String>>;
|
private var _currentCookieMap: HashMap<String, HashMap<String, String>>;
|
||||||
|
|
||||||
constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null) : super() {
|
constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, config: SourcePluginConfig? = null) : super() {
|
||||||
_jsClient = jsClient;
|
_jsClient = jsClient;
|
||||||
|
_jsConfig = config;
|
||||||
_auth = auth;
|
_auth = auth;
|
||||||
_captcha = captcha;
|
_captcha = captcha;
|
||||||
|
|
||||||
@ -87,7 +91,11 @@ class JSHttpClient : ManagedHttpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_jsClient?.validateUrlOrThrow(request.url.toString());
|
if(_jsClient != null)
|
||||||
|
_jsClient?.validateUrlOrThrow(request.url.toString());
|
||||||
|
else if (_jsConfig != null && !_jsConfig.isUrlAllowed(request.url.toString()))
|
||||||
|
throw ScriptImplementationException(_jsConfig, "Attempted to access non-whitelisted url: ${request.url.toString()}\nAdd it to your config");
|
||||||
|
|
||||||
return newBuilder?.let { it.build() } ?: request;
|
return newBuilder?.let { it.build() } ?: request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,7 +52,7 @@ class DedupContentPager : IPager<IPlatformContent>, IAsyncPager<IPlatformContent
|
|||||||
val sameItems = results.filter { isSameItem(result, it) };
|
val sameItems = results.filter { isSameItem(result, it) };
|
||||||
val platformItemMap = sameItems.groupBy { it.id.pluginId }.mapValues { (_, items) -> items.first() }
|
val platformItemMap = sameItems.groupBy { it.id.pluginId }.mapValues { (_, items) -> items.first() }
|
||||||
val bestPlatform = _preferredPlatform.map { it.lowercase() }.firstOrNull { platformItemMap.containsKey(it) }
|
val bestPlatform = _preferredPlatform.map { it.lowercase() }.firstOrNull { platformItemMap.containsKey(it) }
|
||||||
val bestItem = platformItemMap[bestPlatform] ?: sameItems.first()
|
val bestItem = platformItemMap[bestPlatform] ?: sameItems.firstOrNull();
|
||||||
|
|
||||||
resultsToRemove.addAll(sameItems.filter { it != bestItem });
|
resultsToRemove.addAll(sameItems.filter { it != bestItem });
|
||||||
}
|
}
|
||||||
|
@ -6,33 +6,25 @@ import android.app.PendingIntent
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.drawable.Drawable
|
import android.graphics.drawable.Drawable
|
||||||
import android.media.MediaSession2Service.MediaNotification
|
|
||||||
import androidx.concurrent.futures.CallbackToFutureAdapter
|
|
||||||
import androidx.concurrent.futures.ResolvableFuture
|
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.ListenableWorker
|
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.request.target.CustomTarget
|
import com.bumptech.glide.request.target.CustomTarget
|
||||||
import com.bumptech.glide.request.transition.Transition
|
import com.bumptech.glide.request.transition.Transition
|
||||||
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
import com.futo.platformplayer.cache.ChannelContentCache
|
|
||||||
import com.futo.platformplayer.getNowDiffSeconds
|
import com.futo.platformplayer.getNowDiffSeconds
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.models.Subscription
|
import com.futo.platformplayer.models.Subscription
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StateNotifications
|
||||||
import com.futo.platformplayer.states.StateSubscriptions
|
import com.futo.platformplayer.states.StateSubscriptions
|
||||||
import com.futo.platformplayer.views.adapters.viewholders.TabViewHolder
|
import com.futo.platformplayer.toHumanNowDiffString
|
||||||
import com.google.common.util.concurrent.ListenableFuture
|
import com.futo.platformplayer.toHumanNowDiffStringMinDay
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
@ -54,8 +46,10 @@ class BackgroundWorker(private val appContext: Context, private val workerParams
|
|||||||
this.setSound(null, null);
|
this.setSound(null, null);
|
||||||
};
|
};
|
||||||
notificationManager.createNotificationChannel(notificationChannel);
|
notificationManager.createNotificationChannel(notificationChannel);
|
||||||
|
val contentChannel = StateNotifications.instance.contentNotifChannel
|
||||||
|
notificationManager.createNotificationChannel(contentChannel);
|
||||||
try {
|
try {
|
||||||
doSubscriptionUpdating(notificationManager, notificationChannel);
|
doSubscriptionUpdating(notificationManager, notificationChannel, contentChannel);
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
catch(ex: Throwable) {
|
||||||
exception = ex;
|
exception = ex;
|
||||||
@ -77,13 +71,13 @@ class BackgroundWorker(private val appContext: Context, private val workerParams
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
suspend fun doSubscriptionUpdating(manager: NotificationManager, notificationChannel: NotificationChannel) {
|
suspend fun doSubscriptionUpdating(manager: NotificationManager, backgroundChannel: NotificationChannel, contentChannel: NotificationChannel) {
|
||||||
val notif = NotificationCompat.Builder(appContext, notificationChannel.id)
|
val notif = NotificationCompat.Builder(appContext, backgroundChannel.id)
|
||||||
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground)
|
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground)
|
||||||
.setContentTitle("Grayjay")
|
.setContentTitle("Grayjay")
|
||||||
.setContentText("Updating subscriptions...")
|
.setContentText("Updating subscriptions...")
|
||||||
.setSilent(true)
|
.setSilent(true)
|
||||||
.setChannelId(notificationChannel.id)
|
.setChannelId(backgroundChannel.id)
|
||||||
.setProgress(1, 0, true);
|
.setProgress(1, 0, true);
|
||||||
|
|
||||||
manager.notify(12, notif.build());
|
manager.notify(12, notif.build());
|
||||||
@ -94,6 +88,7 @@ class BackgroundWorker(private val appContext: Context, private val workerParams
|
|||||||
val newItems = mutableListOf<IPlatformContent>();
|
val newItems = mutableListOf<IPlatformContent>();
|
||||||
|
|
||||||
val now = OffsetDateTime.now();
|
val now = OffsetDateTime.now();
|
||||||
|
val threeDays = now.minusDays(4);
|
||||||
val contentNotifs = mutableListOf<Pair<Subscription, IPlatformContent>>();
|
val contentNotifs = mutableListOf<Pair<Subscription, IPlatformContent>>();
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val results = StateSubscriptions.instance.getSubscriptionsFeedWithExceptions(true, false,this, { progress, total ->
|
val results = StateSubscriptions.instance.getSubscriptionsFeedWithExceptions(true, false,this, { progress, total ->
|
||||||
@ -111,8 +106,14 @@ class BackgroundWorker(private val appContext: Context, private val workerParams
|
|||||||
synchronized(newSubChanges) {
|
synchronized(newSubChanges) {
|
||||||
if(!newSubChanges.contains(sub)) {
|
if(!newSubChanges.contains(sub)) {
|
||||||
newSubChanges.add(sub);
|
newSubChanges.add(sub);
|
||||||
if(sub.doNotifications && content.datetime?.let { it < now } == true)
|
if(sub.doNotifications) {
|
||||||
contentNotifs.add(Pair(sub, content));
|
if(content.datetime != null) {
|
||||||
|
if(content.datetime!! <= now.plusMinutes(StateNotifications.instance.plannedWarningMinutesEarly) && content.datetime!! > threeDays)
|
||||||
|
contentNotifs.add(Pair(sub, content));
|
||||||
|
else if(content.datetime!! > now.plusMinutes(StateNotifications.instance.plannedWarningMinutesEarly) && Settings.instance.notifications.plannedContentNotification)
|
||||||
|
StateNotifications.instance.scheduleContentNotification(applicationContext, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
newItems.add(content);
|
newItems.add(content);
|
||||||
}
|
}
|
||||||
@ -135,22 +136,7 @@ class BackgroundWorker(private val appContext: Context, private val workerParams
|
|||||||
val items = contentNotifs.take(5).toList()
|
val items = contentNotifs.take(5).toList()
|
||||||
for(i in items.indices) {
|
for(i in items.indices) {
|
||||||
val contentNotif = items.get(i);
|
val contentNotif = items.get(i);
|
||||||
val thumbnail = if(contentNotif.second is IPlatformVideo) (contentNotif.second as IPlatformVideo).thumbnails.getHQThumbnail()
|
StateNotifications.instance.notifyNewContentWithThumbnail(appContext, manager, contentChannel, 13 + i, contentNotif.second);
|
||||||
else null;
|
|
||||||
if(thumbnail != null)
|
|
||||||
Glide.with(appContext).asBitmap()
|
|
||||||
.load(thumbnail)
|
|
||||||
.into(object: CustomTarget<Bitmap>() {
|
|
||||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
|
||||||
notifyNewContent(manager, notificationChannel, 13 + i, contentNotif.first, contentNotif.second, resource);
|
|
||||||
}
|
|
||||||
override fun onLoadCleared(placeholder: Drawable?) {}
|
|
||||||
override fun onLoadFailed(errorDrawable: Drawable?) {
|
|
||||||
notifyNewContent(manager, notificationChannel, 13 + i, contentNotif.first, contentNotif.second, null);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
else
|
|
||||||
notifyNewContent(manager, notificationChannel, 13 + i, contentNotif.first, contentNotif.second, null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
catch(ex: Throwable) {
|
||||||
@ -165,20 +151,4 @@ class BackgroundWorker(private val appContext: Context, private val workerParams
|
|||||||
.setSilent(true)
|
.setSilent(true)
|
||||||
.setChannelId(notificationChannel.id).build());*/
|
.setChannelId(notificationChannel.id).build());*/
|
||||||
}
|
}
|
||||||
|
|
||||||
fun notifyNewContent(manager: NotificationManager, notificationChannel: NotificationChannel, id: Int, sub: Subscription, content: IPlatformContent, thumbnail: Bitmap? = null) {
|
|
||||||
val notifBuilder = NotificationCompat.Builder(appContext, notificationChannel.id)
|
|
||||||
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground)
|
|
||||||
.setContentTitle("New by [${sub.channel.name}]")
|
|
||||||
.setContentText("${content.name}")
|
|
||||||
.setSilent(true)
|
|
||||||
.setContentIntent(PendingIntent.getActivity(this.appContext, 0, MainActivity.getVideoIntent(this.appContext, content.url),
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
|
||||||
.setChannelId(notificationChannel.id);
|
|
||||||
if(thumbnail != null) {
|
|
||||||
//notifBuilder.setLargeIcon(thumbnail);
|
|
||||||
notifBuilder.setStyle(NotificationCompat.BigPictureStyle().bigPicture(thumbnail).bigLargeIcon(null as Bitmap?));
|
|
||||||
}
|
|
||||||
manager.notify(id, notifBuilder.build());
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -1,37 +0,0 @@
|
|||||||
package com.futo.platformplayer.builders
|
|
||||||
|
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
|
||||||
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
|
||||||
import java.io.PrintWriter
|
|
||||||
import java.io.StringWriter
|
|
||||||
|
|
||||||
class HlsBuilder {
|
|
||||||
companion object{
|
|
||||||
fun generateOnDemandHLS(vidSource: IVideoSource, vidUrl: String, audioSource: IAudioSource?, audioUrl: String?, subtitleSource: ISubtitleSource?, subtitleUrl: String?): String {
|
|
||||||
val hlsBuilder = StringWriter()
|
|
||||||
PrintWriter(hlsBuilder).use { writer ->
|
|
||||||
writer.println("#EXTM3U")
|
|
||||||
|
|
||||||
// Audio
|
|
||||||
if (audioSource != null && audioUrl != null) {
|
|
||||||
val audioFormat = audioSource.container.substringAfter("/")
|
|
||||||
writer.println("#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audio\",LANGUAGE=\"en\",NAME=\"English\",AUTOSELECT=YES,DEFAULT=YES,URI=\"${audioUrl.replace("&", "&")}\",FORMAT=\"$audioFormat\"")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subtitles
|
|
||||||
if (subtitleSource != null && subtitleUrl != null) {
|
|
||||||
val subtitleFormat = subtitleSource.format ?: "text/vtt"
|
|
||||||
writer.println("#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",LANGUAGE=\"en\",NAME=\"English\",AUTOSELECT=YES,DEFAULT=YES,URI=\"${subtitleUrl.replace("&", "&")}\",FORMAT=\"$subtitleFormat\"")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Video
|
|
||||||
val videoFormat = vidSource.container.substringAfter("/")
|
|
||||||
writer.println("#EXT-X-STREAM-INF:BANDWIDTH=100000,CODECS=\"${vidSource.codec}\",RESOLUTION=${vidSource.width}x${vidSource.height}${if (audioSource != null) ",AUDIO=\"audio\"" else ""}${if (subtitleSource != null) ",SUBTITLES=\"subs\"" else ""},FORMAT=\"$videoFormat\"")
|
|
||||||
writer.println(vidUrl.replace("&", "&"))
|
|
||||||
}
|
|
||||||
|
|
||||||
return hlsBuilder.toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -58,6 +58,14 @@ class ChannelContentCache {
|
|||||||
uncacheContent(content);
|
uncacheContent(content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fun clearToday() {
|
||||||
|
val yesterday = OffsetDateTime.now().minusDays(1);
|
||||||
|
synchronized(_channelContents) {
|
||||||
|
for(channel in _channelContents)
|
||||||
|
for(content in channel.value.getItems().filter { it.datetime?.isAfter(yesterday) == true })
|
||||||
|
uncacheContent(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getChannelCachePager(channelUrl: String): PlatformContentPager {
|
fun getChannelCachePager(channelUrl: String): PlatformContentPager {
|
||||||
val validID = channelUrl.toSafeFileName();
|
val validID = channelUrl.toSafeFileName();
|
||||||
|
@ -69,7 +69,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.i(FastCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)");
|
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)");
|
||||||
|
|
||||||
time = resumePosition;
|
time = resumePosition;
|
||||||
_streamType = streamType;
|
_streamType = streamType;
|
||||||
@ -314,6 +314,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
connectionState = CastConnectionState.CONNECTING;
|
connectionState = CastConnectionState.CONNECTING;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
_socket?.close()
|
||||||
_socket = factory.createSocket(usedRemoteAddress, port) as SSLSocket;
|
_socket = factory.createSocket(usedRemoteAddress, port) as SSLSocket;
|
||||||
_socket?.startHandshake();
|
_socket?.startHandshake();
|
||||||
Logger.i(TAG, "Successfully connected to Chromecast at $usedRemoteAddress:$port");
|
Logger.i(TAG, "Successfully connected to Chromecast at $usedRemoteAddress:$port");
|
||||||
@ -324,7 +325,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.i(TAG, "Failed to authenticate to Chromecast.", e);
|
Logger.i(TAG, "Failed to authenticate to Chromecast.", e);
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: Throwable) {
|
||||||
_socket?.close();
|
_socket?.close();
|
||||||
Logger.i(TAG, "Failed to connect to Chromecast.", e);
|
Logger.i(TAG, "Failed to connect to Chromecast.", e);
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ import android.content.ContentResolver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import com.futo.platformplayer.*
|
import com.futo.platformplayer.*
|
||||||
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.api.http.server.ManagedHttpServer
|
import com.futo.platformplayer.api.http.server.ManagedHttpServer
|
||||||
import com.futo.platformplayer.api.http.server.handlers.*
|
import com.futo.platformplayer.api.http.server.handlers.*
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.*
|
import com.futo.platformplayer.api.media.models.streams.sources.*
|
||||||
@ -15,6 +16,7 @@ import com.futo.platformplayer.constructs.Event2
|
|||||||
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||||
|
import com.futo.platformplayer.parsers.HLS
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
@ -45,6 +47,7 @@ class StateCasting {
|
|||||||
val onActiveDevicePlayChanged = Event1<Boolean>();
|
val onActiveDevicePlayChanged = Event1<Boolean>();
|
||||||
val onActiveDeviceTimeChanged = Event1<Double>();
|
val onActiveDeviceTimeChanged = Event1<Double>();
|
||||||
var activeDevice: CastingDevice? = null;
|
var activeDevice: CastingDevice? = null;
|
||||||
|
private val _client = ManagedHttpClient();
|
||||||
|
|
||||||
val isCasting: Boolean get() = activeDevice != null;
|
val isCasting: Boolean get() = activeDevice != null;
|
||||||
|
|
||||||
@ -331,20 +334,25 @@ class StateCasting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sourceCount > 1) {
|
if (sourceCount > 1) {
|
||||||
if (ad is AirPlayCastingDevice) {
|
|
||||||
StateApp.withContext(false) { context -> UIDialogs.toast(context, "AirPlay does not support DASH. Try ChromeCast or FastCast for casting this video."); };
|
|
||||||
ad.stopCasting();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) {
|
if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) {
|
||||||
castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition);
|
if (ad is AirPlayCastingDevice) {
|
||||||
|
Logger.i(TAG, "Casting as local HLS");
|
||||||
|
castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition);
|
||||||
|
} else {
|
||||||
|
Logger.i(TAG, "Casting as local DASH");
|
||||||
|
castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
if (ad is FastCastCastingDevice) {
|
if (ad is FastCastCastingDevice) {
|
||||||
|
Logger.i(TAG, "Casting as DASH direct");
|
||||||
castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition);
|
castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition);
|
||||||
|
} else if (ad is AirPlayCastingDevice) {
|
||||||
|
Logger.i(TAG, "Casting as HLS indirect");
|
||||||
|
castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition);
|
||||||
} else {
|
} else {
|
||||||
|
Logger.i(TAG, "Casting as DASH indirect");
|
||||||
castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition);
|
castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition);
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@ -353,19 +361,35 @@ class StateCasting {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (videoSource is IVideoUrlSource)
|
if (videoSource is IVideoUrlSource) {
|
||||||
ad.loadVideo("BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble());
|
Logger.i(TAG, "Casting as singular video");
|
||||||
else if(videoSource is IHLSManifestSource)
|
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble());
|
||||||
ad.loadVideo("BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble());
|
} else if (audioSource is IAudioUrlSource) {
|
||||||
else if (audioSource is IAudioUrlSource)
|
Logger.i(TAG, "Casting as singular audio");
|
||||||
ad.loadVideo("BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble());
|
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble());
|
||||||
else if(audioSource is IHLSManifestAudioSource)
|
} else if(videoSource is IHLSManifestSource) {
|
||||||
ad.loadVideo("BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble());
|
if (ad is ChromecastCastingDevice) {
|
||||||
else if (videoSource is LocalVideoSource)
|
Logger.i(TAG, "Casting as proxied HLS");
|
||||||
|
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition);
|
||||||
|
} else {
|
||||||
|
Logger.i(TAG, "Casting as non-proxied HLS");
|
||||||
|
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble());
|
||||||
|
}
|
||||||
|
} else if(audioSource is IHLSManifestAudioSource) {
|
||||||
|
if (ad is ChromecastCastingDevice) {
|
||||||
|
Logger.i(TAG, "Casting as proxied audio HLS");
|
||||||
|
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition);
|
||||||
|
} else {
|
||||||
|
Logger.i(TAG, "Casting as non-proxied audio HLS");
|
||||||
|
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble());
|
||||||
|
}
|
||||||
|
} else if (videoSource is LocalVideoSource) {
|
||||||
|
Logger.i(TAG, "Casting as local video");
|
||||||
castLocalVideo(video, videoSource, resumePosition);
|
castLocalVideo(video, videoSource, resumePosition);
|
||||||
else if (audioSource is LocalAudioSource)
|
} else if (audioSource is LocalAudioSource) {
|
||||||
|
Logger.i(TAG, "Casting as local audio");
|
||||||
castLocalAudio(video, audioSource, resumePosition);
|
castLocalAudio(video, audioSource, resumePosition);
|
||||||
else {
|
} else {
|
||||||
var str = listOf(
|
var str = listOf(
|
||||||
if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null,
|
if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null,
|
||||||
if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null,
|
if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null,
|
||||||
@ -402,15 +426,23 @@ class StateCasting {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun castVideoIndirect() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun castAudioIndirect() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double) : List<String> {
|
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double) : List<String> {
|
||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
|
|
||||||
val url = "http://${ad.localAddress}:${_castServer.port}";
|
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||||
val id = UUID.randomUUID();
|
val id = UUID.randomUUID();
|
||||||
val videoPath = "/video-${id}"
|
val videoPath = "/video-${id}"
|
||||||
val videoUrl = url + videoPath;
|
val videoUrl = url + videoPath;
|
||||||
|
|
||||||
_castServer.addHandler(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath)
|
HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath)
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
@ -424,12 +456,12 @@ class StateCasting {
|
|||||||
private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double) : List<String> {
|
private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double) : List<String> {
|
||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
|
|
||||||
val url = "http://${ad.localAddress}:${_castServer.port}";
|
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||||
val id = UUID.randomUUID();
|
val id = UUID.randomUUID();
|
||||||
val audioPath = "/audio-${id}"
|
val audioPath = "/audio-${id}"
|
||||||
val audioUrl = url + audioPath;
|
val audioUrl = url + audioPath;
|
||||||
|
|
||||||
_castServer.addHandler(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath)
|
HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath)
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
@ -440,11 +472,106 @@ class StateCasting {
|
|||||||
return listOf(audioUrl);
|
return listOf(audioUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double): List<String> {
|
||||||
|
val ad = activeDevice ?: return listOf()
|
||||||
|
|
||||||
|
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"
|
||||||
|
val id = UUID.randomUUID()
|
||||||
|
|
||||||
|
val hlsPath = "/hls-${id}"
|
||||||
|
val videoPath = "/video-${id}"
|
||||||
|
val audioPath = "/audio-${id}"
|
||||||
|
val subtitlePath = "/subtitle-${id}"
|
||||||
|
|
||||||
|
val hlsUrl = url + hlsPath
|
||||||
|
val videoUrl = url + videoPath
|
||||||
|
val audioUrl = url + audioPath
|
||||||
|
val subtitleUrl = url + subtitlePath
|
||||||
|
|
||||||
|
val mediaRenditions = arrayListOf<HLS.MediaRendition>()
|
||||||
|
val variantPlaylistReferences = arrayListOf<HLS.VariantPlaylistReference>()
|
||||||
|
|
||||||
|
if (videoSource != null) {
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath)
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("castLocalHls")
|
||||||
|
|
||||||
|
val duration = videoSource.duration
|
||||||
|
val videoVariantPlaylistPath = "/video-playlist-${id}"
|
||||||
|
val videoVariantPlaylistUrl = url + videoVariantPlaylistPath
|
||||||
|
val videoVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), videoUrl))
|
||||||
|
val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, videoVariantPlaylistSegments)
|
||||||
|
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpConstantHandler("GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(),
|
||||||
|
"application/vnd.apple.mpegurl")
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("castLocalHls")
|
||||||
|
|
||||||
|
variantPlaylistReferences.add(HLS.VariantPlaylistReference(videoVariantPlaylistUrl, HLS.StreamInfo(
|
||||||
|
videoSource.bitrate, "${videoSource.width}x${videoSource.height}", videoSource.codec, null, null, if (audioSource != null) "audio" else null, if (subtitleSource != null) "subtitles" else null, null, null)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioSource != null) {
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath)
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("castLocalHls")
|
||||||
|
|
||||||
|
val duration = audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown")
|
||||||
|
val audioVariantPlaylistPath = "/audio-playlist-${id}"
|
||||||
|
val audioVariantPlaylistUrl = url + audioVariantPlaylistPath
|
||||||
|
val audioVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), audioUrl))
|
||||||
|
val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, audioVariantPlaylistSegments)
|
||||||
|
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpConstantHandler("GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(),
|
||||||
|
"application/vnd.apple.mpegurl")
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("castLocalHls")
|
||||||
|
|
||||||
|
mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "en", "english", true, true, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subtitleSource != null) {
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath)
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("castLocalHls")
|
||||||
|
|
||||||
|
val duration = videoSource?.duration ?: audioSource?.duration ?: throw Exception("Duration unknown")
|
||||||
|
val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}"
|
||||||
|
val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath
|
||||||
|
val subtitleVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), subtitleUrl))
|
||||||
|
val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, subtitleVariantPlaylistSegments)
|
||||||
|
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpConstantHandler("GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(),
|
||||||
|
"application/vnd.apple.mpegurl")
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("castLocalHls")
|
||||||
|
|
||||||
|
mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "en", "english", true, true, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
val masterPlaylist = HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true)
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpConstantHandler("GET", hlsPath, masterPlaylist.buildM3U8(),
|
||||||
|
"application/vnd.apple.mpegurl")
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("castLocalHls")
|
||||||
|
|
||||||
|
Logger.i(TAG, "added new castLocalHls handlers (hlsPath: $hlsPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath).")
|
||||||
|
ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble())
|
||||||
|
|
||||||
|
return listOf(hlsUrl, videoUrl, audioUrl, subtitleUrl)
|
||||||
|
}
|
||||||
|
|
||||||
private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double) : List<String> {
|
private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double) : List<String> {
|
||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
|
|
||||||
val url = "http://${ad.localAddress}:${_castServer.port}";
|
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||||
val id = UUID.randomUUID();
|
val id = UUID.randomUUID();
|
||||||
|
|
||||||
val dashPath = "/dash-${id}"
|
val dashPath = "/dash-${id}"
|
||||||
@ -457,43 +584,28 @@ class StateCasting {
|
|||||||
val audioUrl = url + audioPath;
|
val audioUrl = url + audioPath;
|
||||||
val subtitleUrl = url + subtitlePath;
|
val subtitleUrl = url + subtitlePath;
|
||||||
|
|
||||||
_castServer.addHandler(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpConstantHandler("GET", dashPath, DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitleUrl),
|
HttpConstantHandler("GET", dashPath, DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitleUrl),
|
||||||
"application/dash+xml")
|
"application/dash+xml")
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
if (videoSource != null) {
|
if (videoSource != null) {
|
||||||
_castServer.addHandler(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath)
|
HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath)
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
_castServer.addHandler(
|
|
||||||
HttpOptionsAllowHandler(videoPath)
|
|
||||||
.withHeader("Access-Control-Allow-Origin", "*")
|
|
||||||
.withHeader("Connection", "keep-alive"))
|
|
||||||
.withTag("cast");
|
|
||||||
}
|
}
|
||||||
if (audioSource != null) {
|
if (audioSource != null) {
|
||||||
_castServer.addHandler(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath)
|
HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath)
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
_castServer.addHandler(
|
|
||||||
HttpOptionsAllowHandler(audioPath)
|
|
||||||
.withHeader("Access-Control-Allow-Origin", "*")
|
|
||||||
.withHeader("Connection", "keep-alive"))
|
|
||||||
.withTag("cast");
|
|
||||||
}
|
}
|
||||||
if (subtitleSource != null) {
|
if (subtitleSource != null) {
|
||||||
_castServer.addHandler(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath)
|
HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath)
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
_castServer.addHandler(
|
|
||||||
HttpOptionsAllowHandler(subtitlePath)
|
|
||||||
.withHeader("Access-Control-Allow-Origin", "*")
|
|
||||||
.withHeader("Connection", "keep-alive"))
|
|
||||||
.withTag("cast");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.i(TAG, "added new castLocalDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath).");
|
Logger.i(TAG, "added new castLocalDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath).");
|
||||||
@ -505,7 +617,7 @@ class StateCasting {
|
|||||||
private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> {
|
private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> {
|
||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
|
|
||||||
val url = "http://${ad.localAddress}:${_castServer.port}";
|
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||||
val id = UUID.randomUUID();
|
val id = UUID.randomUUID();
|
||||||
val subtitlePath = "/subtitle-${id}";
|
val subtitlePath = "/subtitle-${id}";
|
||||||
|
|
||||||
@ -527,7 +639,7 @@ class StateCasting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (content != null) {
|
if (content != null) {
|
||||||
_castServer.addHandler(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
|
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
@ -547,13 +659,311 @@ class StateCasting {
|
|||||||
return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "");
|
return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun castProxiedHls(video: IPlatformVideoDetails, sourceUrl: String, codec: String?, resumePosition: Double): List<String> {
|
||||||
|
_castServer.removeAllHandlers("castProxiedHlsMaster")
|
||||||
|
|
||||||
|
val ad = activeDevice ?: return listOf();
|
||||||
|
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||||
|
|
||||||
|
val id = UUID.randomUUID();
|
||||||
|
val hlsPath = "/hls-${id}"
|
||||||
|
val hlsUrl = url + hlsPath
|
||||||
|
Logger.i(TAG, "HLS url: $hlsUrl");
|
||||||
|
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(HttpFuntionHandler("GET", hlsPath) { masterContext ->
|
||||||
|
_castServer.removeAllHandlers("castProxiedHlsVariant")
|
||||||
|
|
||||||
|
val headers = masterContext.headers.clone()
|
||||||
|
headers["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||||
|
|
||||||
|
val masterPlaylistResponse = _client.get(sourceUrl)
|
||||||
|
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
|
||||||
|
|
||||||
|
val masterPlaylistContent = masterPlaylistResponse.body?.string()
|
||||||
|
?: throw Exception("Master playlist content is empty")
|
||||||
|
|
||||||
|
val masterPlaylist: HLS.MasterPlaylist
|
||||||
|
try {
|
||||||
|
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
|
||||||
|
//This is a variant playlist, not a master playlist
|
||||||
|
Logger.i(TAG, "HLS casting as variant playlist (codec: $codec): $hlsUrl");
|
||||||
|
|
||||||
|
val vpHeaders = masterContext.headers.clone()
|
||||||
|
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||||
|
|
||||||
|
val variantPlaylist = HLS.parseVariantPlaylist(masterPlaylistContent, sourceUrl)
|
||||||
|
val proxiedVariantPlaylist = proxyVariantPlaylist(url, id, variantPlaylist, video.isLive)
|
||||||
|
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||||
|
masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||||
|
return@HttpFuntionHandler
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.i(TAG, "HLS casting as master playlist: $hlsUrl");
|
||||||
|
|
||||||
|
val newVariantPlaylistRefs = arrayListOf<HLS.VariantPlaylistReference>()
|
||||||
|
val newMediaRenditions = arrayListOf<HLS.MediaRendition>()
|
||||||
|
val newMasterPlaylist = HLS.MasterPlaylist(newVariantPlaylistRefs, newMediaRenditions, masterPlaylist.sessionDataList, masterPlaylist.independentSegments)
|
||||||
|
|
||||||
|
for (variantPlaylistRef in masterPlaylist.variantPlaylistsRefs) {
|
||||||
|
val playlistId = UUID.randomUUID();
|
||||||
|
val newPlaylistPath = "/hls-playlist-${playlistId}"
|
||||||
|
val newPlaylistUrl = url + newPlaylistPath;
|
||||||
|
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(HttpFuntionHandler("GET", newPlaylistPath) { vpContext ->
|
||||||
|
val vpHeaders = vpContext.headers.clone()
|
||||||
|
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||||
|
|
||||||
|
val response = _client.get(variantPlaylistRef.url)
|
||||||
|
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
|
||||||
|
|
||||||
|
val vpContent = response.body?.string()
|
||||||
|
?: throw Exception("Variant playlist content is empty")
|
||||||
|
|
||||||
|
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url)
|
||||||
|
val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive)
|
||||||
|
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||||
|
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||||
|
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant")
|
||||||
|
|
||||||
|
newVariantPlaylistRefs.add(HLS.VariantPlaylistReference(
|
||||||
|
newPlaylistUrl,
|
||||||
|
variantPlaylistRef.streamInfo
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (mediaRendition in masterPlaylist.mediaRenditions) {
|
||||||
|
val playlistId = UUID.randomUUID()
|
||||||
|
|
||||||
|
var newPlaylistUrl: String? = null
|
||||||
|
if (mediaRendition.uri != null) {
|
||||||
|
val newPlaylistPath = "/hls-playlist-${playlistId}"
|
||||||
|
newPlaylistUrl = url + newPlaylistPath
|
||||||
|
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(HttpFuntionHandler("GET", newPlaylistPath) { vpContext ->
|
||||||
|
val vpHeaders = vpContext.headers.clone()
|
||||||
|
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||||
|
|
||||||
|
val response = _client.get(mediaRendition.uri)
|
||||||
|
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
|
||||||
|
|
||||||
|
val vpContent = response.body?.string()
|
||||||
|
?: throw Exception("Variant playlist content is empty")
|
||||||
|
|
||||||
|
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, mediaRendition.uri)
|
||||||
|
val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive)
|
||||||
|
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||||
|
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||||
|
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant")
|
||||||
|
}
|
||||||
|
|
||||||
|
newMediaRenditions.add(HLS.MediaRendition(
|
||||||
|
mediaRendition.type,
|
||||||
|
newPlaylistUrl,
|
||||||
|
mediaRendition.groupID,
|
||||||
|
mediaRendition.language,
|
||||||
|
mediaRendition.name,
|
||||||
|
mediaRendition.isDefault,
|
||||||
|
mediaRendition.isAutoSelect,
|
||||||
|
mediaRendition.isForced
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
masterContext.respondCode(200, headers, newMasterPlaylist.buildM3U8());
|
||||||
|
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsMaster")
|
||||||
|
|
||||||
|
Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath).");
|
||||||
|
|
||||||
|
//ChromeCast is sometimes funky with resume position 0
|
||||||
|
val hackfixResumePosition = if (ad is ChromecastCastingDevice && !video.isLive && resumePosition == 0.0) 0.1 else resumePosition;
|
||||||
|
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, hackfixResumePosition, video.duration.toDouble());
|
||||||
|
|
||||||
|
return listOf(hlsUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, isLive: Boolean, proxySegments: Boolean = true): HLS.VariantPlaylist {
|
||||||
|
val newSegments = arrayListOf<HLS.Segment>()
|
||||||
|
|
||||||
|
if (proxySegments) {
|
||||||
|
variantPlaylist.segments.forEachIndexed { index, segment ->
|
||||||
|
val sequenceNumber = (variantPlaylist.mediaSequence ?: 0) + index.toLong()
|
||||||
|
newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newSegments.addAll(variantPlaylist.segments)
|
||||||
|
}
|
||||||
|
|
||||||
|
return HLS.VariantPlaylist(
|
||||||
|
variantPlaylist.version,
|
||||||
|
variantPlaylist.targetDuration,
|
||||||
|
variantPlaylist.mediaSequence,
|
||||||
|
variantPlaylist.discontinuitySequence,
|
||||||
|
variantPlaylist.programDateTime,
|
||||||
|
variantPlaylist.playlistType,
|
||||||
|
variantPlaylist.streamInfo,
|
||||||
|
newSegments
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long): HLS.Segment {
|
||||||
|
if (segment is HLS.MediaSegment) {
|
||||||
|
val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}"
|
||||||
|
val newSegmentUrl = url + newSegmentPath;
|
||||||
|
|
||||||
|
if (_castServer.getHandler("GET", newSegmentPath) == null) {
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpProxyHandler("GET", newSegmentPath, segment.uri, true)
|
||||||
|
.withInjectedHost()
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("castProxiedHlsVariant")
|
||||||
|
}
|
||||||
|
|
||||||
|
return HLS.MediaSegment(
|
||||||
|
segment.duration,
|
||||||
|
newSegmentUrl
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return segment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> {
|
||||||
|
val ad = activeDevice ?: return listOf();
|
||||||
|
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||||
|
val id = UUID.randomUUID();
|
||||||
|
|
||||||
|
val hlsPath = "/hls-${id}"
|
||||||
|
|
||||||
|
val hlsUrl = url + hlsPath;
|
||||||
|
Logger.i(TAG, "HLS url: $hlsUrl");
|
||||||
|
|
||||||
|
val mediaRenditions = arrayListOf<HLS.MediaRendition>()
|
||||||
|
val variantPlaylistReferences = arrayListOf<HLS.VariantPlaylistReference>()
|
||||||
|
|
||||||
|
if (audioSource != null) {
|
||||||
|
val audioPath = "/audio-${id}"
|
||||||
|
val audioUrl = url + audioPath
|
||||||
|
|
||||||
|
val duration = audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown")
|
||||||
|
val audioVariantPlaylistPath = "/audio-playlist-${id}"
|
||||||
|
val audioVariantPlaylistUrl = url + audioVariantPlaylistPath
|
||||||
|
val audioVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), audioUrl))
|
||||||
|
val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, audioVariantPlaylistSegments)
|
||||||
|
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpConstantHandler("GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(),
|
||||||
|
"application/vnd.apple.mpegurl")
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("castHlsIndirectVariant");
|
||||||
|
|
||||||
|
mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "en", "english", true, true, true))
|
||||||
|
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
|
||||||
|
.withInjectedHost()
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("castHlsIndirectVariant");
|
||||||
|
}
|
||||||
|
|
||||||
|
val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) {
|
||||||
|
return@withContext subtitleSource.getSubtitlesURI();
|
||||||
|
} else null;
|
||||||
|
|
||||||
|
var subtitlesUrl: String? = null;
|
||||||
|
if (subtitlesUri != null) {
|
||||||
|
val subtitlePath = "/subtitles-${id}"
|
||||||
|
if(subtitlesUri.scheme == "file") {
|
||||||
|
var content: String? = null;
|
||||||
|
val inputStream = contentResolver.openInputStream(subtitlesUri);
|
||||||
|
inputStream?.use { stream ->
|
||||||
|
val reader = stream.bufferedReader();
|
||||||
|
content = reader.use { it.readText() };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content != null) {
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("castHlsIndirectVariant");
|
||||||
|
}
|
||||||
|
|
||||||
|
subtitlesUrl = url + subtitlePath;
|
||||||
|
} else {
|
||||||
|
subtitlesUrl = subtitlesUri.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subtitlesUrl != null) {
|
||||||
|
val duration = videoSource?.duration ?: audioSource?.duration ?: throw Exception("Duration unknown")
|
||||||
|
val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}"
|
||||||
|
val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath
|
||||||
|
val subtitleVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), subtitlesUrl))
|
||||||
|
val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, subtitleVariantPlaylistSegments)
|
||||||
|
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpConstantHandler("GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(),
|
||||||
|
"application/vnd.apple.mpegurl")
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("castHlsIndirectVariant");
|
||||||
|
|
||||||
|
mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "en", "english", true, true, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoSource != null) {
|
||||||
|
val videoPath = "/video-${id}"
|
||||||
|
val videoUrl = url + videoPath
|
||||||
|
|
||||||
|
val duration = videoSource.duration
|
||||||
|
val videoVariantPlaylistPath = "/video-playlist-${id}"
|
||||||
|
val videoVariantPlaylistUrl = url + videoVariantPlaylistPath
|
||||||
|
val videoVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), videoUrl))
|
||||||
|
val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, videoVariantPlaylistSegments)
|
||||||
|
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpConstantHandler("GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(),
|
||||||
|
"application/vnd.apple.mpegurl")
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("castHlsIndirectVariant");
|
||||||
|
|
||||||
|
variantPlaylistReferences.add(HLS.VariantPlaylistReference(videoVariantPlaylistUrl, HLS.StreamInfo(
|
||||||
|
videoSource.bitrate ?: 0,
|
||||||
|
"${videoSource.width}x${videoSource.height}",
|
||||||
|
videoSource.codec,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
if (audioSource != null) "audio" else null,
|
||||||
|
if (subtitleSource != null) "subtitles" else null,
|
||||||
|
null, null)))
|
||||||
|
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
|
||||||
|
.withInjectedHost()
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("castHlsIndirectVariant");
|
||||||
|
}
|
||||||
|
|
||||||
|
val masterPlaylist = HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true)
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpConstantHandler("GET", hlsPath, masterPlaylist.buildM3U8(),
|
||||||
|
"application/vnd.apple.mpegurl")
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("castHlsIndirectMaster")
|
||||||
|
|
||||||
|
Logger.i(TAG, "added new castHls handlers (hlsPath: $hlsPath).");
|
||||||
|
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble());
|
||||||
|
|
||||||
|
return listOf(hlsUrl, videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> {
|
private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> {
|
||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
val proxyStreams = ad !is FastCastCastingDevice;
|
val proxyStreams = ad !is FastCastCastingDevice;
|
||||||
|
|
||||||
val url = "http://${ad.localAddress}:${_castServer.port}";
|
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||||
Logger.i(TAG, "DASH url: $url");
|
|
||||||
|
|
||||||
val id = UUID.randomUUID();
|
val id = UUID.randomUUID();
|
||||||
|
|
||||||
val dashPath = "/dash-${id}"
|
val dashPath = "/dash-${id}"
|
||||||
@ -562,6 +972,8 @@ class StateCasting {
|
|||||||
val subtitlePath = "/subtitle-${id}"
|
val subtitlePath = "/subtitle-${id}"
|
||||||
|
|
||||||
val dashUrl = url + dashPath;
|
val dashUrl = url + dashPath;
|
||||||
|
Logger.i(TAG, "DASH url: $dashUrl");
|
||||||
|
|
||||||
val videoUrl = if(proxyStreams) url + videoPath else videoSource?.getVideoUrl();
|
val videoUrl = if(proxyStreams) url + videoPath else videoSource?.getVideoUrl();
|
||||||
val audioUrl = if(proxyStreams) url + audioPath else audioSource?.getAudioUrl();
|
val audioUrl = if(proxyStreams) url + audioPath else audioSource?.getAudioUrl();
|
||||||
|
|
||||||
@ -583,7 +995,7 @@ class StateCasting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (content != null) {
|
if (content != null) {
|
||||||
_castServer.addHandler(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
|
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
@ -595,38 +1007,29 @@ class StateCasting {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_castServer.addHandler(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpConstantHandler("GET", dashPath, DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl),
|
HttpConstantHandler("GET", dashPath, DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl),
|
||||||
"application/dash+xml")
|
"application/dash+xml")
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
|
|
||||||
if (videoSource != null) {
|
if (videoSource != null) {
|
||||||
_castServer.addHandler(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl())
|
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
|
||||||
.withInjectedHost()
|
.withInjectedHost()
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
_castServer.addHandler(
|
|
||||||
HttpOptionsAllowHandler(videoPath)
|
|
||||||
.withHeader("Access-Control-Allow-Origin", "*")
|
|
||||||
.withHeader("Connection", "keep-alive"))
|
|
||||||
.withTag("cast");
|
|
||||||
}
|
}
|
||||||
if (audioSource != null) {
|
if (audioSource != null) {
|
||||||
_castServer.addHandler(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl())
|
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
|
||||||
.withInjectedHost()
|
.withInjectedHost()
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
_castServer.addHandler(
|
|
||||||
HttpOptionsAllowHandler(audioPath)
|
|
||||||
.withHeader("Access-Control-Allow-Origin", "*")
|
|
||||||
.withHeader("Connection", "keep-alivcontexte"))
|
|
||||||
.withTag("cast");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath).");
|
Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath).");
|
||||||
ad.loadVideo("BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble());
|
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble());
|
||||||
|
|
||||||
return listOf(dashUrl, videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
|
return listOf(dashUrl, videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
|
||||||
}
|
}
|
||||||
|
@ -24,6 +24,7 @@ import com.google.gson.JsonArray
|
|||||||
import com.google.gson.JsonParser
|
import com.google.gson.JsonParser
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.lang.reflect.InvocationTargetException
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.reflect.jvm.jvmErasure
|
import kotlin.reflect.jvm.jvmErasure
|
||||||
|
|
||||||
@ -185,7 +186,11 @@ class DeveloperEndpoints(private val context: Context) {
|
|||||||
val config = context.readContentJson<SourcePluginConfig>()
|
val config = context.readContentJson<SourcePluginConfig>()
|
||||||
try {
|
try {
|
||||||
_testPluginVariables.clear();
|
_testPluginVariables.clear();
|
||||||
_testPlugin = V8Plugin(StateApp.instance.context, config);
|
|
||||||
|
val client = JSHttpClient(null, null, null, config);
|
||||||
|
val clientAuth = JSHttpClient(null, null, null, config);
|
||||||
|
_testPlugin = V8Plugin(StateApp.instance.context, config, null, client, clientAuth);
|
||||||
|
|
||||||
context.respondJson(200, testPluginOrThrow.getPackageVariables());
|
context.respondJson(200, testPluginOrThrow.getPackageVariables());
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
catch(ex: Throwable) {
|
||||||
@ -235,7 +240,7 @@ class DeveloperEndpoints(private val context: Context) {
|
|||||||
}
|
}
|
||||||
LoginActivity.showLogin(StateApp.instance.context, config) {
|
LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||||
_testPluginVariables.clear();
|
_testPluginVariables.clear();
|
||||||
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null), JSHttpClient(null, it));
|
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
|
||||||
|
|
||||||
};
|
};
|
||||||
context.respondCode(200, "Login started");
|
context.respondCode(200, "Login started");
|
||||||
@ -311,6 +316,11 @@ class DeveloperEndpoints(private val context: Context) {
|
|||||||
val json = wrapRemoteResult(callResult, false);
|
val json = wrapRemoteResult(callResult, false);
|
||||||
context.respondCode(200, json, "application/json");
|
context.respondCode(200, json, "application/json");
|
||||||
}
|
}
|
||||||
|
catch(invocation: InvocationTargetException) {
|
||||||
|
val innerException = invocation.targetException;
|
||||||
|
Logger.e("DeveloperEndpoints", innerException.message, innerException);
|
||||||
|
context.respondCode(500, innerException::class.simpleName + ":" + innerException.message ?: "", "text/plain")
|
||||||
|
}
|
||||||
catch(ilEx: IllegalArgumentException) {
|
catch(ilEx: IllegalArgumentException) {
|
||||||
if(ilEx.message?.contains("does not exist") ?: false) {
|
if(ilEx.message?.contains("does not exist") ?: false) {
|
||||||
context.respondCode(400, ilEx.message ?: "", "text/plain");
|
context.respondCode(400, ilEx.message ?: "", "text/plain");
|
||||||
|
@ -20,6 +20,7 @@ import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComm
|
|||||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
|
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.dp
|
import com.futo.platformplayer.dp
|
||||||
|
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.selectBestImage
|
import com.futo.platformplayer.selectBestImage
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
@ -97,7 +98,7 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
|
|||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
Logger.i(TAG, "Started backfill");
|
Logger.i(TAG, "Started backfill");
|
||||||
processHandle.fullyBackfillServers()
|
processHandle.fullyBackfillServersAnnounceExceptions()
|
||||||
Logger.i(TAG, "Finished backfill");
|
Logger.i(TAG, "Finished backfill");
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.e(TAG, "Failed to backfill servers.", e);
|
Logger.e(TAG, "Failed to backfill servers.", e);
|
||||||
|
@ -11,6 +11,7 @@ import androidx.recyclerview.widget.RecyclerView
|
|||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||||
@ -58,6 +59,7 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
|
|||||||
val onChannelClicked = Event1<PlatformAuthorLink>();
|
val onChannelClicked = Event1<PlatformAuthorLink>();
|
||||||
val onAddToClicked = Event1<IPlatformContent>();
|
val onAddToClicked = Event1<IPlatformContent>();
|
||||||
val onAddToQueueClicked = Event1<IPlatformContent>();
|
val onAddToQueueClicked = Event1<IPlatformContent>();
|
||||||
|
val onLongPress = Event1<IPlatformContent>();
|
||||||
|
|
||||||
private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> {
|
private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> {
|
||||||
Logger.i(TAG, "getContentPager");
|
Logger.i(TAG, "getContentPager");
|
||||||
@ -151,13 +153,14 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
|
|||||||
|
|
||||||
_recyclerResults = view.findViewById(R.id.recycler_videos);
|
_recyclerResults = view.findViewById(R.id.recycler_videos);
|
||||||
|
|
||||||
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results).apply {
|
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar).apply {
|
||||||
this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit);
|
this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit);
|
||||||
this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit);
|
this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit);
|
||||||
this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit);
|
this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit);
|
||||||
this.onChannelClicked.subscribe(this@ChannelContentsFragment.onChannelClicked::emit);
|
this.onChannelClicked.subscribe(this@ChannelContentsFragment.onChannelClicked::emit);
|
||||||
this.onAddToClicked.subscribe(this@ChannelContentsFragment.onAddToClicked::emit);
|
this.onAddToClicked.subscribe(this@ChannelContentsFragment.onAddToClicked::emit);
|
||||||
this.onAddToQueueClicked.subscribe(this@ChannelContentsFragment.onAddToQueueClicked::emit);
|
this.onAddToQueueClicked.subscribe(this@ChannelContentsFragment.onAddToQueueClicked::emit);
|
||||||
|
this.onLongPress.subscribe(this@ChannelContentsFragment.onLongPress::emit);
|
||||||
}
|
}
|
||||||
|
|
||||||
_llmVideo = LinearLayoutManager(view.context);
|
_llmVideo = LinearLayoutManager(view.context);
|
||||||
|
@ -223,6 +223,12 @@ class ChannelFragment : MainFragment() {
|
|||||||
else -> {};
|
else -> {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
adapter.onLongPress.subscribe { content ->
|
||||||
|
_overlayContainer.let {
|
||||||
|
if(content is IPlatformVideo)
|
||||||
|
_slideUpOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it);
|
||||||
|
}
|
||||||
|
}
|
||||||
viewPager.adapter = adapter;
|
viewPager.adapter = adapter;
|
||||||
|
|
||||||
val tabLayoutMediator = TabLayoutMediator(tabs, viewPager) { tab, position ->
|
val tabLayoutMediator = TabLayoutMediator(tabs, viewPager) { tab, position ->
|
||||||
|
@ -37,6 +37,7 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
|||||||
override val visibleThreshold: Int get() = if (feedStyle == FeedStyle.PREVIEW) { 5 } else { 10 };
|
override val visibleThreshold: Int get() = if (feedStyle == FeedStyle.PREVIEW) { 5 } else { 10 };
|
||||||
protected lateinit var headerView: LinearLayout;
|
protected lateinit var headerView: LinearLayout;
|
||||||
private var _videoOptionsOverlay: SlideUpMenuOverlay? = null;
|
private var _videoOptionsOverlay: SlideUpMenuOverlay? = null;
|
||||||
|
protected open val shouldShowTimeBar: Boolean get() = true
|
||||||
|
|
||||||
constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
|
constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
|
||||||
|
|
||||||
@ -57,7 +58,7 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
|||||||
};
|
};
|
||||||
headerView = v;
|
headerView = v;
|
||||||
|
|
||||||
return PreviewContentListAdapter(context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(v)).apply {
|
return PreviewContentListAdapter(context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(v), arrayListOf(), shouldShowTimeBar).apply {
|
||||||
attachAdapterEvents(this);
|
attachAdapterEvents(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -84,6 +84,7 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||||||
private var _channelUrl: String? = null;
|
private var _channelUrl: String? = null;
|
||||||
|
|
||||||
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
|
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
|
||||||
|
override val shouldShowTimeBar: Boolean get() = Settings.instance.search.progressBar
|
||||||
|
|
||||||
constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) {
|
constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) {
|
||||||
_taskSearch = TaskHandler<String, IPager<IPlatformContent>>({fragment.lifecycleScope}, { query ->
|
_taskSearch = TaskHandler<String, IPager<IPlatformContent>>({fragment.lifecycleScope}, { query ->
|
||||||
|
@ -95,6 +95,7 @@ class HomeFragment : MainFragment() {
|
|||||||
private var _announcementsView: AnnouncementView;
|
private var _announcementsView: AnnouncementView;
|
||||||
|
|
||||||
private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>;
|
private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>;
|
||||||
|
override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar
|
||||||
|
|
||||||
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
|
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
|
||||||
_announcementsView = AnnouncementView(context, null).apply {
|
_announcementsView = AnnouncementView(context, null).apply {
|
||||||
|
@ -31,6 +31,7 @@ import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
|||||||
import com.futo.platformplayer.constructs.TaskHandler
|
import com.futo.platformplayer.constructs.TaskHandler
|
||||||
import com.futo.platformplayer.dp
|
import com.futo.platformplayer.dp
|
||||||
import com.futo.platformplayer.fixHtmlWhitespace
|
import com.futo.platformplayer.fixHtmlWhitespace
|
||||||
|
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
||||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||||
@ -363,7 +364,7 @@ class PostDetailFragment : MainFragment {
|
|||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
Logger.i(TAG, "Started backfill");
|
Logger.i(TAG, "Started backfill");
|
||||||
args.processHandle.fullyBackfillServers();
|
args.processHandle.fullyBackfillServersAnnounceExceptions();
|
||||||
Logger.i(TAG, "Finished backfill");
|
Logger.i(TAG, "Finished backfill");
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.e(TAG, "Failed to backfill servers", e)
|
Logger.e(TAG, "Failed to backfill servers", e)
|
||||||
|
@ -93,6 +93,8 @@ class SubscriptionsFeedFragment : MainFragment() {
|
|||||||
|
|
||||||
@SuppressLint("ViewConstructor")
|
@SuppressLint("ViewConstructor")
|
||||||
class SubscriptionsFeedView : ContentFeedView<SubscriptionsFeedFragment> {
|
class SubscriptionsFeedView : ContentFeedView<SubscriptionsFeedFragment> {
|
||||||
|
override val shouldShowTimeBar: Boolean get() = Settings.instance.subscriptions.progressBar
|
||||||
|
|
||||||
constructor(fragment: SubscriptionsFeedFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
|
constructor(fragment: SubscriptionsFeedFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
|
||||||
Logger.i(TAG, "SubscriptionsFeedFragment constructor()");
|
Logger.i(TAG, "SubscriptionsFeedFragment constructor()");
|
||||||
StateSubscriptions.instance.onGlobalSubscriptionsUpdateProgress.subscribe(this) { progress, total ->
|
StateSubscriptions.instance.onGlobalSubscriptionsUpdateProgress.subscribe(this) { progress, total ->
|
||||||
|
@ -125,6 +125,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
private var _searchVideo: IPlatformVideo? = null;
|
private var _searchVideo: IPlatformVideo? = null;
|
||||||
var video: IPlatformVideoDetails? = null
|
var video: IPlatformVideoDetails? = null
|
||||||
private set;
|
private set;
|
||||||
|
var videoLocal: VideoLocal? = null;
|
||||||
private var _playbackTracker: IPlaybackTracker? = null;
|
private var _playbackTracker: IPlaybackTracker? = null;
|
||||||
private var _historyIndex: DBHistory.Index? = null;
|
private var _historyIndex: DBHistory.Index? = null;
|
||||||
|
|
||||||
@ -1055,10 +1056,32 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_player.setPlaybackRate(Settings.instance.playback.getDefaultPlaybackSpeed());
|
_player.setPlaybackRate(Settings.instance.playback.getDefaultPlaybackSpeed());
|
||||||
}
|
}
|
||||||
|
|
||||||
val video = if(videoDetail is VideoLocal)
|
var videoLocal: VideoLocal? = null;
|
||||||
videoDetail;
|
var video: IPlatformVideoDetails? = null;
|
||||||
else //TODO: Update cached video if it exists with video
|
|
||||||
StateDownloads.instance.getCachedVideo(videoDetail.id) ?: videoDetail;
|
if(videoDetail is VideoLocal) {
|
||||||
|
videoLocal = videoDetail;
|
||||||
|
video = videoDetail;
|
||||||
|
val videoTask = StatePlatform.instance.getContentDetails(videoDetail.url);
|
||||||
|
videoTask.invokeOnCompletion { ex ->
|
||||||
|
if(ex != null) {
|
||||||
|
Logger.e(TAG, "Failed to fetch live video for offline video", ex);
|
||||||
|
return@invokeOnCompletion;
|
||||||
|
}
|
||||||
|
val result = videoTask.getCompleted();
|
||||||
|
if(this.video == videoDetail && result != null && result is IPlatformVideoDetails) {
|
||||||
|
this.video = result;
|
||||||
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
updateQualitySourcesOverlay(result, videoLocal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else { //TODO: Update cached video if it exists with video
|
||||||
|
videoLocal = StateDownloads.instance.getCachedVideo(videoDetail.id);
|
||||||
|
video = videoDetail;
|
||||||
|
}
|
||||||
|
this.videoLocal = videoLocal;
|
||||||
this.video = video;
|
this.video = video;
|
||||||
this._playbackTracker = null;
|
this._playbackTracker = null;
|
||||||
|
|
||||||
@ -1093,9 +1116,13 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
me._playbackTracker = tracker;
|
me._playbackTracker = tracker;
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
catch(ex: Throwable) {
|
||||||
withContext(Dispatchers.Main) {
|
Logger.e(TAG, "Playback tracker failed", ex);
|
||||||
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_get_playback_tracker), ex);
|
if(me.video?.isLive == true) withContext(Dispatchers.Main) {
|
||||||
|
UIDialogs.toast(context, context.getString(R.string.failed_to_get_playback_tracker));
|
||||||
};
|
};
|
||||||
|
else withContext(Dispatchers.Main) {
|
||||||
|
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_get_playback_tracker), ex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -1192,7 +1219,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
Logger.i(TAG, "Started backfill");
|
Logger.i(TAG, "Started backfill");
|
||||||
args.processHandle.fullyBackfillServers();
|
args.processHandle.fullyBackfillServersAnnounceExceptions();
|
||||||
Logger.i(TAG, "Finished backfill");
|
Logger.i(TAG, "Finished backfill");
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.e(TAG, "Failed to backfill servers", e)
|
Logger.e(TAG, "Failed to backfill servers", e)
|
||||||
@ -1246,7 +1273,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
|
|
||||||
//Overlay
|
//Overlay
|
||||||
updateQualitySourcesOverlay(video);
|
updateQualitySourcesOverlay(video, videoLocal);
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
@ -1503,6 +1530,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_overlay_quality_selector?.selectOption("audio", _lastAudioSource);
|
_overlay_quality_selector?.selectOption("audio", _lastAudioSource);
|
||||||
_overlay_quality_selector?.selectOption("subtitles", _lastSubtitleSource);
|
_overlay_quality_selector?.selectOption("subtitles", _lastSubtitleSource);
|
||||||
_overlay_quality_selector?.show();
|
_overlay_quality_selector?.show();
|
||||||
|
_slideUpOverlay = _overlay_quality_selector;
|
||||||
}
|
}
|
||||||
|
|
||||||
fun prevVideo() {
|
fun prevVideo() {
|
||||||
@ -1530,9 +1558,9 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
//Quality Selector data
|
//Quality Selector data
|
||||||
private fun updateQualityFormatsOverlay(liveStreamVideoFormats : List<Format>?, liveStreamAudioFormats : List<Format>?) {
|
private fun updateQualityFormatsOverlay(liveStreamVideoFormats : List<Format>?, liveStreamAudioFormats : List<Format>?) {
|
||||||
val v = video ?: return;
|
val v = video ?: return;
|
||||||
updateQualitySourcesOverlay(v, liveStreamVideoFormats, liveStreamAudioFormats);
|
updateQualitySourcesOverlay(v, videoLocal, liveStreamVideoFormats, liveStreamAudioFormats);
|
||||||
}
|
}
|
||||||
private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, liveStreamVideoFormats: List<Format>? = null, liveStreamAudioFormats: List<Format>? = null) {
|
private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, videoLocal: VideoLocal? = null, liveStreamVideoFormats: List<Format>? = null, liveStreamAudioFormats: List<Format>? = null) {
|
||||||
Logger.i(TAG, "updateQualitySourcesOverlay");
|
Logger.i(TAG, "updateQualitySourcesOverlay");
|
||||||
|
|
||||||
val video: IPlatformVideoDetails?;
|
val video: IPlatformVideoDetails?;
|
||||||
@ -1540,24 +1568,35 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
val localAudioSource: List<LocalAudioSource>?;
|
val localAudioSource: List<LocalAudioSource>?;
|
||||||
val localSubtitleSources: List<LocalSubtitleSource>?;
|
val localSubtitleSources: List<LocalSubtitleSource>?;
|
||||||
|
|
||||||
|
val videoSources: List<IVideoSource>?;
|
||||||
|
val audioSources: List<IAudioSource>?;
|
||||||
|
|
||||||
if(videoDetails is VideoLocal) {
|
if(videoDetails is VideoLocal) {
|
||||||
video = videoDetails.videoSerialized;
|
video = videoLocal?.videoSerialized;
|
||||||
localVideoSources = videoDetails.videoSource.toList();
|
localVideoSources = videoDetails.videoSource.toList();
|
||||||
localAudioSource = videoDetails.audioSource.toList();
|
localAudioSource = videoDetails.audioSource.toList();
|
||||||
localSubtitleSources = videoDetails.subtitlesSources.toList();
|
localSubtitleSources = videoDetails.subtitlesSources.toList();
|
||||||
|
videoSources = null
|
||||||
|
audioSources = null;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
video = videoDetails;
|
video = videoDetails;
|
||||||
localVideoSources = null;
|
videoSources = video?.video?.videoSources?.toList();
|
||||||
localAudioSource = null;
|
audioSources = if(video?.video?.isUnMuxed == true)
|
||||||
localSubtitleSources = null;
|
(video.video as VideoUnMuxedSourceDescriptor).audioSources.toList()
|
||||||
|
else null
|
||||||
|
if(videoLocal != null) {
|
||||||
|
localVideoSources = videoLocal.videoSource.toList();
|
||||||
|
localAudioSource = videoLocal.audioSource.toList();
|
||||||
|
localSubtitleSources = videoLocal.subtitlesSources.toList();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
localVideoSources = null;
|
||||||
|
localAudioSource = null;
|
||||||
|
localSubtitleSources = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val videoSources = video?.video?.videoSources?.toList();
|
|
||||||
val audioSources = if(video?.video?.isUnMuxed == true)
|
|
||||||
(video.video as VideoUnMuxedSourceDescriptor).audioSources.toList()
|
|
||||||
else null
|
|
||||||
|
|
||||||
val bestVideoSources = videoSources?.map { it.height * it.width }
|
val bestVideoSources = videoSources?.map { it.height * it.width }
|
||||||
?.distinct()
|
?.distinct()
|
||||||
?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
|
?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
|
||||||
@ -1857,7 +1896,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
private fun setCastEnabled(isCasting: Boolean) {
|
private fun setCastEnabled(isCasting: Boolean) {
|
||||||
Logger.i(TAG, "setCastEnabled(isCasting=$isCasting)")
|
Logger.i(TAG, "setCastEnabled(isCasting=$isCasting)")
|
||||||
|
|
||||||
video?.let { updateQualitySourcesOverlay(it); };
|
video?.let { updateQualitySourcesOverlay(it, videoLocal); };
|
||||||
|
|
||||||
_isCasting = isCasting;
|
_isCasting = isCasting;
|
||||||
|
|
||||||
|
332
app/src/main/java/com/futo/platformplayer/parsers/HLS.kt
Normal file
332
app/src/main/java/com/futo/platformplayer/parsers/HLS.kt
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
package com.futo.platformplayer.parsers
|
||||||
|
|
||||||
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
|
import com.futo.platformplayer.toYesNo
|
||||||
|
import com.futo.platformplayer.yesNoToBoolean
|
||||||
|
import java.net.URI
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
class HLS {
|
||||||
|
companion object {
|
||||||
|
fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String): MasterPlaylist {
|
||||||
|
val baseUrl = URI(sourceUrl).resolve("./").toString()
|
||||||
|
|
||||||
|
val variantPlaylists = mutableListOf<VariantPlaylistReference>()
|
||||||
|
val mediaRenditions = mutableListOf<MediaRendition>()
|
||||||
|
val sessionDataList = mutableListOf<SessionData>()
|
||||||
|
var independentSegments = false
|
||||||
|
|
||||||
|
masterPlaylistContent.lines().forEachIndexed { index, line ->
|
||||||
|
when {
|
||||||
|
line.startsWith("#EXT-X-STREAM-INF") -> {
|
||||||
|
val nextLine = masterPlaylistContent.lines().getOrNull(index + 1)
|
||||||
|
?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none")
|
||||||
|
val url = resolveUrl(baseUrl, nextLine)
|
||||||
|
|
||||||
|
variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line)))
|
||||||
|
}
|
||||||
|
|
||||||
|
line.startsWith("#EXT-X-MEDIA") -> {
|
||||||
|
mediaRenditions.add(parseMediaRendition(line, baseUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
line == "#EXT-X-INDEPENDENT-SEGMENTS" -> {
|
||||||
|
independentSegments = true
|
||||||
|
}
|
||||||
|
|
||||||
|
line.startsWith("#EXT-X-SESSION-DATA") -> {
|
||||||
|
val sessionData = parseSessionData(line)
|
||||||
|
sessionDataList.add(sessionData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseVariantPlaylist(content: String, sourceUrl: String): VariantPlaylist {
|
||||||
|
val lines = content.lines()
|
||||||
|
val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull()
|
||||||
|
val targetDuration = lines.find { it.startsWith("#EXT-X-TARGETDURATION:") }?.substringAfter(":")?.toIntOrNull()
|
||||||
|
val mediaSequence = lines.find { it.startsWith("#EXT-X-MEDIA-SEQUENCE:") }?.substringAfter(":")?.toLongOrNull()
|
||||||
|
val discontinuitySequence = lines.find { it.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE:") }?.substringAfter(":")?.toIntOrNull()
|
||||||
|
val programDateTime = lines.find { it.startsWith("#EXT-X-PROGRAM-DATE-TIME:") }?.substringAfter(":")?.let {
|
||||||
|
ZonedDateTime.parse(it, DateTimeFormatter.ISO_DATE_TIME)
|
||||||
|
}
|
||||||
|
val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":")
|
||||||
|
val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) }
|
||||||
|
|
||||||
|
val segments = mutableListOf<Segment>()
|
||||||
|
var currentSegment: MediaSegment? = null
|
||||||
|
lines.forEachIndexed { index, line ->
|
||||||
|
when {
|
||||||
|
line.startsWith("#EXTINF:") -> {
|
||||||
|
val duration = line.substringAfter(":").substringBefore(",").toDoubleOrNull()
|
||||||
|
?: throw Exception("Invalid segment duration format")
|
||||||
|
currentSegment = MediaSegment(duration = duration)
|
||||||
|
}
|
||||||
|
line == "#EXT-X-DISCONTINUITY" -> {
|
||||||
|
segments.add(DiscontinuitySegment())
|
||||||
|
}
|
||||||
|
line =="#EXT-X-ENDLIST" -> {
|
||||||
|
segments.add(EndListSegment())
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
currentSegment?.let {
|
||||||
|
it.uri = resolveUrl(sourceUrl, line)
|
||||||
|
segments.add(it)
|
||||||
|
}
|
||||||
|
currentSegment = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveUrl(baseUrl: String, url: String): String {
|
||||||
|
val baseUri = URI(baseUrl)
|
||||||
|
val urlUri = URI(url)
|
||||||
|
|
||||||
|
return if (urlUri.isAbsolute) {
|
||||||
|
url
|
||||||
|
} else {
|
||||||
|
val resolvedUri = baseUri.resolve(urlUri)
|
||||||
|
resolvedUri.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseStreamInfo(content: String): StreamInfo {
|
||||||
|
val attributes = parseAttributes(content)
|
||||||
|
return StreamInfo(
|
||||||
|
bandwidth = attributes["BANDWIDTH"]?.toIntOrNull(),
|
||||||
|
resolution = attributes["RESOLUTION"],
|
||||||
|
codecs = attributes["CODECS"],
|
||||||
|
frameRate = attributes["FRAME-RATE"],
|
||||||
|
videoRange = attributes["VIDEO-RANGE"],
|
||||||
|
audio = attributes["AUDIO"],
|
||||||
|
video = attributes["VIDEO"],
|
||||||
|
subtitles = attributes["SUBTITLES"],
|
||||||
|
closedCaptions = attributes["CLOSED-CAPTIONS"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseMediaRendition(line: String, baseUrl: String): MediaRendition {
|
||||||
|
val attributes = parseAttributes(line)
|
||||||
|
val uri = attributes["URI"]?.let { resolveUrl(baseUrl, it) }
|
||||||
|
return MediaRendition(
|
||||||
|
type = attributes["TYPE"],
|
||||||
|
uri = uri,
|
||||||
|
groupID = attributes["GROUP-ID"],
|
||||||
|
language = attributes["LANGUAGE"],
|
||||||
|
name = attributes["NAME"],
|
||||||
|
isDefault = attributes["DEFAULT"]?.yesNoToBoolean(),
|
||||||
|
isAutoSelect = attributes["AUTOSELECT"]?.yesNoToBoolean(),
|
||||||
|
isForced = attributes["FORCED"]?.yesNoToBoolean()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSessionData(line: String): SessionData {
|
||||||
|
val attributes = parseAttributes(line)
|
||||||
|
val dataId = attributes["DATA-ID"]!!
|
||||||
|
val value = attributes["VALUE"]!!
|
||||||
|
return SessionData(dataId, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseAttributes(content: String): Map<String, String> {
|
||||||
|
val attributes = mutableMapOf<String, String>()
|
||||||
|
val attributePairs = content.substringAfter(":").splitToSequence(',')
|
||||||
|
|
||||||
|
var currentPair = StringBuilder()
|
||||||
|
for (pair in attributePairs) {
|
||||||
|
currentPair.append(pair)
|
||||||
|
if (currentPair.count { it == '\"' } % 2 == 0) { // Check if the number of quotes is even
|
||||||
|
val (key, value) = currentPair.toString().split('=')
|
||||||
|
attributes[key.trim()] = value.trim().removeSurrounding("\"")
|
||||||
|
currentPair = StringBuilder() // Reset for the next attribute
|
||||||
|
} else {
|
||||||
|
currentPair.append(',') // Continue building the current attribute pair
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return attributes
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO", "VIDEO")
|
||||||
|
private fun shouldQuote(key: String, value: String?): Boolean {
|
||||||
|
if (value == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (value.contains(','))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return _quoteList.contains(key)
|
||||||
|
}
|
||||||
|
private fun appendAttributes(stringBuilder: StringBuilder, vararg attributes: Pair<String, String?>) {
|
||||||
|
attributes.filter { it.second != null }
|
||||||
|
.joinToString(",") {
|
||||||
|
val value = it.second
|
||||||
|
"${it.first}=${if (shouldQuote(it.first, it.second)) "\"$value\"" else value}"
|
||||||
|
}
|
||||||
|
.let { if (it.isNotEmpty()) stringBuilder.append(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SessionData(
|
||||||
|
val dataId: String,
|
||||||
|
val value: String
|
||||||
|
) {
|
||||||
|
fun toM3U8Line(): String = buildString {
|
||||||
|
append("#EXT-X-SESSION-DATA:")
|
||||||
|
appendAttributes(this,
|
||||||
|
"DATA-ID" to dataId,
|
||||||
|
"VALUE" to value
|
||||||
|
)
|
||||||
|
append("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class StreamInfo(
|
||||||
|
val bandwidth: Int?,
|
||||||
|
val resolution: String?,
|
||||||
|
val codecs: String?,
|
||||||
|
val frameRate: String?,
|
||||||
|
val videoRange: String?,
|
||||||
|
val audio: String?,
|
||||||
|
val video: String?,
|
||||||
|
val subtitles: String?,
|
||||||
|
val closedCaptions: String?
|
||||||
|
) {
|
||||||
|
fun toM3U8Line(): String = buildString {
|
||||||
|
append("#EXT-X-STREAM-INF:")
|
||||||
|
appendAttributes(this,
|
||||||
|
"BANDWIDTH" to bandwidth?.toString(),
|
||||||
|
"RESOLUTION" to resolution,
|
||||||
|
"CODECS" to codecs,
|
||||||
|
"FRAME-RATE" to frameRate,
|
||||||
|
"VIDEO-RANGE" to videoRange,
|
||||||
|
"AUDIO" to audio,
|
||||||
|
"VIDEO" to video,
|
||||||
|
"SUBTITLES" to subtitles,
|
||||||
|
"CLOSED-CAPTIONS" to closedCaptions
|
||||||
|
)
|
||||||
|
append("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MediaRendition(
|
||||||
|
val type: String?,
|
||||||
|
val uri: String?,
|
||||||
|
val groupID: String?,
|
||||||
|
val language: String?,
|
||||||
|
val name: String?,
|
||||||
|
val isDefault: Boolean?,
|
||||||
|
val isAutoSelect: Boolean?,
|
||||||
|
val isForced: Boolean?
|
||||||
|
) {
|
||||||
|
fun toM3U8Line(): String = buildString {
|
||||||
|
append("#EXT-X-MEDIA:")
|
||||||
|
appendAttributes(this,
|
||||||
|
"TYPE" to type,
|
||||||
|
"URI" to uri,
|
||||||
|
"GROUP-ID" to groupID,
|
||||||
|
"LANGUAGE" to language,
|
||||||
|
"NAME" to name,
|
||||||
|
"DEFAULT" to isDefault.toYesNo(),
|
||||||
|
"AUTOSELECT" to isAutoSelect.toYesNo(),
|
||||||
|
"FORCED" to isForced.toYesNo()
|
||||||
|
)
|
||||||
|
append("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
data class MasterPlaylist(
|
||||||
|
val variantPlaylistsRefs: List<VariantPlaylistReference>,
|
||||||
|
val mediaRenditions: List<MediaRendition>,
|
||||||
|
val sessionDataList: List<SessionData>,
|
||||||
|
val independentSegments: Boolean
|
||||||
|
) {
|
||||||
|
fun buildM3U8(): String {
|
||||||
|
val builder = StringBuilder()
|
||||||
|
builder.append("#EXTM3U\n")
|
||||||
|
if (independentSegments) {
|
||||||
|
builder.append("#EXT-X-INDEPENDENT-SEGMENTS\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaRenditions.forEach { rendition ->
|
||||||
|
builder.append(rendition.toM3U8Line())
|
||||||
|
}
|
||||||
|
|
||||||
|
variantPlaylistsRefs.forEach { variant ->
|
||||||
|
builder.append(variant.toM3U8Line())
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionDataList.forEach { data ->
|
||||||
|
builder.append(data.toM3U8Line())
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class VariantPlaylistReference(val url: String, val streamInfo: StreamInfo) {
|
||||||
|
fun toM3U8Line(): String = buildString {
|
||||||
|
append(streamInfo.toM3U8Line())
|
||||||
|
append("$url\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class VariantPlaylist(
|
||||||
|
val version: Int?,
|
||||||
|
val targetDuration: Int?,
|
||||||
|
val mediaSequence: Long?,
|
||||||
|
val discontinuitySequence: Int?,
|
||||||
|
val programDateTime: ZonedDateTime?,
|
||||||
|
val playlistType: String?,
|
||||||
|
val streamInfo: StreamInfo?,
|
||||||
|
val segments: List<Segment>
|
||||||
|
) {
|
||||||
|
fun buildM3U8(): String = buildString {
|
||||||
|
append("#EXTM3U\n")
|
||||||
|
version?.let { append("#EXT-X-VERSION:$it\n") }
|
||||||
|
targetDuration?.let { append("#EXT-X-TARGETDURATION:$it\n") }
|
||||||
|
mediaSequence?.let { append("#EXT-X-MEDIA-SEQUENCE:$it\n") }
|
||||||
|
discontinuitySequence?.let { append("#EXT-X-DISCONTINUITY-SEQUENCE:$it\n") }
|
||||||
|
playlistType?.let { append("#EXT-X-PLAYLIST-TYPE:$it\n") }
|
||||||
|
programDateTime?.let { append("#EXT-X-PROGRAM-DATE-TIME:${it.format(DateTimeFormatter.ISO_DATE_TIME)}\n") }
|
||||||
|
streamInfo?.let { append(it.toM3U8Line()) }
|
||||||
|
|
||||||
|
segments.forEach { segment ->
|
||||||
|
append(segment.toM3U8Line())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class Segment {
|
||||||
|
abstract fun toM3U8Line(): String
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MediaSegment (
|
||||||
|
val duration: Double,
|
||||||
|
var uri: String = ""
|
||||||
|
) : Segment() {
|
||||||
|
override fun toM3U8Line(): String = buildString {
|
||||||
|
append("#EXTINF:${duration},\n")
|
||||||
|
append(uri + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DiscontinuitySegment : Segment() {
|
||||||
|
override fun toM3U8Line(): String = buildString {
|
||||||
|
append("#EXT-X-DISCONTINUITY\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EndListSegment : Segment() {
|
||||||
|
override fun toM3U8Line(): String = buildString {
|
||||||
|
append("#EXT-X-ENDLIST\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,66 @@
|
|||||||
|
package com.futo.platformplayer.parsers
|
||||||
|
|
||||||
|
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||||
|
import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException
|
||||||
|
import com.futo.platformplayer.readHttpHeaderBytes
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
class HttpResponseParser : AutoCloseable {
|
||||||
|
private val _inputStream: InputStream;
|
||||||
|
|
||||||
|
var head: String = "";
|
||||||
|
var headers: HttpHeaders = HttpHeaders();
|
||||||
|
|
||||||
|
var contentType: String? = null;
|
||||||
|
var transferEncoding: String? = null;
|
||||||
|
var location: String? = null;
|
||||||
|
var contentLength: Long = -1L;
|
||||||
|
|
||||||
|
var statusCode: Int = -1;
|
||||||
|
|
||||||
|
constructor(inputStream: InputStream) {
|
||||||
|
_inputStream = inputStream;
|
||||||
|
|
||||||
|
val headerBytes = inputStream.readHttpHeaderBytes()
|
||||||
|
ByteArrayInputStream(headerBytes).use {
|
||||||
|
val reader = it.bufferedReader(Charsets.UTF_8)
|
||||||
|
head = reader.readLine() ?: throw EmptyRequestException("No head found");
|
||||||
|
|
||||||
|
val statusLineParts = head.split(" ")
|
||||||
|
if (statusLineParts.size < 3) {
|
||||||
|
throw IllegalStateException("Invalid status line")
|
||||||
|
}
|
||||||
|
|
||||||
|
statusCode = statusLineParts[1].toInt()
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val line = reader.readLine();
|
||||||
|
val headerEndIndex = line.indexOf(":");
|
||||||
|
if (headerEndIndex == -1)
|
||||||
|
break;
|
||||||
|
|
||||||
|
val headerKey = line.substring(0, headerEndIndex).lowercase()
|
||||||
|
val headerValue = line.substring(headerEndIndex + 1).trim();
|
||||||
|
headers[headerKey] = headerValue;
|
||||||
|
|
||||||
|
when(headerKey) {
|
||||||
|
"content-length" -> contentLength = headerValue.toLong();
|
||||||
|
"content-type" -> contentType = headerValue;
|
||||||
|
"transfer-encoding" -> transferEncoding = headerValue;
|
||||||
|
"location" -> location = headerValue;
|
||||||
|
}
|
||||||
|
if(line.isNullOrEmpty())
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
_inputStream.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = "HttpResponse";
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
package com.futo.platformplayer.receivers
|
||||||
|
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import com.futo.platformplayer.Settings
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StateNotifications
|
||||||
|
|
||||||
|
|
||||||
|
class PlannedNotificationReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
try {
|
||||||
|
Logger.i(TAG, "Planned Notification received");
|
||||||
|
if(!Settings.instance.notifications.plannedContentNotification)
|
||||||
|
return;
|
||||||
|
if(StateApp.instance.contextOrNull == null)
|
||||||
|
StateApp.instance.initializeFiles();
|
||||||
|
|
||||||
|
val notifs = StateNotifications.instance.getScheduledNotifications(60 * 15, true);
|
||||||
|
if(!notifs.isEmpty() && context != null) {
|
||||||
|
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
|
||||||
|
val channel = StateNotifications.instance.contentNotifChannel;
|
||||||
|
notificationManager.createNotificationChannel(channel);
|
||||||
|
var i = 0;
|
||||||
|
for (notif in notifs) {
|
||||||
|
StateNotifications.instance.notifyNewContentWithThumbnail(context, notificationManager, channel, 110 + i, notif);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed PlannedNotificationReceiver.onReceive", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = "PlannedNotificationReceiver"
|
||||||
|
|
||||||
|
fun getIntent(context: Context): PendingIntent {
|
||||||
|
return PendingIntent.getBroadcast(context, 110, Intent(context, PlannedNotificationReceiver::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,147 @@
|
|||||||
|
package com.futo.platformplayer.states
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.request.target.CustomTarget
|
||||||
|
import com.bumptech.glide.request.transition.Transition
|
||||||
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
|
import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent
|
||||||
|
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.receivers.PlannedNotificationReceiver
|
||||||
|
import com.futo.platformplayer.serializers.PlatformContentSerializer
|
||||||
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
|
import com.futo.platformplayer.toHumanNowDiffString
|
||||||
|
import com.futo.platformplayer.toHumanNowDiffStringMinDay
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
|
class StateNotifications {
|
||||||
|
private val _alarmManagerLock = Object();
|
||||||
|
private var _alarmManager: AlarmManager? = null;
|
||||||
|
val plannedWarningMinutesEarly: Long = 10;
|
||||||
|
|
||||||
|
val contentNotifChannel = NotificationChannel("contentChannel", "Content Notifications",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH).apply {
|
||||||
|
this.enableVibration(false);
|
||||||
|
this.setSound(null, null);
|
||||||
|
};
|
||||||
|
|
||||||
|
private val _plannedContent = FragmentedStorage.storeJson<SerializedPlatformContent>("planned_content_notifs", PlatformContentSerializer())
|
||||||
|
.load();
|
||||||
|
|
||||||
|
private fun getAlarmManager(context: Context): AlarmManager {
|
||||||
|
synchronized(_alarmManagerLock) {
|
||||||
|
if(_alarmManager == null)
|
||||||
|
_alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager;
|
||||||
|
return _alarmManager!!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scheduleContentNotification(context: Context, content: IPlatformContent) {
|
||||||
|
try {
|
||||||
|
var existing = _plannedContent.findItem { it.url == content.url };
|
||||||
|
if(existing != null) {
|
||||||
|
_plannedContent.delete(existing);
|
||||||
|
existing = null;
|
||||||
|
}
|
||||||
|
if(existing == null && content.datetime != null) {
|
||||||
|
val item = SerializedPlatformContent.fromContent(content);
|
||||||
|
_plannedContent.saveAsync(item);
|
||||||
|
|
||||||
|
val manager = getAlarmManager(context);
|
||||||
|
val notifyDateTime = content.datetime!!.minusMinutes(plannedWarningMinutesEarly);
|
||||||
|
if(Build.VERSION.SDK_INT >= 31 && !manager.canScheduleExactAlarms()) {
|
||||||
|
Logger.i(TAG, "Scheduling in-exact notification for [${content.name}] at ${notifyDateTime.toHumanNowDiffString()}")
|
||||||
|
manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, notifyDateTime.toEpochSecond().times(1000), PlannedNotificationReceiver.getIntent(context));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Logger.i(TAG, "Scheduling exact notification for [${content.name}] at ${notifyDateTime.toHumanNowDiffString()}")
|
||||||
|
manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, notifyDateTime.toEpochSecond().times(1000), PlannedNotificationReceiver.getIntent(context))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e(TAG, "scheduleContentNotification failed for [${content.name}]", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun removeChannelPlannedContent(channelUrl: String) {
|
||||||
|
val toDeletes = _plannedContent.findItems { it.author.url == channelUrl };
|
||||||
|
for(toDelete in toDeletes)
|
||||||
|
_plannedContent.delete(toDelete);
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getScheduledNotifications(secondsFuture: Long, deleteReturned: Boolean = false): List<SerializedPlatformContent> {
|
||||||
|
val minDate = OffsetDateTime.now().plusSeconds(secondsFuture);
|
||||||
|
val toNotify = _plannedContent.findItems { it.datetime?.let { it.isBefore(minDate) } == true }
|
||||||
|
|
||||||
|
if(deleteReturned) {
|
||||||
|
for(toDelete in toNotify)
|
||||||
|
_plannedContent.delete(toDelete);
|
||||||
|
}
|
||||||
|
return toNotify;
|
||||||
|
}
|
||||||
|
|
||||||
|
fun notifyNewContentWithThumbnail(context: Context, manager: NotificationManager, notificationChannel: NotificationChannel, id: Int, content: IPlatformContent) {
|
||||||
|
val thumbnail = if(content is IPlatformVideo) (content as IPlatformVideo).thumbnails.getHQThumbnail()
|
||||||
|
else null;
|
||||||
|
if(thumbnail != null)
|
||||||
|
Glide.with(context).asBitmap()
|
||||||
|
.load(thumbnail)
|
||||||
|
.into(object: CustomTarget<Bitmap>() {
|
||||||
|
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||||
|
notifyNewContent(context, manager, notificationChannel, id, content, resource);
|
||||||
|
}
|
||||||
|
override fun onLoadCleared(placeholder: Drawable?) {}
|
||||||
|
override fun onLoadFailed(errorDrawable: Drawable?) {
|
||||||
|
notifyNewContent(context, manager, notificationChannel, id, content, null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
else
|
||||||
|
notifyNewContent(context, manager, notificationChannel, id, content, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
fun notifyNewContent(context: Context, manager: NotificationManager, notificationChannel: NotificationChannel, id: Int, content: IPlatformContent, thumbnail: Bitmap? = null) {
|
||||||
|
val notifBuilder = NotificationCompat.Builder(context, notificationChannel.id)
|
||||||
|
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground)
|
||||||
|
.setContentTitle("New by [${content.author.name}]")
|
||||||
|
.setContentText("${content.name}")
|
||||||
|
.setSubText(content.datetime?.toHumanNowDiffStringMinDay())
|
||||||
|
.setSilent(true)
|
||||||
|
.setContentIntent(PendingIntent.getActivity(context, 0, MainActivity.getVideoIntent(context, content.url),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||||
|
.setChannelId(notificationChannel.id);
|
||||||
|
if(thumbnail != null) {
|
||||||
|
//notifBuilder.setLargeIcon(thumbnail);
|
||||||
|
notifBuilder.setStyle(NotificationCompat.BigPictureStyle().bigPicture(thumbnail).bigLargeIcon(null as Bitmap?));
|
||||||
|
}
|
||||||
|
manager.notify(id, notifBuilder.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val TAG = "StateNotifications";
|
||||||
|
private var _instance : StateNotifications? = null;
|
||||||
|
val instance : StateNotifications
|
||||||
|
get(){
|
||||||
|
if(_instance == null)
|
||||||
|
_instance = StateNotifications();
|
||||||
|
return _instance!!;
|
||||||
|
};
|
||||||
|
|
||||||
|
fun finish() {
|
||||||
|
_instance?.let {
|
||||||
|
_instance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -33,7 +33,7 @@ class FragmentedStorage {
|
|||||||
fun initialize(filesDir: File) {
|
fun initialize(filesDir: File) {
|
||||||
_filesDir = filesDir;
|
_filesDir = filesDir;
|
||||||
}
|
}
|
||||||
|
inline fun <reified T> storeJson(name: String, serializer: KSerializer<T>? = null): ManagedStore<T> = store(name, JsonStoreSerializer.create(serializer), null, null);
|
||||||
inline fun <reified T> storeJson(parentDir: File, name: String, serializer: KSerializer<T>? = null): ManagedStore<T> = store(name, JsonStoreSerializer.create(serializer), null, parentDir);
|
inline fun <reified T> storeJson(parentDir: File, name: String, serializer: KSerializer<T>? = null): ManagedStore<T> = store(name, JsonStoreSerializer.create(serializer), null, parentDir);
|
||||||
inline fun <reified T> storeJson(name: String, prettyName: String? = null, parentDir: File? = null): ManagedStore<T> = store(name, JsonStoreSerializer.create(), prettyName, parentDir);
|
inline fun <reified T> storeJson(name: String, prettyName: String? = null, parentDir: File? = null): ManagedStore<T> = store(name, JsonStoreSerializer.create(), prettyName, parentDir);
|
||||||
inline fun <reified T> store(name: String, serializer: StoreSerializer<T>, prettyName: String? = null, parentDir: File? = null): ManagedStore<T> {
|
inline fun <reified T> store(name: String, serializer: StoreSerializer<T>, prettyName: String? = null, parentDir: File? = null): ManagedStore<T> {
|
||||||
|
@ -20,6 +20,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec
|
|||||||
val onChannelClicked = Event1<PlatformAuthorLink>();
|
val onChannelClicked = Event1<PlatformAuthorLink>();
|
||||||
val onAddToClicked = Event1<IPlatformContent>();
|
val onAddToClicked = Event1<IPlatformContent>();
|
||||||
val onAddToQueueClicked = Event1<IPlatformContent>();
|
val onAddToQueueClicked = Event1<IPlatformContent>();
|
||||||
|
val onLongPress = Event1<IPlatformContent>();
|
||||||
|
|
||||||
override fun getItemCount(): Int {
|
override fun getItemCount(): Int {
|
||||||
return _cache.size;
|
return _cache.size;
|
||||||
@ -55,6 +56,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec
|
|||||||
onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit);
|
onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit);
|
||||||
onAddToClicked.subscribe(this@ChannelViewPagerAdapter.onAddToClicked::emit);
|
onAddToClicked.subscribe(this@ChannelViewPagerAdapter.onAddToClicked::emit);
|
||||||
onAddToQueueClicked.subscribe(this@ChannelViewPagerAdapter.onAddToQueueClicked::emit);
|
onAddToQueueClicked.subscribe(this@ChannelViewPagerAdapter.onAddToQueueClicked::emit);
|
||||||
|
onLongPress.subscribe(this@ChannelViewPagerAdapter.onLongPress::emit);
|
||||||
};
|
};
|
||||||
1 -> ChannelListFragment.newInstance().apply { onClickChannel.subscribe(onChannelClicked::emit) };
|
1 -> ChannelListFragment.newInstance().apply { onClickChannel.subscribe(onChannelClicked::emit) };
|
||||||
//2 -> ChannelStoreFragment.newInstance();
|
//2 -> ChannelStoreFragment.newInstance();
|
||||||
|
@ -75,7 +75,7 @@ class CommentViewHolder : ViewHolder {
|
|||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
Logger.i(TAG, "Started backfill");
|
Logger.i(TAG, "Started backfill");
|
||||||
args.processHandle.fullyBackfillServers();
|
args.processHandle.fullyBackfillServersAnnounceExceptions();
|
||||||
Logger.i(TAG, "Finished backfill");
|
Logger.i(TAG, "Finished backfill");
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.e(TAG, "Failed to backfill servers.", e)
|
Logger.e(TAG, "Failed to backfill servers.", e)
|
||||||
|
@ -29,6 +29,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
|
|||||||
private val _exoPlayer: PlayerManager?;
|
private val _exoPlayer: PlayerManager?;
|
||||||
private val _feedStyle : FeedStyle;
|
private val _feedStyle : FeedStyle;
|
||||||
private var _paused: Boolean = false;
|
private var _paused: Boolean = false;
|
||||||
|
private val _shouldShowTimeBar: Boolean
|
||||||
|
|
||||||
val onUrlClicked = Event1<String>();
|
val onUrlClicked = Event1<String>();
|
||||||
val onContentUrlClicked = Event2<String, ContentType>();
|
val onContentUrlClicked = Event2<String, ContentType>();
|
||||||
@ -48,12 +49,13 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
|
|||||||
|
|
||||||
constructor(context: Context, feedStyle : FeedStyle, dataSet: ArrayList<IPlatformContent>, exoPlayer: PlayerManager? = null,
|
constructor(context: Context, feedStyle : FeedStyle, dataSet: ArrayList<IPlatformContent>, exoPlayer: PlayerManager? = null,
|
||||||
initialPlay: Boolean = false, viewsToPrepend: ArrayList<View> = arrayListOf(),
|
initialPlay: Boolean = false, viewsToPrepend: ArrayList<View> = arrayListOf(),
|
||||||
viewsToAppend: ArrayList<View> = arrayListOf()) : super(context, viewsToPrepend, viewsToAppend) {
|
viewsToAppend: ArrayList<View> = arrayListOf(), shouldShowTimeBar: Boolean = true) : super(context, viewsToPrepend, viewsToAppend) {
|
||||||
|
|
||||||
this._feedStyle = feedStyle;
|
this._feedStyle = feedStyle;
|
||||||
this._dataSet = dataSet;
|
this._dataSet = dataSet;
|
||||||
this._initialPlay = initialPlay;
|
this._initialPlay = initialPlay;
|
||||||
this._exoPlayer = exoPlayer;
|
this._exoPlayer = exoPlayer;
|
||||||
|
this._shouldShowTimeBar = shouldShowTimeBar
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getChildCount(): Int = _dataSet.size;
|
override fun getChildCount(): Int = _dataSet.size;
|
||||||
@ -97,7 +99,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
|
|||||||
};
|
};
|
||||||
private fun createPlaceholderViewHolder(viewGroup: ViewGroup): PreviewPlaceholderViewHolder
|
private fun createPlaceholderViewHolder(viewGroup: ViewGroup): PreviewPlaceholderViewHolder
|
||||||
= PreviewPlaceholderViewHolder(viewGroup, _feedStyle);
|
= PreviewPlaceholderViewHolder(viewGroup, _feedStyle);
|
||||||
private fun createVideoPreviewViewHolder(viewGroup: ViewGroup): PreviewVideoViewHolder = PreviewVideoViewHolder(viewGroup, _feedStyle, _exoPlayer).apply {
|
private fun createVideoPreviewViewHolder(viewGroup: ViewGroup): PreviewVideoViewHolder = PreviewVideoViewHolder(viewGroup, _feedStyle, _exoPlayer, _shouldShowTimeBar).apply {
|
||||||
this.onVideoClicked.subscribe(this@PreviewContentListAdapter.onContentClicked::emit);
|
this.onVideoClicked.subscribe(this@PreviewContentListAdapter.onContentClicked::emit);
|
||||||
this.onChannelClicked.subscribe(this@PreviewContentListAdapter.onChannelClicked::emit);
|
this.onChannelClicked.subscribe(this@PreviewContentListAdapter.onChannelClicked::emit);
|
||||||
this.onAddToClicked.subscribe(this@PreviewContentListAdapter.onAddToClicked::emit);
|
this.onAddToClicked.subscribe(this@PreviewContentListAdapter.onAddToClicked::emit);
|
||||||
|
@ -5,10 +5,12 @@ import android.view.View
|
|||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||||
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
|
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
|
||||||
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||||
import com.futo.platformplayer.constructs.Event2
|
import com.futo.platformplayer.constructs.Event2
|
||||||
import com.futo.platformplayer.images.GlideHelper.Companion.loadThumbnails
|
import com.futo.platformplayer.images.GlideHelper.Companion.loadThumbnails
|
||||||
@ -17,6 +19,7 @@ import com.futo.platformplayer.states.StateApp
|
|||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.video.PlayerManager
|
import com.futo.platformplayer.video.PlayerManager
|
||||||
import com.futo.platformplayer.views.FeedStyle
|
import com.futo.platformplayer.views.FeedStyle
|
||||||
|
import com.futo.platformplayer.views.Loader
|
||||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@ -25,6 +28,7 @@ class PreviewNestedVideoView : PreviewVideoView {
|
|||||||
|
|
||||||
protected val _platformIndicatorNested: PlatformIndicator;
|
protected val _platformIndicatorNested: PlatformIndicator;
|
||||||
protected val _containerLoader: LinearLayout;
|
protected val _containerLoader: LinearLayout;
|
||||||
|
protected val _loader: Loader;
|
||||||
protected val _containerUnavailable: LinearLayout;
|
protected val _containerUnavailable: LinearLayout;
|
||||||
protected val _textNestedUrl: TextView;
|
protected val _textNestedUrl: TextView;
|
||||||
|
|
||||||
@ -38,8 +42,39 @@ class PreviewNestedVideoView : PreviewVideoView {
|
|||||||
constructor(context: Context, feedStyle: FeedStyle, exoPlayer: PlayerManager? = null): super(context, feedStyle, exoPlayer) {
|
constructor(context: Context, feedStyle: FeedStyle, exoPlayer: PlayerManager? = null): super(context, feedStyle, exoPlayer) {
|
||||||
_platformIndicatorNested = findViewById(R.id.thumbnail_platform_nested);
|
_platformIndicatorNested = findViewById(R.id.thumbnail_platform_nested);
|
||||||
_containerLoader = findViewById(R.id.container_loader);
|
_containerLoader = findViewById(R.id.container_loader);
|
||||||
|
_loader = findViewById(R.id.loader);
|
||||||
_containerUnavailable = findViewById(R.id.container_unavailable);
|
_containerUnavailable = findViewById(R.id.container_unavailable);
|
||||||
_textNestedUrl = findViewById(R.id.text_nested_url);
|
_textNestedUrl = findViewById(R.id.text_nested_url);
|
||||||
|
|
||||||
|
_imageChannel?.setOnClickListener { _contentNested?.let { onChannelClicked.emit(it.author) } };
|
||||||
|
_textChannelName.setOnClickListener { _contentNested?.let { onChannelClicked.emit(it.author) } };
|
||||||
|
_textVideoMetadata.setOnClickListener { _contentNested?.let { onChannelClicked.emit(it.author) } };
|
||||||
|
_button_add_to.setOnClickListener {
|
||||||
|
if(_contentNested is IPlatformVideo)
|
||||||
|
_contentNested?.let { onAddToClicked.emit(it as IPlatformVideo) }
|
||||||
|
else _content?.let {
|
||||||
|
if(it is IPlatformNestedContent)
|
||||||
|
loadNested(it) {
|
||||||
|
if(it is IPlatformVideo)
|
||||||
|
onAddToClicked.emit(it);
|
||||||
|
else
|
||||||
|
UIDialogs.toast(context, "Content is not a video");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
_button_add_to_queue.setOnClickListener {
|
||||||
|
if(_contentNested is IPlatformVideo)
|
||||||
|
_contentNested?.let { onAddToQueueClicked.emit(it as IPlatformVideo) }
|
||||||
|
else _content?.let {
|
||||||
|
if(it is IPlatformNestedContent)
|
||||||
|
loadNested(it) {
|
||||||
|
if(it is IPlatformVideo)
|
||||||
|
onAddToQueueClicked.emit(it);
|
||||||
|
else
|
||||||
|
UIDialogs.toast(context, "Content is not a video");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun inflate(feedStyle: FeedStyle) {
|
override fun inflate(feedStyle: FeedStyle) {
|
||||||
@ -81,6 +116,7 @@ class PreviewNestedVideoView : PreviewVideoView {
|
|||||||
if(!_contentSupported) {
|
if(!_contentSupported) {
|
||||||
_containerUnavailable.visibility = View.VISIBLE;
|
_containerUnavailable.visibility = View.VISIBLE;
|
||||||
_containerLoader.visibility = View.GONE;
|
_containerLoader.visibility = View.GONE;
|
||||||
|
_loader.stop();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
if(_feedStyle == FeedStyle.THUMBNAIL)
|
if(_feedStyle == FeedStyle.THUMBNAIL)
|
||||||
@ -96,12 +132,14 @@ class PreviewNestedVideoView : PreviewVideoView {
|
|||||||
_contentSupported = false;
|
_contentSupported = false;
|
||||||
_containerUnavailable.visibility = View.VISIBLE;
|
_containerUnavailable.visibility = View.VISIBLE;
|
||||||
_containerLoader.visibility = View.GONE;
|
_containerLoader.visibility = View.GONE;
|
||||||
|
_loader.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadNested(content: IPlatformNestedContent) {
|
private fun loadNested(content: IPlatformNestedContent, onCompleted: ((IPlatformContentDetails)->Unit)? = null) {
|
||||||
Logger.i(TAG, "Loading nested content [${content.contentUrl}]");
|
Logger.i(TAG, "Loading nested content [${content.contentUrl}]");
|
||||||
_containerLoader.visibility = View.VISIBLE;
|
_containerLoader.visibility = View.VISIBLE;
|
||||||
|
_loader.start();
|
||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
val def = StatePlatform.instance.getContentDetails(content.contentUrl);
|
val def = StatePlatform.instance.getContentDetails(content.contentUrl);
|
||||||
def.invokeOnCompletion {
|
def.invokeOnCompletion {
|
||||||
@ -112,11 +150,13 @@ class PreviewNestedVideoView : PreviewVideoView {
|
|||||||
if(_content == content) {
|
if(_content == content) {
|
||||||
_containerUnavailable.visibility = View.VISIBLE;
|
_containerUnavailable.visibility = View.VISIBLE;
|
||||||
_containerLoader.visibility = View.GONE;
|
_containerLoader.visibility = View.GONE;
|
||||||
|
_loader.stop();
|
||||||
}
|
}
|
||||||
//TODO: Handle exception
|
//TODO: Handle exception
|
||||||
}
|
}
|
||||||
else if(_content == content) {
|
else if(_content == content) {
|
||||||
_containerLoader.visibility = View.GONE;
|
_containerLoader.visibility = View.GONE;
|
||||||
|
_loader.stop();
|
||||||
val nestedContent = def.getCompleted();
|
val nestedContent = def.getCompleted();
|
||||||
_contentNested = nestedContent;
|
_contentNested = nestedContent;
|
||||||
if(nestedContent is IPlatformVideoDetails) {
|
if(nestedContent is IPlatformVideoDetails) {
|
||||||
@ -131,6 +171,8 @@ class PreviewNestedVideoView : PreviewVideoView {
|
|||||||
else {
|
else {
|
||||||
_containerUnavailable.visibility = View.VISIBLE;
|
_containerUnavailable.visibility = View.VISIBLE;
|
||||||
}
|
}
|
||||||
|
if(onCompleted != null)
|
||||||
|
onCompleted(nestedContent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -27,9 +27,11 @@ import com.futo.platformplayer.logging.Logger
|
|||||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StateDownloads
|
import com.futo.platformplayer.states.StateDownloads
|
||||||
|
import com.futo.platformplayer.states.StatePlaylists
|
||||||
import com.futo.platformplayer.video.PlayerManager
|
import com.futo.platformplayer.video.PlayerManager
|
||||||
import com.futo.platformplayer.views.others.CreatorThumbnail
|
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||||
import com.futo.platformplayer.views.FeedStyle
|
import com.futo.platformplayer.views.FeedStyle
|
||||||
|
import com.futo.platformplayer.views.others.ProgressBar
|
||||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||||
import com.futo.platformplayer.views.video.FutoThumbnailPlayer
|
import com.futo.platformplayer.views.video.FutoThumbnailPlayer
|
||||||
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
||||||
@ -67,6 +69,8 @@ open class PreviewVideoView : LinearLayout {
|
|||||||
Logger.w(TAG, "Failed to load profile.", it);
|
Logger.w(TAG, "Failed to load profile.", it);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private val _timeBar: ProgressBar?;
|
||||||
|
|
||||||
val onVideoClicked = Event2<IPlatformVideo, Long>();
|
val onVideoClicked = Event2<IPlatformVideo, Long>();
|
||||||
val onLongPress = Event1<IPlatformVideo>();
|
val onLongPress = Event1<IPlatformVideo>();
|
||||||
val onChannelClicked = Event1<PlatformAuthorLink>();
|
val onChannelClicked = Event1<PlatformAuthorLink>();
|
||||||
@ -77,10 +81,12 @@ open class PreviewVideoView : LinearLayout {
|
|||||||
private set
|
private set
|
||||||
|
|
||||||
val content: IPlatformContent? get() = currentVideo;
|
val content: IPlatformContent? get() = currentVideo;
|
||||||
|
val shouldShowTimeBar: Boolean
|
||||||
|
|
||||||
constructor(context: Context, feedStyle : FeedStyle, exoPlayer: PlayerManager? = null) : super(context) {
|
constructor(context: Context, feedStyle : FeedStyle, exoPlayer: PlayerManager? = null, shouldShowTimeBar: Boolean = true) : super(context) {
|
||||||
inflate(feedStyle);
|
inflate(feedStyle);
|
||||||
_feedStyle = feedStyle;
|
_feedStyle = feedStyle;
|
||||||
|
this.shouldShowTimeBar = shouldShowTimeBar
|
||||||
val playerContainer = findViewById<FrameLayout>(R.id.player_container);
|
val playerContainer = findViewById<FrameLayout>(R.id.player_container);
|
||||||
|
|
||||||
val displayMetrics = Resources.getSystem().displayMetrics;
|
val displayMetrics = Resources.getSystem().displayMetrics;
|
||||||
@ -117,6 +123,7 @@ open class PreviewVideoView : LinearLayout {
|
|||||||
_button_add_to = findViewById(R.id.button_add_to);
|
_button_add_to = findViewById(R.id.button_add_to);
|
||||||
_imageNeopassChannel = findViewById(R.id.image_neopass_channel);
|
_imageNeopassChannel = findViewById(R.id.image_neopass_channel);
|
||||||
_layoutDownloaded = findViewById(R.id.layout_downloaded);
|
_layoutDownloaded = findViewById(R.id.layout_downloaded);
|
||||||
|
_timeBar = findViewById(R.id.time_bar)
|
||||||
|
|
||||||
this._exoPlayer = exoPlayer
|
this._exoPlayer = exoPlayer
|
||||||
|
|
||||||
@ -235,13 +242,26 @@ open class PreviewVideoView : LinearLayout {
|
|||||||
_containerLive.visibility = GONE;
|
_containerLive.visibility = GONE;
|
||||||
_containerDuration.visibility = VISIBLE;
|
_containerDuration.visibility = VISIBLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val timeBar = _timeBar
|
||||||
|
if (timeBar != null) {
|
||||||
|
if (shouldShowTimeBar) {
|
||||||
|
val historyPosition = StatePlaylists.instance.getHistoryPosition(video.url)
|
||||||
|
timeBar.visibility = if (historyPosition > 0) VISIBLE else GONE
|
||||||
|
timeBar.progress = historyPosition.toFloat() / video.duration.toFloat()
|
||||||
|
} else {
|
||||||
|
timeBar.visibility = GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
currentVideo = null;
|
currentVideo = null;
|
||||||
_imageVideo.setImageResource(0);
|
_imageVideo.setImageResource(0);
|
||||||
_containerDuration.visibility = GONE;
|
_containerDuration.visibility = GONE;
|
||||||
_containerLive.visibility = GONE;
|
_containerLive.visibility = GONE;
|
||||||
|
_timeBar?.visibility = GONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
_textVideoMetadata.text = metadata + timeMeta;
|
_textVideoMetadata.text = metadata + timeMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,8 +27,8 @@ class PreviewVideoViewHolder : ContentPreviewViewHolder {
|
|||||||
|
|
||||||
private val view: PreviewVideoView get() = itemView as PreviewVideoView;
|
private val view: PreviewVideoView get() = itemView as PreviewVideoView;
|
||||||
|
|
||||||
constructor(viewGroup: ViewGroup, feedStyle : FeedStyle, exoPlayer: PlayerManager? = null): super(
|
constructor(viewGroup: ViewGroup, feedStyle : FeedStyle, exoPlayer: PlayerManager? = null, shouldShowTimeBar: Boolean = true): super(
|
||||||
PreviewVideoView(viewGroup.context, feedStyle, exoPlayer)
|
PreviewVideoView(viewGroup.context, feedStyle, exoPlayer, shouldShowTimeBar)
|
||||||
) {
|
) {
|
||||||
view.onVideoClicked.subscribe(onVideoClicked::emit);
|
view.onVideoClicked.subscribe(onVideoClicked::emit);
|
||||||
view.onChannelClicked.subscribe(onChannelClicked::emit);
|
view.onChannelClicked.subscribe(onChannelClicked::emit);
|
||||||
|
@ -19,6 +19,9 @@ open class BigButton : LinearLayout {
|
|||||||
private val _textPrimary: TextView;
|
private val _textPrimary: TextView;
|
||||||
private val _textSecondary: TextView;
|
private val _textSecondary: TextView;
|
||||||
|
|
||||||
|
val title: String get() = _textPrimary.text.toString();
|
||||||
|
val description: String get() = _textSecondary.text.toString();
|
||||||
|
|
||||||
val onClick = Event0();
|
val onClick = Event0();
|
||||||
|
|
||||||
constructor(context : Context, text: String, subText: String, icon: Int, action: ()->Unit) : super(context) {
|
constructor(context : Context, text: String, subText: String, icon: Int, action: ()->Unit) : super(context) {
|
||||||
|
@ -28,6 +28,10 @@ class ButtonField : BigButton, IField {
|
|||||||
|
|
||||||
override val value: Any? = null;
|
override val value: Any? = null;
|
||||||
|
|
||||||
|
override val searchContent: String?
|
||||||
|
get() = "$title $description";
|
||||||
|
|
||||||
|
|
||||||
override val obj : Any? get() {
|
override val obj : Any? get() {
|
||||||
if(this._obj == null)
|
if(this._obj == null)
|
||||||
throw java.lang.IllegalStateException("Can only be called if fromField is used");
|
throw java.lang.IllegalStateException("Can only be called if fromField is used");
|
||||||
|
@ -41,6 +41,9 @@ class DropdownField : TableRow, IField {
|
|||||||
|
|
||||||
override val value: Any? get() = _selected;
|
override val value: Any? get() = _selected;
|
||||||
|
|
||||||
|
override val searchContent: String?
|
||||||
|
get() = "${_title.text} ${_description.text}";
|
||||||
|
|
||||||
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs){
|
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs){
|
||||||
inflate(context, R.layout.field_dropdown, this);
|
inflate(context, R.layout.field_dropdown, this);
|
||||||
_spinner = findViewById(R.id.field_spinner);
|
_spinner = findViewById(R.id.field_spinner);
|
||||||
|
@ -23,6 +23,8 @@ interface IField {
|
|||||||
|
|
||||||
var reference: Any?;
|
var reference: Any?;
|
||||||
|
|
||||||
|
val searchContent: String?;
|
||||||
|
|
||||||
fun fromField(obj : Any, field : Field, formField: FormField? = null) : IField;
|
fun fromField(obj : Any, field : Field, formField: FormField? = null) : IField;
|
||||||
fun setField();
|
fun setField();
|
||||||
|
|
||||||
|
@ -3,12 +3,14 @@ package com.futo.platformplayer.views.fields
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.FrameLayout
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
|
import androidx.core.widget.addTextChangedListener
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
import com.futo.platformplayer.constructs.Event2
|
import com.futo.platformplayer.constructs.Event2
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@ -24,11 +26,12 @@ import kotlin.reflect.full.hasAnnotation
|
|||||||
import kotlin.reflect.jvm.javaField
|
import kotlin.reflect.jvm.javaField
|
||||||
import kotlin.reflect.jvm.javaMethod
|
import kotlin.reflect.jvm.javaMethod
|
||||||
import kotlin.streams.asStream
|
import kotlin.streams.asStream
|
||||||
import kotlin.streams.toList
|
|
||||||
|
|
||||||
class FieldForm : LinearLayout {
|
class FieldForm : LinearLayout {
|
||||||
|
|
||||||
private val _root : LinearLayout;
|
private val _containerSearch: FrameLayout;
|
||||||
|
private val _editSearch: EditText;
|
||||||
|
private val _fieldsContainer : LinearLayout;
|
||||||
|
|
||||||
val onChanged = Event2<IField, Any>();
|
val onChanged = Event2<IField, Any>();
|
||||||
|
|
||||||
@ -36,11 +39,45 @@ class FieldForm : LinearLayout {
|
|||||||
|
|
||||||
constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs) {
|
constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs) {
|
||||||
inflate(context, R.layout.field_form, this);
|
inflate(context, R.layout.field_form, this);
|
||||||
_root = findViewById(R.id.field_form_root);
|
_containerSearch = findViewById(R.id.container_search);
|
||||||
|
_editSearch = findViewById(R.id.edit_search);
|
||||||
|
_fieldsContainer = findViewById(R.id.field_form_container);
|
||||||
|
|
||||||
|
_editSearch.addTextChangedListener {
|
||||||
|
updateSettingsVisibility();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSettingsVisibility(group: GroupField? = null) {
|
||||||
|
val settings = group?.getFields() ?: _fields;
|
||||||
|
|
||||||
|
val query = _editSearch.text.toString().lowercase();
|
||||||
|
|
||||||
|
var groupVisible = false;
|
||||||
|
val isGroupMatch = query.isEmpty() || group?.searchContent?.lowercase()?.contains(query) == true;
|
||||||
|
for(field in settings) {
|
||||||
|
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(group != null)
|
||||||
|
group.visibility = if(groupVisible) View.VISIBLE else View.GONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSearchVisible(visible: Boolean) {
|
||||||
|
_containerSearch.visibility = if(visible) View.VISIBLE else View.GONE;
|
||||||
|
_editSearch.setText("");
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fromObject(scope: CoroutineScope, obj : Any, onLoaded: (()->Unit)? = null) {
|
fun fromObject(scope: CoroutineScope, obj : Any, onLoaded: (()->Unit)? = null) {
|
||||||
_root.removeAllViews();
|
_fieldsContainer.removeAllViews();
|
||||||
|
|
||||||
scope.launch(Dispatchers.Default) {
|
scope.launch(Dispatchers.Default) {
|
||||||
val newFields = getFieldsFromObject(context, obj);
|
val newFields = getFieldsFromObject(context, obj);
|
||||||
@ -50,7 +87,7 @@ class FieldForm : LinearLayout {
|
|||||||
if (field !is View)
|
if (field !is View)
|
||||||
throw java.lang.IllegalStateException("Only views can be IFields");
|
throw java.lang.IllegalStateException("Only views can be IFields");
|
||||||
|
|
||||||
_root.addView(field as View);
|
_fieldsContainer.addView(field as View);
|
||||||
field.onChanged.subscribe { a1, a2, oldValue ->
|
field.onChanged.subscribe { a1, a2, oldValue ->
|
||||||
onChanged.emit(a1, a2);
|
onChanged.emit(a1, a2);
|
||||||
};
|
};
|
||||||
@ -62,13 +99,13 @@ class FieldForm : LinearLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun fromObject(obj : Any) {
|
fun fromObject(obj : Any) {
|
||||||
_root.removeAllViews();
|
_fieldsContainer.removeAllViews();
|
||||||
val newFields = getFieldsFromObject(context, obj);
|
val newFields = getFieldsFromObject(context, obj);
|
||||||
for(field in newFields) {
|
for(field in newFields) {
|
||||||
if(field !is View)
|
if(field !is View)
|
||||||
throw java.lang.IllegalStateException("Only views can be IFields");
|
throw java.lang.IllegalStateException("Only views can be IFields");
|
||||||
|
|
||||||
_root.addView(field as View);
|
_fieldsContainer.addView(field as View);
|
||||||
field.onChanged.subscribe { a1, a2, oldValue ->
|
field.onChanged.subscribe { a1, a2, oldValue ->
|
||||||
onChanged.emit(a1, a2);
|
onChanged.emit(a1, a2);
|
||||||
};
|
};
|
||||||
@ -76,7 +113,7 @@ class FieldForm : LinearLayout {
|
|||||||
_fields = newFields;
|
_fields = newFields;
|
||||||
}
|
}
|
||||||
fun fromPluginSettings(settings: List<SourcePluginConfig.Setting>, values: HashMap<String, String?>, groupTitle: String? = null, groupDescription: String? = null) {
|
fun fromPluginSettings(settings: List<SourcePluginConfig.Setting>, values: HashMap<String, String?>, groupTitle: String? = null, groupDescription: String? = null) {
|
||||||
_root.removeAllViews();
|
_fieldsContainer.removeAllViews();
|
||||||
val newFields = getFieldsFromPluginSettings(context, settings, values);
|
val newFields = getFieldsFromPluginSettings(context, settings, values);
|
||||||
if (newFields.isEmpty()) {
|
if (newFields.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
@ -87,7 +124,7 @@ class FieldForm : LinearLayout {
|
|||||||
if(field.second !is View)
|
if(field.second !is View)
|
||||||
throw java.lang.IllegalStateException("Only views can be IFields");
|
throw java.lang.IllegalStateException("Only views can be IFields");
|
||||||
finalizePluginSettingField(field.first, field.second, newFields);
|
finalizePluginSettingField(field.first, field.second, newFields);
|
||||||
_root.addView(field as View);
|
_fieldsContainer.addView(field as View);
|
||||||
}
|
}
|
||||||
_fields = newFields.map { it.second };
|
_fields = newFields.map { it.second };
|
||||||
} else {
|
} else {
|
||||||
@ -96,7 +133,7 @@ class FieldForm : LinearLayout {
|
|||||||
}
|
}
|
||||||
val group = GroupField(context, groupTitle, groupDescription)
|
val group = GroupField(context, groupTitle, groupDescription)
|
||||||
.withFields(newFields.map { it.second });
|
.withFields(newFields.map { it.second });
|
||||||
_root.addView(group as View);
|
_fieldsContainer.addView(group as View);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private fun finalizePluginSettingField(setting: SourcePluginConfig.Setting, field: IField, others: List<Pair<SourcePluginConfig.Setting, IField>>) {
|
private fun finalizePluginSettingField(setting: SourcePluginConfig.Setting, field: IField, others: List<Pair<SourcePluginConfig.Setting, IField>>) {
|
||||||
@ -210,7 +247,6 @@ class FieldForm : LinearLayout {
|
|||||||
.asStream()
|
.asStream()
|
||||||
.filter { it.hasAnnotation<FormField>() && it.javaField != null }
|
.filter { it.hasAnnotation<FormField>() && it.javaField != null }
|
||||||
.map { Pair<KProperty<*>, FormField>(it, it.findAnnotation()!!) }
|
.map { Pair<KProperty<*>, FormField>(it, it.findAnnotation()!!) }
|
||||||
.toList()
|
|
||||||
|
|
||||||
//TODO: Rewrite fields to properties so no map is required
|
//TODO: Rewrite fields to properties so no map is required
|
||||||
val propertyMap = mutableMapOf<Field, KProperty<*>>();
|
val propertyMap = mutableMapOf<Field, KProperty<*>>();
|
||||||
@ -252,7 +288,6 @@ class FieldForm : LinearLayout {
|
|||||||
.asStream()
|
.asStream()
|
||||||
.filter { it.hasAnnotation<FormField>() && it.javaField == null && it.getter.javaMethod != null}
|
.filter { it.hasAnnotation<FormField>() && it.javaField == null && it.getter.javaMethod != null}
|
||||||
.map { Pair<Method, FormField>(it.getter.javaMethod!!, it.findAnnotation()!!) }
|
.map { Pair<Method, FormField>(it.getter.javaMethod!!, it.findAnnotation()!!) }
|
||||||
.toList();
|
|
||||||
|
|
||||||
for(prop in objProps) {
|
for(prop in objProps) {
|
||||||
prop.first.isAccessible = true;
|
prop.first.isAccessible = true;
|
||||||
@ -270,7 +305,6 @@ class FieldForm : LinearLayout {
|
|||||||
.asStream()
|
.asStream()
|
||||||
.filter { it.getAnnotation(FormField::class.java) != null && !it.name.startsWith("get") && !it.name.startsWith("set") }
|
.filter { it.getAnnotation(FormField::class.java) != null && !it.name.startsWith("get") && !it.name.startsWith("set") }
|
||||||
.map { Pair<Method, FormField>(it, it.getAnnotation(FormField::class.java)) }
|
.map { Pair<Method, FormField>(it, it.getAnnotation(FormField::class.java)) }
|
||||||
.toList();
|
|
||||||
|
|
||||||
for(meth in objMethods) {
|
for(meth in objMethods) {
|
||||||
meth.first.isAccessible = true;
|
meth.first.isAccessible = true;
|
||||||
|
@ -39,6 +39,8 @@ class GroupField : LinearLayout, IField {
|
|||||||
|
|
||||||
override val value: Any? = null;
|
override val value: Any? = null;
|
||||||
|
|
||||||
|
override val searchContent: String? get() = "${_title.text} ${_subtitle.text}";
|
||||||
|
|
||||||
constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs) {
|
constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs) {
|
||||||
inflate(context, R.layout.field_group, this);
|
inflate(context, R.layout.field_group, this);
|
||||||
_title = findViewById(R.id.field_group_title);
|
_title = findViewById(R.id.field_group_title);
|
||||||
@ -142,6 +144,9 @@ class GroupField : LinearLayout, IField {
|
|||||||
field.setField();
|
field.setField();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fun getFields(): List<IField> {
|
||||||
|
return _fields;
|
||||||
|
}
|
||||||
|
|
||||||
override fun setValue(value: Any) {}
|
override fun setValue(value: Any) {}
|
||||||
}
|
}
|
@ -34,6 +34,9 @@ class ReadOnlyTextField : TableRow, IField {
|
|||||||
|
|
||||||
override val value: Any? = null;
|
override val value: Any? = null;
|
||||||
|
|
||||||
|
override val searchContent: String?
|
||||||
|
get() = "${_title.text}";
|
||||||
|
|
||||||
constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs){
|
constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs){
|
||||||
inflate(context, R.layout.field_readonly_text, this);
|
inflate(context, R.layout.field_readonly_text, this);
|
||||||
_title = findViewById(R.id.field_title);
|
_title = findViewById(R.id.field_title);
|
||||||
|
@ -38,6 +38,9 @@ class ToggleField : TableRow, IField {
|
|||||||
|
|
||||||
override val value: Any get() = _lastValue;
|
override val value: Any get() = _lastValue;
|
||||||
|
|
||||||
|
override val searchContent: String?
|
||||||
|
get() = "${_title.text} ${_description.text}";
|
||||||
|
|
||||||
constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs){
|
constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs){
|
||||||
inflate(context, R.layout.field_toggle, this);
|
inflate(context, R.layout.field_toggle, this);
|
||||||
_toggle = findViewById(R.id.field_toggle);
|
_toggle = findViewById(R.id.field_toggle);
|
||||||
|
@ -308,13 +308,21 @@ class LiveChatOverlay : LinearLayout {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
private var _dedupHackfix = "";
|
||||||
fun addDonation(donation: LiveEventDonation) {
|
fun addDonation(donation: LiveEventDonation) {
|
||||||
|
val uniqueIdentifier = "${donation.name}${donation.amount}${donation.message}";
|
||||||
if(donation.hasExpired()) {
|
if(donation.hasExpired()) {
|
||||||
Logger.i(TAG, "Donation that is already expired: [${donation.amount}]" + donation.name + ":" + donation.message + " EXPIRE: ${donation.expire}");
|
Logger.i(TAG, "Donation that is already expired: [${donation.amount}]" + donation.name + ":" + donation.message + " EXPIRE: ${donation.expire}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
else if(_dedupHackfix == uniqueIdentifier) {
|
||||||
|
Logger.i(TAG, "Donation duplicate found, ignoring");
|
||||||
|
return;
|
||||||
|
}
|
||||||
else
|
else
|
||||||
Logger.i(TAG, "Donation Added: [${donation.amount}]" + donation.name + ":" + donation.message + " EXPIRE: ${donation.expire}");
|
Logger.i(TAG, "Donation Added: [${donation.amount}]" + donation.name + ":" + donation.message + " EXPIRE: ${donation.expire}");
|
||||||
|
_dedupHackfix = uniqueIdentifier;
|
||||||
|
|
||||||
val view = LiveChatDonationPill(context, donation);
|
val view = LiveChatDonationPill(context, donation);
|
||||||
view.setOnClickListener {
|
view.setOnClickListener {
|
||||||
showDonation(donation);
|
showDonation(donation);
|
||||||
|
7
app/src/main/res/drawable/background_donation.xml
Normal file
7
app/src/main/res/drawable/background_donation.xml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<solid android:color="#2A2A2A" />
|
||||||
|
<corners android:radius="500dp" />
|
||||||
|
<size android:height="20dp" />
|
||||||
|
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
|
||||||
|
</shape>
|
@ -2,8 +2,44 @@
|
|||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:gravity="center_vertical"
|
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:id="@+id/field_form_root">
|
android:id="@+id/field_form_root">
|
||||||
|
|
||||||
|
<!--Search Text-->
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/container_search"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:layout_margin="10dp">
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/edit_search"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="text"
|
||||||
|
android:imeOptions="actionDone"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:hint="Search"
|
||||||
|
android:paddingEnd="46dp" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/button_clear_search"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:paddingStart="18dp"
|
||||||
|
android:paddingEnd="18dp"
|
||||||
|
android:layout_gravity="right|center_vertical"
|
||||||
|
android:visibility="invisible"
|
||||||
|
android:src="@drawable/ic_clear_16dp" />
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/field_form_container"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="vertical">
|
||||||
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
@ -9,7 +9,7 @@
|
|||||||
android:paddingStart="7dp"
|
android:paddingStart="7dp"
|
||||||
android:paddingEnd="12dp"
|
android:paddingEnd="12dp"
|
||||||
android:layout_marginEnd="5dp"
|
android:layout_marginEnd="5dp"
|
||||||
android:background="@drawable/background_pill"
|
android:background="@drawable/background_donation"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:id="@+id/root">
|
android:id="@+id/root">
|
||||||
|
|
||||||
@ -24,7 +24,7 @@
|
|||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
android:layout_marginRight="5dp"
|
android:layout_marginRight="5dp"
|
||||||
android:layout_marginLeft="5dp"
|
android:layout_marginLeft="0dp"
|
||||||
android:scaleType="fitCenter"
|
android:scaleType="fitCenter"
|
||||||
app:srcCompat="@drawable/placeholder_profile" />
|
app:srcCompat="@drawable/placeholder_profile" />
|
||||||
|
|
||||||
@ -32,7 +32,7 @@
|
|||||||
android:id="@+id/donation_amount"
|
android:id="@+id/donation_amount"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:layout_marginLeft="5dp"
|
android:layout_marginLeft="3dp"
|
||||||
app:layout_constraintLeft_toRightOf="@id/donation_author_image"
|
app:layout_constraintLeft_toRightOf="@id/donation_author_image"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
android:textColor="@color/white"
|
android:textColor="@color/white"
|
||||||
|
@ -32,6 +32,20 @@
|
|||||||
android:scaleType="centerCrop"
|
android:scaleType="centerCrop"
|
||||||
tools:srcCompat="@drawable/placeholder_video_thumbnail" />
|
tools:srcCompat="@drawable/placeholder_video_thumbnail" />
|
||||||
|
|
||||||
|
<com.futo.platformplayer.views.others.ProgressBar
|
||||||
|
android:id="@+id/time_bar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="2dp"
|
||||||
|
android:layout_gravity="bottom"
|
||||||
|
android:layout_marginBottom="6dp"
|
||||||
|
app:progress="60%"
|
||||||
|
app:inactiveColor="#55EEEEEE"
|
||||||
|
app:radiusBottomLeft="0dp"
|
||||||
|
app:radiusBottomRight="0dp"
|
||||||
|
app:radiusTopLeft="0dp"
|
||||||
|
app:radiusTopRight="0dp"
|
||||||
|
android:visibility="visible"/>
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent">
|
||||||
|
@ -117,6 +117,20 @@
|
|||||||
android:layout_gravity="end"
|
android:layout_gravity="end"
|
||||||
android:layout_marginStart="4dp"
|
android:layout_marginStart="4dp"
|
||||||
android:layout_marginBottom="4dp" />
|
android:layout_marginBottom="4dp" />
|
||||||
|
|
||||||
|
<com.futo.platformplayer.views.others.ProgressBar
|
||||||
|
android:id="@+id/time_bar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="2dp"
|
||||||
|
android:layout_alignParentStart="true"
|
||||||
|
android:layout_alignParentBottom="true"
|
||||||
|
app:progress="60%"
|
||||||
|
app:inactiveColor="#55EEEEEE"
|
||||||
|
app:radiusBottomLeft="4dp"
|
||||||
|
app:radiusBottomRight="4dp"
|
||||||
|
app:radiusTopLeft="0dp"
|
||||||
|
app:radiusTopRight="0dp"
|
||||||
|
android:visibility="visible"/>
|
||||||
</RelativeLayout>
|
</RelativeLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
|
||||||
|
@ -125,7 +125,13 @@
|
|||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="#BB000000"
|
android:background="#BB000000"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
android:orientation="vertical" />
|
android:gravity="center"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<com.futo.platformplayer.views.Loader
|
||||||
|
android:id="@+id/loader"
|
||||||
|
android:layout_width="50dp"
|
||||||
|
android:layout_height="50dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/container_unavailable"
|
android:id="@+id/container_unavailable"
|
||||||
|
@ -46,10 +46,9 @@
|
|||||||
app:layout_constraintLeft_toRightOf="@id/ic_viewers"
|
app:layout_constraintLeft_toRightOf="@id/ic_viewers"
|
||||||
tools:text="1536 viewers"/>
|
tools:text="1536 viewers"/>
|
||||||
|
|
||||||
<ScrollView
|
<HorizontalScrollView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="35dp"
|
android:layout_height="35dp"
|
||||||
android:orientation="horizontal"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
app:layout_constraintRight_toRightOf="parent">
|
app:layout_constraintRight_toRightOf="parent">
|
||||||
@ -61,7 +60,7 @@
|
|||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</ScrollView>
|
</HorizontalScrollView>
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/button_close"
|
android:id="@+id/button_close"
|
||||||
|
@ -8,7 +8,10 @@
|
|||||||
<string name="lorem_ipsum" translatable="false">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</string>
|
<string name="lorem_ipsum" translatable="false">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</string>
|
||||||
<string name="add_to_queue">Add to queue</string>
|
<string name="add_to_queue">Add to queue</string>
|
||||||
<string name="general">General</string>
|
<string name="general">General</string>
|
||||||
|
<string name="channel">Channel</string>
|
||||||
<string name="home">Home</string>
|
<string name="home">Home</string>
|
||||||
|
<string name="progress_bar">Progress Bar</string>
|
||||||
|
<string name="progress_bar_description">If a historical progress bar should be shown</string>
|
||||||
<string name="recommendations">Recommendations</string>
|
<string name="recommendations">Recommendations</string>
|
||||||
<string name="more">More</string>
|
<string name="more">More</string>
|
||||||
<string name="playlists">Playlists</string>
|
<string name="playlists">Playlists</string>
|
||||||
@ -26,6 +29,7 @@
|
|||||||
<string name="defaults">Defaults</string>
|
<string name="defaults">Defaults</string>
|
||||||
<string name="home_screen">Home Screen</string>
|
<string name="home_screen">Home Screen</string>
|
||||||
<string name="preferred_quality">Preferred Quality</string>
|
<string name="preferred_quality">Preferred Quality</string>
|
||||||
|
<string name="preferred_quality_description">Default quality for watching a video</string>
|
||||||
<string name="update">Update</string>
|
<string name="update">Update</string>
|
||||||
<string name="close">Close</string>
|
<string name="close">Close</string>
|
||||||
<string name="never">Never</string>
|
<string name="never">Never</string>
|
||||||
@ -267,6 +271,9 @@
|
|||||||
<string name="a_list_of_user_reported_and_self_reported_issues">A list of user-reported and self-reported issues</string>
|
<string name="a_list_of_user_reported_and_self_reported_issues">A list of user-reported and self-reported issues</string>
|
||||||
<string name="also_removes_any_data_related_plugin_like_login_or_settings">Also removes any data related plugin like login or settings</string>
|
<string name="also_removes_any_data_related_plugin_like_login_or_settings">Also removes any data related plugin like login or settings</string>
|
||||||
<string name="announcement">Announcement</string>
|
<string name="announcement">Announcement</string>
|
||||||
|
<string name="notifications">Notifications</string>
|
||||||
|
<string name="planned_content_notifications">Planned Content Notifications</string>
|
||||||
|
<string name="planned_content_notifications_description">Schedules discovered planned content as notifications, resulting in more accurate notifications for this content.</string>
|
||||||
<string name="attempt_to_utilize_byte_ranges">Attempt to utilize byte ranges</string>
|
<string name="attempt_to_utilize_byte_ranges">Attempt to utilize byte ranges</string>
|
||||||
<string name="auto_update">Auto Update</string>
|
<string name="auto_update">Auto Update</string>
|
||||||
<string name="auto_rotate">Auto-Rotate</string>
|
<string name="auto_rotate">Auto-Rotate</string>
|
||||||
@ -296,6 +303,8 @@
|
|||||||
<string name="clears_cookies_when_you_log_out">Clears cookies when you log out</string>
|
<string name="clears_cookies_when_you_log_out">Clears cookies when you log out</string>
|
||||||
<string name="clears_in_app_browser_cookies">Clears in-app browser cookies</string>
|
<string name="clears_in_app_browser_cookies">Clears in-app browser cookies</string>
|
||||||
<string name="configure_browsing_behavior">Configure browsing behavior</string>
|
<string name="configure_browsing_behavior">Configure browsing behavior</string>
|
||||||
|
<string name="time_bar">Time bar</string>
|
||||||
|
<string name="configure_if_historical_time_bar_should_be_shown">Configure if historical time bars should be shown</string>
|
||||||
<string name="configure_casting">Configure casting</string>
|
<string name="configure_casting">Configure casting</string>
|
||||||
<string name="configure_daily_backup_in_case_of_catastrophic_failure">Configure daily backup in case of catastrophic failure</string>
|
<string name="configure_daily_backup_in_case_of_catastrophic_failure">Configure daily backup in case of catastrophic failure</string>
|
||||||
<string name="configure_downloading_of_videos">Configure downloading of videos</string>
|
<string name="configure_downloading_of_videos">Configure downloading of videos</string>
|
||||||
@ -350,8 +359,11 @@
|
|||||||
<string name="player">Player</string>
|
<string name="player">Player</string>
|
||||||
<string name="plugins">Plugins</string>
|
<string name="plugins">Plugins</string>
|
||||||
<string name="preferred_casting_quality">Preferred Casting Quality</string>
|
<string name="preferred_casting_quality">Preferred Casting Quality</string>
|
||||||
|
<string name="preferred_casting_quality_description">Default quality while casting to an external device</string>
|
||||||
<string name="preferred_metered_quality">Preferred Metered Quality</string>
|
<string name="preferred_metered_quality">Preferred Metered Quality</string>
|
||||||
|
<string name="preferred_metered_quality_description">Default quality while on metered connections such as cellular</string>
|
||||||
<string name="preferred_preview_quality">Preferred Preview Quality</string>
|
<string name="preferred_preview_quality">Preferred Preview Quality</string>
|
||||||
|
<string name="preferred_preview_quality_description">Default quality while previewing a video in a feed</string>
|
||||||
<string name="primary_language">Primary Language</string>
|
<string name="primary_language">Primary Language</string>
|
||||||
<string name="default_comment_section">Default Comment Section</string>
|
<string name="default_comment_section">Default Comment Section</string>
|
||||||
<string name="reinstall_embedded_plugins">Reinstall Embedded Plugins</string>
|
<string name="reinstall_embedded_plugins">Reinstall Embedded Plugins</string>
|
||||||
|
@ -1 +1 @@
|
|||||||
Subproject commit d0b7a2c1b4939c27b4ec04ee52b5a16380c27afb
|
Subproject commit 396dd16987fba87e6f455a900426a5d7f22cbde3
|
@ -1 +1 @@
|
|||||||
Subproject commit a8bc4ff91301ef70f8fbabf181e78bbed828156d
|
Subproject commit 6ea204605d4a27867702d7b024237506904d53c7
|
@ -1 +1 @@
|
|||||||
Subproject commit 9e26b7032e64ed03315a8e75d2174cb4253030d1
|
Subproject commit 55aef15f4b4ad1359266bb77908b48726d32594b
|
@ -1 +1 @@
|
|||||||
Subproject commit 6732a56cd60522f995478399173dd020d8ffc828
|
Subproject commit 8d978dd7bd749f837f13322742329c8f769a1a57
|
@ -1 +1 @@
|
|||||||
Subproject commit d0b7a2c1b4939c27b4ec04ee52b5a16380c27afb
|
Subproject commit 396dd16987fba87e6f455a900426a5d7f22cbde3
|
@ -1 +1 @@
|
|||||||
Subproject commit a8bc4ff91301ef70f8fbabf181e78bbed828156d
|
Subproject commit 6ea204605d4a27867702d7b024237506904d53c7
|
@ -1 +1 @@
|
|||||||
Subproject commit 339b44e9f00521ab4cfe755a343fd9e6e5338d04
|
Subproject commit 55aef15f4b4ad1359266bb77908b48726d32594b
|
@ -1 +1 @@
|
|||||||
Subproject commit 6732a56cd60522f995478399173dd020d8ffc828
|
Subproject commit 8d978dd7bd749f837f13322742329c8f769a1a57
|
@ -1 +1 @@
|
|||||||
Subproject commit 7de4d54c25f087a2bc76a2704e575a6f9441987b
|
Subproject commit 839e4c4a4f5ed6cb6f68047f88b26c5831e6e703
|
Loading…
x
Reference in New Issue
Block a user