Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into db-store

This commit is contained in:
Kelvin 2023-11-24 15:22:34 +01:00
commit f3c9e0196e
70 changed files with 1910 additions and 335 deletions

View File

@ -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"

View File

@ -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)
}

View File

@ -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)
}
} }

View File

@ -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"
} }

View File

@ -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 {

View File

@ -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

View File

@ -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? {

View File

@ -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();
})
}
}
} }
} }

View File

@ -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);

View File

@ -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));

View File

@ -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 {

View File

@ -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") {

View File

@ -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) {

View File

@ -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 {

View File

@ -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);
} }
} }

View File

@ -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;

View File

@ -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;
} }

View File

@ -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 });
} }

View File

@ -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());
}
} }

View File

@ -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("&", "&amp;")}\",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("&", "&amp;")}\",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("&", "&amp;"))
}
return hlsBuilder.toString()
}
}
}

View File

@ -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();

View File

@ -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);

View File

@ -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());
} }

View File

@ -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");

View File

@ -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);

View File

@ -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);

View File

@ -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 ->

View File

@ -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);
} }
} }

View File

@ -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 ->

View File

@ -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 {

View File

@ -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)

View File

@ -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 ->

View File

@ -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;

View 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")
}
}
}

View File

@ -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";
}
}

View File

@ -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);
}
}
}

View File

@ -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;
}
}
}
}

View File

@ -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> {

View File

@ -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();

View File

@ -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)

View File

@ -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);

View File

@ -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);
} }
} }
}; };

View File

@ -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;
} }

View File

@ -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);

View File

@ -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) {

View File

@ -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");

View File

@ -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);

View File

@ -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();

View File

@ -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;

View File

@ -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) {}
} }

View File

@ -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);

View File

@ -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);

View File

@ -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);

View 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>

View File

@ -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>

View File

@ -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"

View File

@ -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">

View File

@ -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>

View File

@ -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"

View File

@ -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"

View File

@ -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