mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-04-20 03:24:50 +00:00
Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into db-store
This commit is contained in:
commit
f3c9e0196e
70 changed files with 1910 additions and 335 deletions
|
@ -9,6 +9,8 @@
|
|||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WRITE_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
|
||||
android:allowBackup="true"
|
||||
|
@ -39,6 +41,7 @@
|
|||
|
||||
<receiver android:name=".receivers.MediaControlReceiver" />
|
||||
<receiver android:name=".receivers.AudioNoisyReceiver" />
|
||||
<receiver android:name=".receivers.PlannedNotificationReceiver" />
|
||||
|
||||
<activity
|
||||
android:name=".activities.MainActivity"
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
package com.futo.platformplayer
|
||||
|
||||
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.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.Charset
|
||||
|
||||
|
||||
private const val IPV4_PART_COUNT = 4;
|
||||
|
@ -273,3 +277,46 @@ fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
|
|||
|
||||
return connectedSocket;
|
||||
}
|
||||
|
||||
fun InputStream.readHttpHeaderBytes() : ByteArray {
|
||||
val headerBytes = ByteArrayOutputStream()
|
||||
var crlfCount = 0
|
||||
|
||||
while (crlfCount < 4) {
|
||||
val b = read()
|
||||
if (b == -1) {
|
||||
throw IOException("Unexpected end of stream while reading headers")
|
||||
}
|
||||
|
||||
if (b == 0x0D || b == 0x0A) { // CR or LF
|
||||
crlfCount++
|
||||
} else {
|
||||
crlfCount = 0
|
||||
}
|
||||
|
||||
headerBytes.write(b)
|
||||
}
|
||||
|
||||
return headerBytes.toByteArray()
|
||||
}
|
||||
|
||||
fun InputStream.readLine() : String? {
|
||||
val line = ByteArrayOutputStream()
|
||||
var crlfCount = 0
|
||||
|
||||
while (crlfCount < 2) {
|
||||
val b = read()
|
||||
if (b == -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (b == 0x0D || b == 0x0A) { // CR or LF
|
||||
crlfCount++
|
||||
} else {
|
||||
crlfCount = 0
|
||||
line.write(b)
|
||||
}
|
||||
}
|
||||
|
||||
return String(line.toByteArray(), Charsets.UTF_8)
|
||||
}
|
|
@ -1,6 +1,11 @@
|
|||
package com.futo.platformplayer
|
||||
|
||||
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.views.adapters.CommentViewHolder
|
||||
import com.futo.polycentric.core.ProcessHandle
|
||||
import userpackage.Protocol
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.min
|
||||
|
@ -39,4 +44,21 @@ fun Protocol.Claim.resolveChannelUrl(): String? {
|
|||
|
||||
fun Protocol.Claim.resolveChannelUrls(): List<String> {
|
||||
return StatePlatform.instance.resolveChannelUrlsByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
|
||||
}
|
||||
|
||||
suspend fun ProcessHandle.fullyBackfillServersAnnounceExceptions() {
|
||||
val exceptions = fullyBackfillServers()
|
||||
for (pair in exceptions) {
|
||||
val server = pair.key
|
||||
val exception = pair.value
|
||||
|
||||
StateAnnouncement.instance.registerAnnouncement(
|
||||
"backfill-failed",
|
||||
"Backfill failed",
|
||||
"Failed to backfill server $server. $exception",
|
||||
AnnouncementType.SESSION_RECURRING
|
||||
);
|
||||
|
||||
Logger.e("Backfill", "Failed to backfill server $server.", exception)
|
||||
}
|
||||
}
|
|
@ -1,5 +1,10 @@
|
|||
package com.futo.platformplayer
|
||||
|
||||
import android.net.Uri
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
import java.net.URLEncoder
|
||||
|
||||
//Syntax sugaring
|
||||
inline fun <reified T> Any.assume(): T?{
|
||||
if(this is T)
|
||||
|
@ -12,4 +17,12 @@ inline fun <reified T, R> Any.assume(cb: (T) -> R): R? {
|
|||
if(result != null)
|
||||
return cb(result);
|
||||
return null;
|
||||
}
|
||||
|
||||
fun String?.yesNoToBoolean(): Boolean {
|
||||
return this?.uppercase() == "YES"
|
||||
}
|
||||
|
||||
fun Boolean?.toYesNo(): String {
|
||||
return if (this == true) "YES" else "NO"
|
||||
}
|
|
@ -158,7 +158,11 @@ class Settings : FragmentedStorageFileJson() {
|
|||
var previewFeedItems: Boolean = true;
|
||||
|
||||
|
||||
@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)
|
||||
fun clearHidden() {
|
||||
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)
|
||||
var previewFeedItems: Boolean = true;
|
||||
|
||||
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||
var progressBar: Boolean = false;
|
||||
|
||||
|
||||
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();
|
||||
@Serializable
|
||||
class SubscriptionsSettings {
|
||||
|
@ -213,14 +229,17 @@ class Settings : FragmentedStorageFileJson() {
|
|||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
|
||||
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)
|
||||
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;
|
||||
|
||||
@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)
|
||||
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)
|
||||
var subscriptionConcurrency: Int = 3;
|
||||
|
||||
|
@ -244,17 +263,17 @@ class Settings : FragmentedStorageFileJson() {
|
|||
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;
|
||||
|
||||
@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;
|
||||
|
||||
|
||||
@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;
|
||||
|
||||
@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() {
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing..");
|
||||
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();
|
||||
@Serializable
|
||||
class PlaybackSettings {
|
||||
|
@ -288,29 +307,29 @@ class Settings : FragmentedStorageFileJson() {
|
|||
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)
|
||||
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)
|
||||
var preferredMeteredQuality: Int = 0;
|
||||
fun getPreferredQualityPixelCount(): Int = preferedQualityToPixels(preferredQuality);
|
||||
fun getPreferredMeteredQualityPixelCount(): Int = preferedQualityToPixels(preferredMeteredQuality);
|
||||
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)
|
||||
var preferredPreviewQuality: Int = 5;
|
||||
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)
|
||||
var autoRotate: Int = 2;
|
||||
|
||||
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)
|
||||
var autoRotateDeadZone: Int = 0;
|
||||
|
||||
|
@ -318,7 +337,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||
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)
|
||||
var backgroundPlay: Int = 2;
|
||||
|
||||
|
@ -360,7 +379,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||
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();
|
||||
@Serializable
|
||||
class CommentSettings {
|
||||
|
@ -369,7 +388,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||
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();
|
||||
@Serializable
|
||||
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();
|
||||
@Serializable
|
||||
class Browsing {
|
||||
|
@ -418,7 +437,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||
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();
|
||||
@Serializable
|
||||
class Casting {
|
||||
|
@ -446,8 +465,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||
}*/
|
||||
}
|
||||
|
||||
|
||||
@FormField(R.string.logging, FieldForm.GROUP, -1, 8)
|
||||
@FormField(R.string.logging, FieldForm.GROUP, -1, 10)
|
||||
var logging = Logging();
|
||||
@Serializable
|
||||
class Logging {
|
||||
|
@ -471,9 +489,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@FormField(R.string.announcement, FieldForm.GROUP, -1, 10)
|
||||
@FormField(R.string.announcement, FieldForm.GROUP, -1, 11)
|
||||
var announcementSettings = AnnouncementSettings();
|
||||
@Serializable
|
||||
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
|
||||
var plugins = Plugins();
|
||||
@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();
|
||||
@Serializable
|
||||
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();
|
||||
@Serializable
|
||||
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();
|
||||
@Serializable
|
||||
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();
|
||||
@Serializable
|
||||
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();
|
||||
@Serializable
|
||||
class Other {
|
||||
|
@ -716,7 +740,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||
var bypassRotationPrevention: Boolean = false;
|
||||
}
|
||||
|
||||
@FormField(R.string.info, FieldForm.GROUP, -1, 16)
|
||||
@FormField(R.string.info, FieldForm.GROUP, -1, 19)
|
||||
var info = Info();
|
||||
@Serializable
|
||||
class Info {
|
||||
|
|
|
@ -20,6 +20,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
|||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.background.BackgroundWorker
|
||||
import com.futo.platformplayer.cache.ChannelContentCache
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
|
||||
|
@ -111,6 +112,14 @@ class SettingsDev : FragmentedStorageFileJson() {
|
|||
.build();
|
||||
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
|
||||
@Transient
|
||||
|
|
|
@ -10,6 +10,7 @@ import android.widget.ImageButton
|
|||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
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.sources.IAudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
||||
|
@ -54,7 +55,6 @@ class UISlideOverlays {
|
|||
|
||||
fun showSubscriptionOptionsOverlay(subscription: Subscription, container: ViewGroup) {
|
||||
val items = arrayListOf<View>();
|
||||
var menu: SlideUpMenuOverlay? = null;
|
||||
|
||||
val originalNotif = subscription.doNotifications;
|
||||
val originalLive = subscription.doFetchLive;
|
||||
|
@ -62,54 +62,69 @@ class UISlideOverlays {
|
|||
val originalVideo = subscription.doFetchVideos;
|
||||
val originalPosts = subscription.doFetchPosts;
|
||||
|
||||
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()),
|
||||
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)));
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){
|
||||
val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url);
|
||||
val capabilities = plugin.getChannelCapabilities();
|
||||
|
||||
menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, items);
|
||||
withContext(Dispatchers.Main) {
|
||||
|
||||
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);
|
||||
var menu: SlideUpMenuOverlay? = null;
|
||||
|
||||
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? {
|
||||
|
|
|
@ -8,6 +8,7 @@ import android.content.res.Configuration
|
|||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.preference.PreferenceManager
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
|
@ -884,15 +885,20 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
|
||||
if((fragment?.isOverlay ?: false) && fragBeforeOverlay != null) {
|
||||
navigate(fragBeforeOverlay!!, null, false, true);
|
||||
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
val last = _queue.lastOrNull();
|
||||
if (last != null) {
|
||||
_queue.remove(last);
|
||||
navigate(last.first, last.second, false, true);
|
||||
} else
|
||||
finish();
|
||||
} else {
|
||||
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
|
||||
finish();
|
||||
} else {
|
||||
UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", {
|
||||
finish();
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ import androidx.appcompat.app.AppCompatActivity
|
|||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
|
@ -82,7 +83,7 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
|
|||
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill");
|
||||
processHandle.fullyBackfillServers();
|
||||
processHandle.fullyBackfillServersAnnounceExceptions();
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, getString(R.string.failed_to_fully_backfill_servers), e);
|
||||
|
|
|
@ -19,6 +19,7 @@ import com.futo.platformplayer.R
|
|||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.dialogs.CommentDialog
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.selectBestImage
|
||||
|
@ -194,7 +195,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
|||
if (hasChanges) {
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill");
|
||||
processHandle.fullyBackfillServers();
|
||||
processHandle.fullyBackfillServersAnnounceExceptions();
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.changes_have_been_saved));
|
||||
|
|
|
@ -69,9 +69,11 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
|||
}
|
||||
|
||||
fun reloadSettings() {
|
||||
_form.setSearchVisible(false);
|
||||
_loader.start();
|
||||
_form.fromObject(lifecycleScope, Settings.instance) {
|
||||
_loader.stop();
|
||||
_form.setSearchVisible(true);
|
||||
|
||||
var devCounter = 0;
|
||||
_form.findField("code")?.assume<ReadOnlyTextField>()?.setOnClickListener {
|
||||
|
|
|
@ -197,8 +197,13 @@ class HttpContext : AutoCloseable {
|
|||
}
|
||||
fun respondCode(status: Int, headers: HttpHeaders, body: String? = null) {
|
||||
val bytes = body?.toByteArray(Charsets.UTF_8);
|
||||
if(body != null && headers.get("content-length").isNullOrEmpty())
|
||||
headers.put("content-length", bytes!!.size.toString());
|
||||
if(headers.get("content-length").isNullOrEmpty()) {
|
||||
if (body != null) {
|
||||
headers.put("content-length", bytes!!.size.toString());
|
||||
} else {
|
||||
headers.put("content-length", "0")
|
||||
}
|
||||
}
|
||||
respond(status, headers) { responseStream ->
|
||||
if(body != null) {
|
||||
responseStream.write(bytes!!);
|
||||
|
@ -219,8 +224,7 @@ class HttpContext : AutoCloseable {
|
|||
headersToRespond.put("keep-alive", "timeout=5, max=1000");
|
||||
}
|
||||
|
||||
val responseHeader = HttpResponse(status, headers);
|
||||
|
||||
val responseHeader = HttpResponse(status, headersToRespond);
|
||||
responseStream.write(responseHeader.getHttpHeaderBytes());
|
||||
|
||||
if(method != "HEAD") {
|
||||
|
|
|
@ -5,6 +5,7 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
|
|||
import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpFuntionHandler
|
||||
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.OutputStream
|
||||
import java.lang.reflect.Field
|
||||
|
@ -17,6 +18,7 @@ import java.util.*
|
|||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.stream.IntStream.range
|
||||
import kotlin.collections.HashMap
|
||||
|
||||
class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
||||
private val _client : ManagedHttpClient = ManagedHttpClient();
|
||||
|
@ -28,7 +30,8 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
|||
var port = 0
|
||||
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;
|
||||
|
||||
@Synchronized
|
||||
|
@ -114,32 +117,78 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
|||
|
||||
fun getHandler(method: String, path: String) : HttpHandler? {
|
||||
synchronized(_handlers) {
|
||||
//TODO: Support regex paths?
|
||||
if(method == "HEAD")
|
||||
return _handlers.firstOrNull { it.path == path && (it.allowHEAD || it.method == "HEAD") }
|
||||
return _handlers.firstOrNull { it.method == method && it.path == path };
|
||||
if (method == "HEAD") {
|
||||
return _headHandlers[path]
|
||||
}
|
||||
|
||||
val handlerMap = _handlers[method] ?: return null
|
||||
return handlerMap[path]
|
||||
}
|
||||
}
|
||||
fun addHandler(handler: HttpHandler, withHEAD: Boolean = false) : HttpHandler {
|
||||
synchronized(_handlers) {
|
||||
_handlers.add(handler);
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
synchronized(_handlers) {
|
||||
val handler = getHandler(method, path);
|
||||
if(handler != null)
|
||||
_handlers.remove(handler);
|
||||
val handlerMap = _handlers[method] ?: return
|
||||
val handler = handlerMap.remove(path) ?: return
|
||||
if (method == "HEAD" || handler.allowHEAD) {
|
||||
_headHandlers.remove(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
fun removeAllHandlers(tag: String? = null) {
|
||||
synchronized(_handlers) {
|
||||
if(tag == null)
|
||||
_handlers.clear();
|
||||
else
|
||||
_handlers.removeIf { it.tag == tag };
|
||||
else {
|
||||
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) {
|
||||
|
|
|
@ -15,6 +15,7 @@ abstract class HttpHandler(val method: String, val path: String) {
|
|||
headers.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
fun withContentType(contentType: String) = withHeader("Content-Type", contentType);
|
||||
|
||||
fun withTag(tag: String) : HttpHandler {
|
||||
|
|
|
@ -2,19 +2,18 @@ package com.futo.platformplayer.api.http.server.handlers
|
|||
|
||||
import com.futo.platformplayer.api.http.server.HttpContext
|
||||
|
||||
class HttpOptionsAllowHandler(path: String) : HttpHandler("OPTIONS", path) {
|
||||
class HttpOptionsAllowHandler(path: String, val allowedMethods: List<String> = listOf()) : HttpHandler("OPTIONS", path) {
|
||||
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", "");
|
||||
val requestedMethods = httpContext.headers.getOrDefault("Access-Control-Request-Method", "");
|
||||
val requestedHeaders = httpContext.headers.getOrDefault("Access-Control-Request-Headers", "");
|
||||
|
||||
val newHeaders = headers.clone();
|
||||
newHeaders.put("Allow", requestedMethods);
|
||||
newHeaders.put("Access-Control-Allow-Methods", requestedMethods);
|
||||
newHeaders.put("Access-Control-Allow-Headers", "*");
|
||||
if (allowedMethods.isNotEmpty()) {
|
||||
newHeaders.put("Access-Control-Allow-Methods", allowedMethods.map { it.uppercase() }.joinToString(", "))
|
||||
} else {
|
||||
newHeaders.put("Access-Control-Allow-Methods", "*")
|
||||
}
|
||||
|
||||
newHeaders.put("Access-Control-Allow-Headers", "*")
|
||||
httpContext.respondCode(200, newHeaders);
|
||||
}
|
||||
}
|
|
@ -1,12 +1,20 @@
|
|||
package com.futo.platformplayer.api.http.server.handlers
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.futo.platformplayer.api.http.server.HttpContext
|
||||
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
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 contentType: String? = null;
|
||||
|
||||
|
@ -18,10 +26,17 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
|
|||
private var _injectHost = false;
|
||||
private var _injectReferer = false;
|
||||
|
||||
|
||||
private val _client = ManagedHttpClient();
|
||||
|
||||
override fun handle(context: HttpContext) {
|
||||
if (useTcp) {
|
||||
handleWithTcp(context)
|
||||
} else {
|
||||
handleWithOkHttp(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleWithOkHttp(context: HttpContext) {
|
||||
val proxyHeaders = HashMap<String, String>();
|
||||
for (header in context.headers.filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) })
|
||||
proxyHeaders[header.key] = header.value;
|
||||
|
@ -35,8 +50,8 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
|
|||
proxyHeaders.put("Referer", targetUrl);
|
||||
|
||||
val useMethod = if (method == "inherit") context.method else method;
|
||||
Logger.i(TAG, "Proxied Request ${useMethod}: ${targetUrl}");
|
||||
Logger.i(TAG, "Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
|
||||
Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${targetUrl}");
|
||||
Logger.i(TAG, "handleWithOkHttp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
|
||||
|
||||
val resp = when (useMethod) {
|
||||
"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}]");
|
||||
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)
|
||||
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 {
|
||||
this.content = body;
|
||||
return this;
|
||||
|
|
|
@ -6,10 +6,13 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
|
|||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourceAuth
|
||||
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
|
||||
|
||||
class JSHttpClient : ManagedHttpClient {
|
||||
private val _jsClient: JSClient?;
|
||||
private val _jsConfig: SourcePluginConfig?;
|
||||
private val _auth: SourceAuth?;
|
||||
private val _captcha: SourceCaptchaData?;
|
||||
|
||||
|
@ -20,8 +23,9 @@ class JSHttpClient : ManagedHttpClient {
|
|||
|
||||
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;
|
||||
_jsConfig = config;
|
||||
_auth = auth;
|
||||
_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;
|
||||
}
|
||||
|
||||
|
|
|
@ -52,7 +52,7 @@ class DedupContentPager : IPager<IPlatformContent>, IAsyncPager<IPlatformContent
|
|||
val sameItems = results.filter { isSameItem(result, it) };
|
||||
val platformItemMap = sameItems.groupBy { it.id.pluginId }.mapValues { (_, items) -> items.first() }
|
||||
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 });
|
||||
}
|
||||
|
|
|
@ -6,33 +6,25 @@ import android.app.PendingIntent
|
|||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
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.work.CoroutineWorker
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.target.CustomTarget
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
import com.futo.platformplayer.Settings
|
||||
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.cache.ChannelContentCache
|
||||
import com.futo.platformplayer.getNowDiffSeconds
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
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.views.adapters.viewholders.TabViewHolder
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.futo.platformplayer.toHumanNowDiffString
|
||||
import com.futo.platformplayer.toHumanNowDiffStringMinDay
|
||||
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 java.time.OffsetDateTime
|
||||
|
||||
|
@ -54,8 +46,10 @@ class BackgroundWorker(private val appContext: Context, private val workerParams
|
|||
this.setSound(null, null);
|
||||
};
|
||||
notificationManager.createNotificationChannel(notificationChannel);
|
||||
val contentChannel = StateNotifications.instance.contentNotifChannel
|
||||
notificationManager.createNotificationChannel(contentChannel);
|
||||
try {
|
||||
doSubscriptionUpdating(notificationManager, notificationChannel);
|
||||
doSubscriptionUpdating(notificationManager, notificationChannel, contentChannel);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
exception = ex;
|
||||
|
@ -77,13 +71,13 @@ class BackgroundWorker(private val appContext: Context, private val workerParams
|
|||
}
|
||||
|
||||
|
||||
suspend fun doSubscriptionUpdating(manager: NotificationManager, notificationChannel: NotificationChannel) {
|
||||
val notif = NotificationCompat.Builder(appContext, notificationChannel.id)
|
||||
suspend fun doSubscriptionUpdating(manager: NotificationManager, backgroundChannel: NotificationChannel, contentChannel: NotificationChannel) {
|
||||
val notif = NotificationCompat.Builder(appContext, backgroundChannel.id)
|
||||
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground)
|
||||
.setContentTitle("Grayjay")
|
||||
.setContentText("Updating subscriptions...")
|
||||
.setSilent(true)
|
||||
.setChannelId(notificationChannel.id)
|
||||
.setChannelId(backgroundChannel.id)
|
||||
.setProgress(1, 0, true);
|
||||
|
||||
manager.notify(12, notif.build());
|
||||
|
@ -94,6 +88,7 @@ class BackgroundWorker(private val appContext: Context, private val workerParams
|
|||
val newItems = mutableListOf<IPlatformContent>();
|
||||
|
||||
val now = OffsetDateTime.now();
|
||||
val threeDays = now.minusDays(4);
|
||||
val contentNotifs = mutableListOf<Pair<Subscription, IPlatformContent>>();
|
||||
withContext(Dispatchers.IO) {
|
||||
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) {
|
||||
if(!newSubChanges.contains(sub)) {
|
||||
newSubChanges.add(sub);
|
||||
if(sub.doNotifications && content.datetime?.let { it < now } == true)
|
||||
contentNotifs.add(Pair(sub, content));
|
||||
if(sub.doNotifications) {
|
||||
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);
|
||||
}
|
||||
|
@ -135,22 +136,7 @@ class BackgroundWorker(private val appContext: Context, private val workerParams
|
|||
val items = contentNotifs.take(5).toList()
|
||||
for(i in items.indices) {
|
||||
val contentNotif = items.get(i);
|
||||
val thumbnail = if(contentNotif.second is IPlatformVideo) (contentNotif.second as IPlatformVideo).thumbnails.getHQThumbnail()
|
||||
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);
|
||||
StateNotifications.instance.notifyNewContentWithThumbnail(appContext, manager, contentChannel, 13 + i, contentNotif.second);
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
|
@ -165,20 +151,4 @@ class BackgroundWorker(private val appContext: Context, private val workerParams
|
|||
.setSilent(true)
|
||||
.setChannelId(notificationChannel.id).build());*/
|
||||
}
|
||||
|
||||
fun notifyNewContent(manager: NotificationManager, notificationChannel: NotificationChannel, id: Int, sub: Subscription, content: IPlatformContent, thumbnail: Bitmap? = null) {
|
||||
val notifBuilder = NotificationCompat.Builder(appContext, notificationChannel.id)
|
||||
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground)
|
||||
.setContentTitle("New by [${sub.channel.name}]")
|
||||
.setContentText("${content.name}")
|
||||
.setSilent(true)
|
||||
.setContentIntent(PendingIntent.getActivity(this.appContext, 0, MainActivity.getVideoIntent(this.appContext, content.url),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||
.setChannelId(notificationChannel.id);
|
||||
if(thumbnail != null) {
|
||||
//notifBuilder.setLargeIcon(thumbnail);
|
||||
notifBuilder.setStyle(NotificationCompat.BigPictureStyle().bigPicture(thumbnail).bigLargeIcon(null as Bitmap?));
|
||||
}
|
||||
manager.notify(id, notifBuilder.build());
|
||||
}
|
||||
}
|
|
@ -1,37 +0,0 @@
|
|||
package com.futo.platformplayer.builders
|
||||
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
|
||||
class HlsBuilder {
|
||||
companion object{
|
||||
fun generateOnDemandHLS(vidSource: IVideoSource, vidUrl: String, audioSource: IAudioSource?, audioUrl: String?, subtitleSource: ISubtitleSource?, subtitleUrl: String?): String {
|
||||
val hlsBuilder = StringWriter()
|
||||
PrintWriter(hlsBuilder).use { writer ->
|
||||
writer.println("#EXTM3U")
|
||||
|
||||
// Audio
|
||||
if (audioSource != null && audioUrl != null) {
|
||||
val audioFormat = audioSource.container.substringAfter("/")
|
||||
writer.println("#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audio\",LANGUAGE=\"en\",NAME=\"English\",AUTOSELECT=YES,DEFAULT=YES,URI=\"${audioUrl.replace("&", "&")}\",FORMAT=\"$audioFormat\"")
|
||||
}
|
||||
|
||||
// Subtitles
|
||||
if (subtitleSource != null && subtitleUrl != null) {
|
||||
val subtitleFormat = subtitleSource.format ?: "text/vtt"
|
||||
writer.println("#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",LANGUAGE=\"en\",NAME=\"English\",AUTOSELECT=YES,DEFAULT=YES,URI=\"${subtitleUrl.replace("&", "&")}\",FORMAT=\"$subtitleFormat\"")
|
||||
}
|
||||
|
||||
// Video
|
||||
val videoFormat = vidSource.container.substringAfter("/")
|
||||
writer.println("#EXT-X-STREAM-INF:BANDWIDTH=100000,CODECS=\"${vidSource.codec}\",RESOLUTION=${vidSource.width}x${vidSource.height}${if (audioSource != null) ",AUDIO=\"audio\"" else ""}${if (subtitleSource != null) ",SUBTITLES=\"subs\"" else ""},FORMAT=\"$videoFormat\"")
|
||||
writer.println(vidUrl.replace("&", "&"))
|
||||
}
|
||||
|
||||
return hlsBuilder.toString()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -58,6 +58,14 @@ class ChannelContentCache {
|
|||
uncacheContent(content);
|
||||
}
|
||||
}
|
||||
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 {
|
||||
val validID = channelUrl.toSafeFileName();
|
||||
|
|
|
@ -69,7 +69,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
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;
|
||||
_streamType = streamType;
|
||||
|
@ -314,6 +314,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
try {
|
||||
_socket?.close()
|
||||
_socket = factory.createSocket(usedRemoteAddress, port) as SSLSocket;
|
||||
_socket?.startHandshake();
|
||||
Logger.i(TAG, "Successfully connected to Chromecast at $usedRemoteAddress:$port");
|
||||
|
@ -324,7 +325,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to authenticate to Chromecast.", e);
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
} catch (e: Throwable) {
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Failed to connect to Chromecast.", e);
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import android.content.ContentResolver
|
|||
import android.content.Context
|
||||
import android.os.Looper
|
||||
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.handlers.*
|
||||
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.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.parsers.HLS
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.*
|
||||
import java.net.InetAddress
|
||||
|
@ -45,6 +47,7 @@ class StateCasting {
|
|||
val onActiveDevicePlayChanged = Event1<Boolean>();
|
||||
val onActiveDeviceTimeChanged = Event1<Double>();
|
||||
var activeDevice: CastingDevice? = null;
|
||||
private val _client = ManagedHttpClient();
|
||||
|
||||
val isCasting: Boolean get() = activeDevice != null;
|
||||
|
||||
|
@ -331,20 +334,25 @@ class StateCasting {
|
|||
}
|
||||
|
||||
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) {
|
||||
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 {
|
||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
if (ad is FastCastCastingDevice) {
|
||||
Logger.i(TAG, "Casting as DASH direct");
|
||||
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 {
|
||||
Logger.i(TAG, "Casting as DASH indirect");
|
||||
castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition);
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
|
@ -353,19 +361,35 @@ class StateCasting {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
if (videoSource is IVideoUrlSource)
|
||||
ad.loadVideo("BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble());
|
||||
else if(videoSource is IHLSManifestSource)
|
||||
ad.loadVideo("BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble());
|
||||
else if (audioSource is IAudioUrlSource)
|
||||
ad.loadVideo("BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble());
|
||||
else if(audioSource is IHLSManifestAudioSource)
|
||||
ad.loadVideo("BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble());
|
||||
else if (videoSource is LocalVideoSource)
|
||||
if (videoSource is IVideoUrlSource) {
|
||||
Logger.i(TAG, "Casting as singular video");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble());
|
||||
} else if (audioSource is IAudioUrlSource) {
|
||||
Logger.i(TAG, "Casting as singular audio");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble());
|
||||
} else if(videoSource is IHLSManifestSource) {
|
||||
if (ad is ChromecastCastingDevice) {
|
||||
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);
|
||||
else if (audioSource is LocalAudioSource)
|
||||
} else if (audioSource is LocalAudioSource) {
|
||||
Logger.i(TAG, "Casting as local audio");
|
||||
castLocalAudio(video, audioSource, resumePosition);
|
||||
else {
|
||||
} else {
|
||||
var str = listOf(
|
||||
if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null,
|
||||
if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null,
|
||||
|
@ -402,15 +426,23 @@ class StateCasting {
|
|||
return true;
|
||||
}
|
||||
|
||||
private fun castVideoIndirect() {
|
||||
|
||||
}
|
||||
|
||||
private fun castAudioIndirect() {
|
||||
|
||||
}
|
||||
|
||||
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double) : List<String> {
|
||||
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 videoPath = "/video-${id}"
|
||||
val videoUrl = url + videoPath;
|
||||
|
||||
_castServer.addHandler(
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
|
@ -424,12 +456,12 @@ class StateCasting {
|
|||
private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double) : List<String> {
|
||||
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 audioPath = "/audio-${id}"
|
||||
val audioUrl = url + audioPath;
|
||||
|
||||
_castServer.addHandler(
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
|
@ -440,11 +472,106 @@ class StateCasting {
|
|||
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> {
|
||||
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 dashPath = "/dash-${id}"
|
||||
|
@ -457,43 +584,28 @@ class StateCasting {
|
|||
val audioUrl = url + audioPath;
|
||||
val subtitleUrl = url + subtitlePath;
|
||||
|
||||
_castServer.addHandler(
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpConstantHandler("GET", dashPath, DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitleUrl),
|
||||
"application/dash+xml")
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
if (videoSource != null) {
|
||||
_castServer.addHandler(
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
_castServer.addHandler(
|
||||
HttpOptionsAllowHandler(videoPath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*")
|
||||
.withHeader("Connection", "keep-alive"))
|
||||
.withTag("cast");
|
||||
}
|
||||
if (audioSource != null) {
|
||||
_castServer.addHandler(
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
_castServer.addHandler(
|
||||
HttpOptionsAllowHandler(audioPath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*")
|
||||
.withHeader("Connection", "keep-alive"))
|
||||
.withTag("cast");
|
||||
}
|
||||
if (subtitleSource != null) {
|
||||
_castServer.addHandler(
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).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).");
|
||||
|
@ -505,7 +617,7 @@ class StateCasting {
|
|||
private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> {
|
||||
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 subtitlePath = "/subtitle-${id}";
|
||||
|
||||
|
@ -527,7 +639,7 @@ class StateCasting {
|
|||
}
|
||||
|
||||
if (content != null) {
|
||||
_castServer.addHandler(
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
|
@ -547,13 +659,311 @@ class StateCasting {
|
|||
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> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val proxyStreams = ad !is FastCastCastingDevice;
|
||||
|
||||
val url = "http://${ad.localAddress}:${_castServer.port}";
|
||||
Logger.i(TAG, "DASH url: $url");
|
||||
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val dashPath = "/dash-${id}"
|
||||
|
@ -562,6 +972,8 @@ class StateCasting {
|
|||
val subtitlePath = "/subtitle-${id}"
|
||||
|
||||
val dashUrl = url + dashPath;
|
||||
Logger.i(TAG, "DASH url: $dashUrl");
|
||||
|
||||
val videoUrl = if(proxyStreams) url + videoPath else videoSource?.getVideoUrl();
|
||||
val audioUrl = if(proxyStreams) url + audioPath else audioSource?.getAudioUrl();
|
||||
|
||||
|
@ -583,7 +995,7 @@ class StateCasting {
|
|||
}
|
||||
|
||||
if (content != null) {
|
||||
_castServer.addHandler(
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
|
@ -595,38 +1007,29 @@ class StateCasting {
|
|||
}
|
||||
}
|
||||
|
||||
_castServer.addHandler(
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpConstantHandler("GET", dashPath, DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl),
|
||||
"application/dash+xml")
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
|
||||
if (videoSource != null) {
|
||||
_castServer.addHandler(
|
||||
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl())
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
_castServer.addHandler(
|
||||
HttpOptionsAllowHandler(videoPath)
|
||||
.withHeader("Access-Control-Allow-Origin", "*")
|
||||
.withHeader("Connection", "keep-alive"))
|
||||
.withTag("cast");
|
||||
}
|
||||
if (audioSource != null) {
|
||||
_castServer.addHandler(
|
||||
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl())
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).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).");
|
||||
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());
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ import com.google.gson.JsonArray
|
|||
import com.google.gson.JsonParser
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.util.UUID
|
||||
import kotlin.reflect.jvm.jvmErasure
|
||||
|
||||
|
@ -185,7 +186,11 @@ class DeveloperEndpoints(private val context: Context) {
|
|||
val config = context.readContentJson<SourcePluginConfig>()
|
||||
try {
|
||||
_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());
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
|
@ -235,7 +240,7 @@ class DeveloperEndpoints(private val context: Context) {
|
|||
}
|
||||
LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||
_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");
|
||||
|
@ -311,6 +316,11 @@ class DeveloperEndpoints(private val context: Context) {
|
|||
val json = wrapRemoteResult(callResult, false);
|
||||
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) {
|
||||
if(ilEx.message?.contains("does not exist") ?: false) {
|
||||
context.respondCode(400, ilEx.message ?: "", "text/plain");
|
||||
|
|
|
@ -20,6 +20,7 @@ import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComm
|
|||
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.selectBestImage
|
||||
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) {
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill");
|
||||
processHandle.fullyBackfillServers()
|
||||
processHandle.fullyBackfillServersAnnounceExceptions()
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to backfill servers.", e);
|
||||
|
|
|
@ -11,6 +11,7 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
|
@ -58,6 +59,7 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
|
|||
val onChannelClicked = Event1<PlatformAuthorLink>();
|
||||
val onAddToClicked = Event1<IPlatformContent>();
|
||||
val onAddToQueueClicked = Event1<IPlatformContent>();
|
||||
val onLongPress = Event1<IPlatformContent>();
|
||||
|
||||
private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> {
|
||||
Logger.i(TAG, "getContentPager");
|
||||
|
@ -151,13 +153,14 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
|
|||
|
||||
_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.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit);
|
||||
this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit);
|
||||
this.onChannelClicked.subscribe(this@ChannelContentsFragment.onChannelClicked::emit);
|
||||
this.onAddToClicked.subscribe(this@ChannelContentsFragment.onAddToClicked::emit);
|
||||
this.onAddToQueueClicked.subscribe(this@ChannelContentsFragment.onAddToQueueClicked::emit);
|
||||
this.onLongPress.subscribe(this@ChannelContentsFragment.onLongPress::emit);
|
||||
}
|
||||
|
||||
_llmVideo = LinearLayoutManager(view.context);
|
||||
|
|
|
@ -223,6 +223,12 @@ class ChannelFragment : MainFragment() {
|
|||
else -> {};
|
||||
}
|
||||
}
|
||||
adapter.onLongPress.subscribe { content ->
|
||||
_overlayContainer.let {
|
||||
if(content is IPlatformVideo)
|
||||
_slideUpOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it);
|
||||
}
|
||||
}
|
||||
viewPager.adapter = adapter;
|
||||
|
||||
val tabLayoutMediator = TabLayoutMediator(tabs, viewPager) { tab, position ->
|
||||
|
|
|
@ -37,6 +37,7 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
|||
override val visibleThreshold: Int get() = if (feedStyle == FeedStyle.PREVIEW) { 5 } else { 10 };
|
||||
protected lateinit var headerView: LinearLayout;
|
||||
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) {
|
||||
|
||||
|
@ -57,7 +58,7 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
|||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -84,6 +84,7 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||
private var _channelUrl: String? = null;
|
||||
|
||||
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
|
||||
override val shouldShowTimeBar: Boolean get() = Settings.instance.search.progressBar
|
||||
|
||||
constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) {
|
||||
_taskSearch = TaskHandler<String, IPager<IPlatformContent>>({fragment.lifecycleScope}, { query ->
|
||||
|
|
|
@ -95,6 +95,7 @@ class HomeFragment : MainFragment() {
|
|||
private var _announcementsView: AnnouncementView;
|
||||
|
||||
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) {
|
||||
_announcementsView = AnnouncementView(context, null).apply {
|
||||
|
|
|
@ -31,6 +31,7 @@ import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
|||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.fixHtmlWhitespace
|
||||
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||
|
@ -363,7 +364,7 @@ class PostDetailFragment : MainFragment {
|
|||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill");
|
||||
args.processHandle.fullyBackfillServers();
|
||||
args.processHandle.fullyBackfillServersAnnounceExceptions();
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to backfill servers", e)
|
||||
|
|
|
@ -93,6 +93,8 @@ class SubscriptionsFeedFragment : MainFragment() {
|
|||
|
||||
@SuppressLint("ViewConstructor")
|
||||
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) {
|
||||
Logger.i(TAG, "SubscriptionsFeedFragment constructor()");
|
||||
StateSubscriptions.instance.onGlobalSubscriptionsUpdateProgress.subscribe(this) { progress, total ->
|
||||
|
|
|
@ -125,6 +125,7 @@ class VideoDetailView : ConstraintLayout {
|
|||
private var _searchVideo: IPlatformVideo? = null;
|
||||
var video: IPlatformVideoDetails? = null
|
||||
private set;
|
||||
var videoLocal: VideoLocal? = null;
|
||||
private var _playbackTracker: IPlaybackTracker? = null;
|
||||
private var _historyIndex: DBHistory.Index? = null;
|
||||
|
||||
|
@ -1055,10 +1056,32 @@ class VideoDetailView : ConstraintLayout {
|
|||
_player.setPlaybackRate(Settings.instance.playback.getDefaultPlaybackSpeed());
|
||||
}
|
||||
|
||||
val video = if(videoDetail is VideoLocal)
|
||||
videoDetail;
|
||||
else //TODO: Update cached video if it exists with video
|
||||
StateDownloads.instance.getCachedVideo(videoDetail.id) ?: videoDetail;
|
||||
var videoLocal: VideoLocal? = null;
|
||||
var video: IPlatformVideoDetails? = null;
|
||||
|
||||
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._playbackTracker = null;
|
||||
|
||||
|
@ -1093,9 +1116,13 @@ class VideoDetailView : ConstraintLayout {
|
|||
me._playbackTracker = tracker;
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_get_playback_tracker), ex);
|
||||
Logger.e(TAG, "Playback tracker failed", 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) {
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill");
|
||||
args.processHandle.fullyBackfillServers();
|
||||
args.processHandle.fullyBackfillServersAnnounceExceptions();
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to backfill servers", e)
|
||||
|
@ -1246,7 +1273,7 @@ class VideoDetailView : ConstraintLayout {
|
|||
|
||||
|
||||
//Overlay
|
||||
updateQualitySourcesOverlay(video);
|
||||
updateQualitySourcesOverlay(video, videoLocal);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
|
@ -1503,6 +1530,7 @@ class VideoDetailView : ConstraintLayout {
|
|||
_overlay_quality_selector?.selectOption("audio", _lastAudioSource);
|
||||
_overlay_quality_selector?.selectOption("subtitles", _lastSubtitleSource);
|
||||
_overlay_quality_selector?.show();
|
||||
_slideUpOverlay = _overlay_quality_selector;
|
||||
}
|
||||
|
||||
fun prevVideo() {
|
||||
|
@ -1530,9 +1558,9 @@ class VideoDetailView : ConstraintLayout {
|
|||
//Quality Selector data
|
||||
private fun updateQualityFormatsOverlay(liveStreamVideoFormats : List<Format>?, liveStreamAudioFormats : List<Format>?) {
|
||||
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");
|
||||
|
||||
val video: IPlatformVideoDetails?;
|
||||
|
@ -1540,24 +1568,35 @@ class VideoDetailView : ConstraintLayout {
|
|||
val localAudioSource: List<LocalAudioSource>?;
|
||||
val localSubtitleSources: List<LocalSubtitleSource>?;
|
||||
|
||||
val videoSources: List<IVideoSource>?;
|
||||
val audioSources: List<IAudioSource>?;
|
||||
|
||||
if(videoDetails is VideoLocal) {
|
||||
video = videoDetails.videoSerialized;
|
||||
video = videoLocal?.videoSerialized;
|
||||
localVideoSources = videoDetails.videoSource.toList();
|
||||
localAudioSource = videoDetails.audioSource.toList();
|
||||
localSubtitleSources = videoDetails.subtitlesSources.toList();
|
||||
videoSources = null
|
||||
audioSources = null;
|
||||
}
|
||||
else {
|
||||
video = videoDetails;
|
||||
localVideoSources = null;
|
||||
localAudioSource = null;
|
||||
localSubtitleSources = null;
|
||||
videoSources = video?.video?.videoSources?.toList();
|
||||
audioSources = if(video?.video?.isUnMuxed == true)
|
||||
(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 }
|
||||
?.distinct()
|
||||
?.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) {
|
||||
Logger.i(TAG, "setCastEnabled(isCasting=$isCasting)")
|
||||
|
||||
video?.let { updateQualitySourcesOverlay(it); };
|
||||
video?.let { updateQualitySourcesOverlay(it, videoLocal); };
|
||||
|
||||
_isCasting = isCasting;
|
||||
|
||||
|
|
332
app/src/main/java/com/futo/platformplayer/parsers/HLS.kt
Normal file
332
app/src/main/java/com/futo/platformplayer/parsers/HLS.kt
Normal file
|
@ -0,0 +1,332 @@
|
|||
package com.futo.platformplayer.parsers
|
||||
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.toYesNo
|
||||
import com.futo.platformplayer.yesNoToBoolean
|
||||
import java.net.URI
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
class HLS {
|
||||
companion object {
|
||||
fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String): MasterPlaylist {
|
||||
val baseUrl = URI(sourceUrl).resolve("./").toString()
|
||||
|
||||
val variantPlaylists = mutableListOf<VariantPlaylistReference>()
|
||||
val mediaRenditions = mutableListOf<MediaRendition>()
|
||||
val sessionDataList = mutableListOf<SessionData>()
|
||||
var independentSegments = false
|
||||
|
||||
masterPlaylistContent.lines().forEachIndexed { index, line ->
|
||||
when {
|
||||
line.startsWith("#EXT-X-STREAM-INF") -> {
|
||||
val nextLine = masterPlaylistContent.lines().getOrNull(index + 1)
|
||||
?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none")
|
||||
val url = resolveUrl(baseUrl, nextLine)
|
||||
|
||||
variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line)))
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-MEDIA") -> {
|
||||
mediaRenditions.add(parseMediaRendition(line, baseUrl))
|
||||
}
|
||||
|
||||
line == "#EXT-X-INDEPENDENT-SEGMENTS" -> {
|
||||
independentSegments = true
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-SESSION-DATA") -> {
|
||||
val sessionData = parseSessionData(line)
|
||||
sessionDataList.add(sessionData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments)
|
||||
}
|
||||
|
||||
fun parseVariantPlaylist(content: String, sourceUrl: String): VariantPlaylist {
|
||||
val lines = content.lines()
|
||||
val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull()
|
||||
val targetDuration = lines.find { it.startsWith("#EXT-X-TARGETDURATION:") }?.substringAfter(":")?.toIntOrNull()
|
||||
val mediaSequence = lines.find { it.startsWith("#EXT-X-MEDIA-SEQUENCE:") }?.substringAfter(":")?.toLongOrNull()
|
||||
val discontinuitySequence = lines.find { it.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE:") }?.substringAfter(":")?.toIntOrNull()
|
||||
val programDateTime = lines.find { it.startsWith("#EXT-X-PROGRAM-DATE-TIME:") }?.substringAfter(":")?.let {
|
||||
ZonedDateTime.parse(it, DateTimeFormatter.ISO_DATE_TIME)
|
||||
}
|
||||
val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":")
|
||||
val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) }
|
||||
|
||||
val segments = mutableListOf<Segment>()
|
||||
var currentSegment: MediaSegment? = null
|
||||
lines.forEachIndexed { index, line ->
|
||||
when {
|
||||
line.startsWith("#EXTINF:") -> {
|
||||
val duration = line.substringAfter(":").substringBefore(",").toDoubleOrNull()
|
||||
?: throw Exception("Invalid segment duration format")
|
||||
currentSegment = MediaSegment(duration = duration)
|
||||
}
|
||||
line == "#EXT-X-DISCONTINUITY" -> {
|
||||
segments.add(DiscontinuitySegment())
|
||||
}
|
||||
line =="#EXT-X-ENDLIST" -> {
|
||||
segments.add(EndListSegment())
|
||||
}
|
||||
else -> {
|
||||
currentSegment?.let {
|
||||
it.uri = resolveUrl(sourceUrl, line)
|
||||
segments.add(it)
|
||||
}
|
||||
currentSegment = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments)
|
||||
}
|
||||
|
||||
private fun resolveUrl(baseUrl: String, url: String): String {
|
||||
val baseUri = URI(baseUrl)
|
||||
val urlUri = URI(url)
|
||||
|
||||
return if (urlUri.isAbsolute) {
|
||||
url
|
||||
} else {
|
||||
val resolvedUri = baseUri.resolve(urlUri)
|
||||
resolvedUri.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseStreamInfo(content: String): StreamInfo {
|
||||
val attributes = parseAttributes(content)
|
||||
return StreamInfo(
|
||||
bandwidth = attributes["BANDWIDTH"]?.toIntOrNull(),
|
||||
resolution = attributes["RESOLUTION"],
|
||||
codecs = attributes["CODECS"],
|
||||
frameRate = attributes["FRAME-RATE"],
|
||||
videoRange = attributes["VIDEO-RANGE"],
|
||||
audio = attributes["AUDIO"],
|
||||
video = attributes["VIDEO"],
|
||||
subtitles = attributes["SUBTITLES"],
|
||||
closedCaptions = attributes["CLOSED-CAPTIONS"]
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseMediaRendition(line: String, baseUrl: String): MediaRendition {
|
||||
val attributes = parseAttributes(line)
|
||||
val uri = attributes["URI"]?.let { resolveUrl(baseUrl, it) }
|
||||
return MediaRendition(
|
||||
type = attributes["TYPE"],
|
||||
uri = uri,
|
||||
groupID = attributes["GROUP-ID"],
|
||||
language = attributes["LANGUAGE"],
|
||||
name = attributes["NAME"],
|
||||
isDefault = attributes["DEFAULT"]?.yesNoToBoolean(),
|
||||
isAutoSelect = attributes["AUTOSELECT"]?.yesNoToBoolean(),
|
||||
isForced = attributes["FORCED"]?.yesNoToBoolean()
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseSessionData(line: String): SessionData {
|
||||
val attributes = parseAttributes(line)
|
||||
val dataId = attributes["DATA-ID"]!!
|
||||
val value = attributes["VALUE"]!!
|
||||
return SessionData(dataId, value)
|
||||
}
|
||||
|
||||
private fun parseAttributes(content: String): Map<String, String> {
|
||||
val attributes = mutableMapOf<String, String>()
|
||||
val attributePairs = content.substringAfter(":").splitToSequence(',')
|
||||
|
||||
var currentPair = StringBuilder()
|
||||
for (pair in attributePairs) {
|
||||
currentPair.append(pair)
|
||||
if (currentPair.count { it == '\"' } % 2 == 0) { // Check if the number of quotes is even
|
||||
val (key, value) = currentPair.toString().split('=')
|
||||
attributes[key.trim()] = value.trim().removeSurrounding("\"")
|
||||
currentPair = StringBuilder() // Reset for the next attribute
|
||||
} else {
|
||||
currentPair.append(',') // Continue building the current attribute pair
|
||||
}
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
private val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO", "VIDEO")
|
||||
private fun shouldQuote(key: String, value: String?): Boolean {
|
||||
if (value == null)
|
||||
return false;
|
||||
|
||||
if (value.contains(','))
|
||||
return true;
|
||||
|
||||
return _quoteList.contains(key)
|
||||
}
|
||||
private fun appendAttributes(stringBuilder: StringBuilder, vararg attributes: Pair<String, String?>) {
|
||||
attributes.filter { it.second != null }
|
||||
.joinToString(",") {
|
||||
val value = it.second
|
||||
"${it.first}=${if (shouldQuote(it.first, it.second)) "\"$value\"" else value}"
|
||||
}
|
||||
.let { if (it.isNotEmpty()) stringBuilder.append(it) }
|
||||
}
|
||||
}
|
||||
|
||||
data class SessionData(
|
||||
val dataId: String,
|
||||
val value: String
|
||||
) {
|
||||
fun toM3U8Line(): String = buildString {
|
||||
append("#EXT-X-SESSION-DATA:")
|
||||
appendAttributes(this,
|
||||
"DATA-ID" to dataId,
|
||||
"VALUE" to value
|
||||
)
|
||||
append("\n")
|
||||
}
|
||||
}
|
||||
|
||||
data class StreamInfo(
|
||||
val bandwidth: Int?,
|
||||
val resolution: String?,
|
||||
val codecs: String?,
|
||||
val frameRate: String?,
|
||||
val videoRange: String?,
|
||||
val audio: String?,
|
||||
val video: String?,
|
||||
val subtitles: String?,
|
||||
val closedCaptions: String?
|
||||
) {
|
||||
fun toM3U8Line(): String = buildString {
|
||||
append("#EXT-X-STREAM-INF:")
|
||||
appendAttributes(this,
|
||||
"BANDWIDTH" to bandwidth?.toString(),
|
||||
"RESOLUTION" to resolution,
|
||||
"CODECS" to codecs,
|
||||
"FRAME-RATE" to frameRate,
|
||||
"VIDEO-RANGE" to videoRange,
|
||||
"AUDIO" to audio,
|
||||
"VIDEO" to video,
|
||||
"SUBTITLES" to subtitles,
|
||||
"CLOSED-CAPTIONS" to closedCaptions
|
||||
)
|
||||
append("\n")
|
||||
}
|
||||
}
|
||||
|
||||
data class MediaRendition(
|
||||
val type: String?,
|
||||
val uri: String?,
|
||||
val groupID: String?,
|
||||
val language: String?,
|
||||
val name: String?,
|
||||
val isDefault: Boolean?,
|
||||
val isAutoSelect: Boolean?,
|
||||
val isForced: Boolean?
|
||||
) {
|
||||
fun toM3U8Line(): String = buildString {
|
||||
append("#EXT-X-MEDIA:")
|
||||
appendAttributes(this,
|
||||
"TYPE" to type,
|
||||
"URI" to uri,
|
||||
"GROUP-ID" to groupID,
|
||||
"LANGUAGE" to language,
|
||||
"NAME" to name,
|
||||
"DEFAULT" to isDefault.toYesNo(),
|
||||
"AUTOSELECT" to isAutoSelect.toYesNo(),
|
||||
"FORCED" to isForced.toYesNo()
|
||||
)
|
||||
append("\n")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
data class MasterPlaylist(
|
||||
val variantPlaylistsRefs: List<VariantPlaylistReference>,
|
||||
val mediaRenditions: List<MediaRendition>,
|
||||
val sessionDataList: List<SessionData>,
|
||||
val independentSegments: Boolean
|
||||
) {
|
||||
fun buildM3U8(): String {
|
||||
val builder = StringBuilder()
|
||||
builder.append("#EXTM3U\n")
|
||||
if (independentSegments) {
|
||||
builder.append("#EXT-X-INDEPENDENT-SEGMENTS\n")
|
||||
}
|
||||
|
||||
mediaRenditions.forEach { rendition ->
|
||||
builder.append(rendition.toM3U8Line())
|
||||
}
|
||||
|
||||
variantPlaylistsRefs.forEach { variant ->
|
||||
builder.append(variant.toM3U8Line())
|
||||
}
|
||||
|
||||
sessionDataList.forEach { data ->
|
||||
builder.append(data.toM3U8Line())
|
||||
}
|
||||
|
||||
return builder.toString()
|
||||
}
|
||||
}
|
||||
|
||||
data class VariantPlaylistReference(val url: String, val streamInfo: StreamInfo) {
|
||||
fun toM3U8Line(): String = buildString {
|
||||
append(streamInfo.toM3U8Line())
|
||||
append("$url\n")
|
||||
}
|
||||
}
|
||||
|
||||
data class VariantPlaylist(
|
||||
val version: Int?,
|
||||
val targetDuration: Int?,
|
||||
val mediaSequence: Long?,
|
||||
val discontinuitySequence: Int?,
|
||||
val programDateTime: ZonedDateTime?,
|
||||
val playlistType: String?,
|
||||
val streamInfo: StreamInfo?,
|
||||
val segments: List<Segment>
|
||||
) {
|
||||
fun buildM3U8(): String = buildString {
|
||||
append("#EXTM3U\n")
|
||||
version?.let { append("#EXT-X-VERSION:$it\n") }
|
||||
targetDuration?.let { append("#EXT-X-TARGETDURATION:$it\n") }
|
||||
mediaSequence?.let { append("#EXT-X-MEDIA-SEQUENCE:$it\n") }
|
||||
discontinuitySequence?.let { append("#EXT-X-DISCONTINUITY-SEQUENCE:$it\n") }
|
||||
playlistType?.let { append("#EXT-X-PLAYLIST-TYPE:$it\n") }
|
||||
programDateTime?.let { append("#EXT-X-PROGRAM-DATE-TIME:${it.format(DateTimeFormatter.ISO_DATE_TIME)}\n") }
|
||||
streamInfo?.let { append(it.toM3U8Line()) }
|
||||
|
||||
segments.forEach { segment ->
|
||||
append(segment.toM3U8Line())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class Segment {
|
||||
abstract fun toM3U8Line(): String
|
||||
}
|
||||
|
||||
data class MediaSegment (
|
||||
val duration: Double,
|
||||
var uri: String = ""
|
||||
) : Segment() {
|
||||
override fun toM3U8Line(): String = buildString {
|
||||
append("#EXTINF:${duration},\n")
|
||||
append(uri + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
class DiscontinuitySegment : Segment() {
|
||||
override fun toM3U8Line(): String = buildString {
|
||||
append("#EXT-X-DISCONTINUITY\n")
|
||||
}
|
||||
}
|
||||
|
||||
class EndListSegment : Segment() {
|
||||
override fun toM3U8Line(): String = buildString {
|
||||
append("#EXT-X-ENDLIST\n")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
package com.futo.platformplayer.parsers
|
||||
|
||||
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||
import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException
|
||||
import com.futo.platformplayer.readHttpHeaderBytes
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
|
||||
class HttpResponseParser : AutoCloseable {
|
||||
private val _inputStream: InputStream;
|
||||
|
||||
var head: String = "";
|
||||
var headers: HttpHeaders = HttpHeaders();
|
||||
|
||||
var contentType: String? = null;
|
||||
var transferEncoding: String? = null;
|
||||
var location: String? = null;
|
||||
var contentLength: Long = -1L;
|
||||
|
||||
var statusCode: Int = -1;
|
||||
|
||||
constructor(inputStream: InputStream) {
|
||||
_inputStream = inputStream;
|
||||
|
||||
val headerBytes = inputStream.readHttpHeaderBytes()
|
||||
ByteArrayInputStream(headerBytes).use {
|
||||
val reader = it.bufferedReader(Charsets.UTF_8)
|
||||
head = reader.readLine() ?: throw EmptyRequestException("No head found");
|
||||
|
||||
val statusLineParts = head.split(" ")
|
||||
if (statusLineParts.size < 3) {
|
||||
throw IllegalStateException("Invalid status line")
|
||||
}
|
||||
|
||||
statusCode = statusLineParts[1].toInt()
|
||||
|
||||
while (true) {
|
||||
val line = reader.readLine();
|
||||
val headerEndIndex = line.indexOf(":");
|
||||
if (headerEndIndex == -1)
|
||||
break;
|
||||
|
||||
val headerKey = line.substring(0, headerEndIndex).lowercase()
|
||||
val headerValue = line.substring(headerEndIndex + 1).trim();
|
||||
headers[headerKey] = headerValue;
|
||||
|
||||
when(headerKey) {
|
||||
"content-length" -> contentLength = headerValue.toLong();
|
||||
"content-type" -> contentType = headerValue;
|
||||
"transfer-encoding" -> transferEncoding = headerValue;
|
||||
"location" -> location = headerValue;
|
||||
}
|
||||
if(line.isNullOrEmpty())
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
_inputStream.close();
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "HttpResponse";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
package com.futo.platformplayer.receivers
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateNotifications
|
||||
|
||||
|
||||
class PlannedNotificationReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
try {
|
||||
Logger.i(TAG, "Planned Notification received");
|
||||
if(!Settings.instance.notifications.plannedContentNotification)
|
||||
return;
|
||||
if(StateApp.instance.contextOrNull == null)
|
||||
StateApp.instance.initializeFiles();
|
||||
|
||||
val notifs = StateNotifications.instance.getScheduledNotifications(60 * 15, true);
|
||||
if(!notifs.isEmpty() && context != null) {
|
||||
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
|
||||
val channel = StateNotifications.instance.contentNotifChannel;
|
||||
notificationManager.createNotificationChannel(channel);
|
||||
var i = 0;
|
||||
for (notif in notifs) {
|
||||
StateNotifications.instance.notifyNewContentWithThumbnail(context, notificationManager, channel, 110 + i, notif);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed PlannedNotificationReceiver.onReceive", ex);
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "PlannedNotificationReceiver"
|
||||
|
||||
fun getIntent(context: Context): PendingIntent {
|
||||
return PendingIntent.getBroadcast(context, 110, Intent(context, PlannedNotificationReceiver::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,147 @@
|
|||
package com.futo.platformplayer.states
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.target.CustomTarget
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.receivers.PlannedNotificationReceiver
|
||||
import com.futo.platformplayer.serializers.PlatformContentSerializer
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.toHumanNowDiffString
|
||||
import com.futo.platformplayer.toHumanNowDiffStringMinDay
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
class StateNotifications {
|
||||
private val _alarmManagerLock = Object();
|
||||
private var _alarmManager: AlarmManager? = null;
|
||||
val plannedWarningMinutesEarly: Long = 10;
|
||||
|
||||
val contentNotifChannel = NotificationChannel("contentChannel", "Content Notifications",
|
||||
NotificationManager.IMPORTANCE_HIGH).apply {
|
||||
this.enableVibration(false);
|
||||
this.setSound(null, null);
|
||||
};
|
||||
|
||||
private val _plannedContent = FragmentedStorage.storeJson<SerializedPlatformContent>("planned_content_notifs", PlatformContentSerializer())
|
||||
.load();
|
||||
|
||||
private fun getAlarmManager(context: Context): AlarmManager {
|
||||
synchronized(_alarmManagerLock) {
|
||||
if(_alarmManager == null)
|
||||
_alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager;
|
||||
return _alarmManager!!;
|
||||
}
|
||||
}
|
||||
|
||||
fun scheduleContentNotification(context: Context, content: IPlatformContent) {
|
||||
try {
|
||||
var existing = _plannedContent.findItem { it.url == content.url };
|
||||
if(existing != null) {
|
||||
_plannedContent.delete(existing);
|
||||
existing = null;
|
||||
}
|
||||
if(existing == null && content.datetime != null) {
|
||||
val item = SerializedPlatformContent.fromContent(content);
|
||||
_plannedContent.saveAsync(item);
|
||||
|
||||
val manager = getAlarmManager(context);
|
||||
val notifyDateTime = content.datetime!!.minusMinutes(plannedWarningMinutesEarly);
|
||||
if(Build.VERSION.SDK_INT >= 31 && !manager.canScheduleExactAlarms()) {
|
||||
Logger.i(TAG, "Scheduling in-exact notification for [${content.name}] at ${notifyDateTime.toHumanNowDiffString()}")
|
||||
manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, notifyDateTime.toEpochSecond().times(1000), PlannedNotificationReceiver.getIntent(context));
|
||||
}
|
||||
else {
|
||||
Logger.i(TAG, "Scheduling exact notification for [${content.name}] at ${notifyDateTime.toHumanNowDiffString()}")
|
||||
manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, notifyDateTime.toEpochSecond().times(1000), PlannedNotificationReceiver.getIntent(context))
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "scheduleContentNotification failed for [${content.name}]", ex);
|
||||
}
|
||||
}
|
||||
fun removeChannelPlannedContent(channelUrl: String) {
|
||||
val toDeletes = _plannedContent.findItems { it.author.url == channelUrl };
|
||||
for(toDelete in toDeletes)
|
||||
_plannedContent.delete(toDelete);
|
||||
}
|
||||
|
||||
fun getScheduledNotifications(secondsFuture: Long, deleteReturned: Boolean = false): List<SerializedPlatformContent> {
|
||||
val minDate = OffsetDateTime.now().plusSeconds(secondsFuture);
|
||||
val toNotify = _plannedContent.findItems { it.datetime?.let { it.isBefore(minDate) } == true }
|
||||
|
||||
if(deleteReturned) {
|
||||
for(toDelete in toNotify)
|
||||
_plannedContent.delete(toDelete);
|
||||
}
|
||||
return toNotify;
|
||||
}
|
||||
|
||||
fun notifyNewContentWithThumbnail(context: Context, manager: NotificationManager, notificationChannel: NotificationChannel, id: Int, content: IPlatformContent) {
|
||||
val thumbnail = if(content is IPlatformVideo) (content as IPlatformVideo).thumbnails.getHQThumbnail()
|
||||
else null;
|
||||
if(thumbnail != null)
|
||||
Glide.with(context).asBitmap()
|
||||
.load(thumbnail)
|
||||
.into(object: CustomTarget<Bitmap>() {
|
||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||
notifyNewContent(context, manager, notificationChannel, id, content, resource);
|
||||
}
|
||||
override fun onLoadCleared(placeholder: Drawable?) {}
|
||||
override fun onLoadFailed(errorDrawable: Drawable?) {
|
||||
notifyNewContent(context, manager, notificationChannel, id, content, null);
|
||||
}
|
||||
})
|
||||
else
|
||||
notifyNewContent(context, manager, notificationChannel, id, content, null);
|
||||
}
|
||||
|
||||
fun notifyNewContent(context: Context, manager: NotificationManager, notificationChannel: NotificationChannel, id: Int, content: IPlatformContent, thumbnail: Bitmap? = null) {
|
||||
val notifBuilder = NotificationCompat.Builder(context, notificationChannel.id)
|
||||
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground)
|
||||
.setContentTitle("New by [${content.author.name}]")
|
||||
.setContentText("${content.name}")
|
||||
.setSubText(content.datetime?.toHumanNowDiffStringMinDay())
|
||||
.setSilent(true)
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0, MainActivity.getVideoIntent(context, content.url),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||
.setChannelId(notificationChannel.id);
|
||||
if(thumbnail != null) {
|
||||
//notifBuilder.setLargeIcon(thumbnail);
|
||||
notifBuilder.setStyle(NotificationCompat.BigPictureStyle().bigPicture(thumbnail).bigLargeIcon(null as Bitmap?));
|
||||
}
|
||||
manager.notify(id, notifBuilder.build());
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
val TAG = "StateNotifications";
|
||||
private var _instance : StateNotifications? = null;
|
||||
val instance : StateNotifications
|
||||
get(){
|
||||
if(_instance == null)
|
||||
_instance = StateNotifications();
|
||||
return _instance!!;
|
||||
};
|
||||
|
||||
fun finish() {
|
||||
_instance?.let {
|
||||
_instance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -33,7 +33,7 @@ class FragmentedStorage {
|
|||
fun initialize(filesDir: File) {
|
||||
_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(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> {
|
||||
|
|
|
@ -20,6 +20,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec
|
|||
val onChannelClicked = Event1<PlatformAuthorLink>();
|
||||
val onAddToClicked = Event1<IPlatformContent>();
|
||||
val onAddToQueueClicked = Event1<IPlatformContent>();
|
||||
val onLongPress = Event1<IPlatformContent>();
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return _cache.size;
|
||||
|
@ -55,6 +56,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec
|
|||
onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit);
|
||||
onAddToClicked.subscribe(this@ChannelViewPagerAdapter.onAddToClicked::emit);
|
||||
onAddToQueueClicked.subscribe(this@ChannelViewPagerAdapter.onAddToQueueClicked::emit);
|
||||
onLongPress.subscribe(this@ChannelViewPagerAdapter.onLongPress::emit);
|
||||
};
|
||||
1 -> ChannelListFragment.newInstance().apply { onClickChannel.subscribe(onChannelClicked::emit) };
|
||||
//2 -> ChannelStoreFragment.newInstance();
|
||||
|
|
|
@ -75,7 +75,7 @@ class CommentViewHolder : ViewHolder {
|
|||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill");
|
||||
args.processHandle.fullyBackfillServers();
|
||||
args.processHandle.fullyBackfillServersAnnounceExceptions();
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to backfill servers.", e)
|
||||
|
|
|
@ -29,6 +29,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
|
|||
private val _exoPlayer: PlayerManager?;
|
||||
private val _feedStyle : FeedStyle;
|
||||
private var _paused: Boolean = false;
|
||||
private val _shouldShowTimeBar: Boolean
|
||||
|
||||
val onUrlClicked = Event1<String>();
|
||||
val onContentUrlClicked = Event2<String, ContentType>();
|
||||
|
@ -48,12 +49,13 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
|
|||
|
||||
constructor(context: Context, feedStyle : FeedStyle, dataSet: ArrayList<IPlatformContent>, exoPlayer: PlayerManager? = null,
|
||||
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._dataSet = dataSet;
|
||||
this._initialPlay = initialPlay;
|
||||
this._exoPlayer = exoPlayer;
|
||||
this._shouldShowTimeBar = shouldShowTimeBar
|
||||
}
|
||||
|
||||
override fun getChildCount(): Int = _dataSet.size;
|
||||
|
@ -97,7 +99,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
|
|||
};
|
||||
private fun createPlaceholderViewHolder(viewGroup: ViewGroup): PreviewPlaceholderViewHolder
|
||||
= 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.onChannelClicked.subscribe(this@PreviewContentListAdapter.onChannelClicked::emit);
|
||||
this.onAddToClicked.subscribe(this@PreviewContentListAdapter.onAddToClicked::emit);
|
||||
|
|
|
@ -5,10 +5,12 @@ import android.view.View
|
|||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
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.IPlatformContent
|
||||
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.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
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.video.PlayerManager
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.Loader
|
||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
@ -25,6 +28,7 @@ class PreviewNestedVideoView : PreviewVideoView {
|
|||
|
||||
protected val _platformIndicatorNested: PlatformIndicator;
|
||||
protected val _containerLoader: LinearLayout;
|
||||
protected val _loader: Loader;
|
||||
protected val _containerUnavailable: LinearLayout;
|
||||
protected val _textNestedUrl: TextView;
|
||||
|
||||
|
@ -38,8 +42,39 @@ class PreviewNestedVideoView : PreviewVideoView {
|
|||
constructor(context: Context, feedStyle: FeedStyle, exoPlayer: PlayerManager? = null): super(context, feedStyle, exoPlayer) {
|
||||
_platformIndicatorNested = findViewById(R.id.thumbnail_platform_nested);
|
||||
_containerLoader = findViewById(R.id.container_loader);
|
||||
_loader = findViewById(R.id.loader);
|
||||
_containerUnavailable = findViewById(R.id.container_unavailable);
|
||||
_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) {
|
||||
|
@ -81,6 +116,7 @@ class PreviewNestedVideoView : PreviewVideoView {
|
|||
if(!_contentSupported) {
|
||||
_containerUnavailable.visibility = View.VISIBLE;
|
||||
_containerLoader.visibility = View.GONE;
|
||||
_loader.stop();
|
||||
}
|
||||
else {
|
||||
if(_feedStyle == FeedStyle.THUMBNAIL)
|
||||
|
@ -96,12 +132,14 @@ class PreviewNestedVideoView : PreviewVideoView {
|
|||
_contentSupported = false;
|
||||
_containerUnavailable.visibility = View.VISIBLE;
|
||||
_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}]");
|
||||
_containerLoader.visibility = View.VISIBLE;
|
||||
_loader.start();
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
val def = StatePlatform.instance.getContentDetails(content.contentUrl);
|
||||
def.invokeOnCompletion {
|
||||
|
@ -112,11 +150,13 @@ class PreviewNestedVideoView : PreviewVideoView {
|
|||
if(_content == content) {
|
||||
_containerUnavailable.visibility = View.VISIBLE;
|
||||
_containerLoader.visibility = View.GONE;
|
||||
_loader.stop();
|
||||
}
|
||||
//TODO: Handle exception
|
||||
}
|
||||
else if(_content == content) {
|
||||
_containerLoader.visibility = View.GONE;
|
||||
_loader.stop();
|
||||
val nestedContent = def.getCompleted();
|
||||
_contentNested = nestedContent;
|
||||
if(nestedContent is IPlatformVideoDetails) {
|
||||
|
@ -131,6 +171,8 @@ class PreviewNestedVideoView : PreviewVideoView {
|
|||
else {
|
||||
_containerUnavailable.visibility = View.VISIBLE;
|
||||
}
|
||||
if(onCompleted != null)
|
||||
onCompleted(nestedContent);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -27,9 +27,11 @@ import com.futo.platformplayer.logging.Logger
|
|||
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateDownloads
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
import com.futo.platformplayer.video.PlayerManager
|
||||
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||
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.video.FutoThumbnailPlayer
|
||||
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
||||
|
@ -67,6 +69,8 @@ open class PreviewVideoView : LinearLayout {
|
|||
Logger.w(TAG, "Failed to load profile.", it);
|
||||
};
|
||||
|
||||
private val _timeBar: ProgressBar?;
|
||||
|
||||
val onVideoClicked = Event2<IPlatformVideo, Long>();
|
||||
val onLongPress = Event1<IPlatformVideo>();
|
||||
val onChannelClicked = Event1<PlatformAuthorLink>();
|
||||
|
@ -77,10 +81,12 @@ open class PreviewVideoView : LinearLayout {
|
|||
private set
|
||||
|
||||
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);
|
||||
_feedStyle = feedStyle;
|
||||
this.shouldShowTimeBar = shouldShowTimeBar
|
||||
val playerContainer = findViewById<FrameLayout>(R.id.player_container);
|
||||
|
||||
val displayMetrics = Resources.getSystem().displayMetrics;
|
||||
|
@ -117,6 +123,7 @@ open class PreviewVideoView : LinearLayout {
|
|||
_button_add_to = findViewById(R.id.button_add_to);
|
||||
_imageNeopassChannel = findViewById(R.id.image_neopass_channel);
|
||||
_layoutDownloaded = findViewById(R.id.layout_downloaded);
|
||||
_timeBar = findViewById(R.id.time_bar)
|
||||
|
||||
this._exoPlayer = exoPlayer
|
||||
|
||||
|
@ -235,13 +242,26 @@ open class PreviewVideoView : LinearLayout {
|
|||
_containerLive.visibility = GONE;
|
||||
_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 {
|
||||
currentVideo = null;
|
||||
_imageVideo.setImageResource(0);
|
||||
_containerDuration.visibility = GONE;
|
||||
_containerLive.visibility = GONE;
|
||||
_timeBar?.visibility = GONE;
|
||||
}
|
||||
|
||||
_textVideoMetadata.text = metadata + timeMeta;
|
||||
}
|
||||
|
||||
|
|
|
@ -27,8 +27,8 @@ class PreviewVideoViewHolder : ContentPreviewViewHolder {
|
|||
|
||||
private val view: PreviewVideoView get() = itemView as PreviewVideoView;
|
||||
|
||||
constructor(viewGroup: ViewGroup, feedStyle : FeedStyle, exoPlayer: PlayerManager? = null): super(
|
||||
PreviewVideoView(viewGroup.context, feedStyle, exoPlayer)
|
||||
constructor(viewGroup: ViewGroup, feedStyle : FeedStyle, exoPlayer: PlayerManager? = null, shouldShowTimeBar: Boolean = true): super(
|
||||
PreviewVideoView(viewGroup.context, feedStyle, exoPlayer, shouldShowTimeBar)
|
||||
) {
|
||||
view.onVideoClicked.subscribe(onVideoClicked::emit);
|
||||
view.onChannelClicked.subscribe(onChannelClicked::emit);
|
||||
|
|
|
@ -19,6 +19,9 @@ open class BigButton : LinearLayout {
|
|||
private val _textPrimary: TextView;
|
||||
private val _textSecondary: TextView;
|
||||
|
||||
val title: String get() = _textPrimary.text.toString();
|
||||
val description: String get() = _textSecondary.text.toString();
|
||||
|
||||
val onClick = Event0();
|
||||
|
||||
constructor(context : Context, text: String, subText: String, icon: Int, action: ()->Unit) : super(context) {
|
||||
|
|
|
@ -28,6 +28,10 @@ class ButtonField : BigButton, IField {
|
|||
|
||||
override val value: Any? = null;
|
||||
|
||||
override val searchContent: String?
|
||||
get() = "$title $description";
|
||||
|
||||
|
||||
override val obj : Any? get() {
|
||||
if(this._obj == null)
|
||||
throw java.lang.IllegalStateException("Can only be called if fromField is used");
|
||||
|
|
|
@ -41,6 +41,9 @@ class DropdownField : TableRow, IField {
|
|||
|
||||
override val value: Any? get() = _selected;
|
||||
|
||||
override val searchContent: String?
|
||||
get() = "${_title.text} ${_description.text}";
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs){
|
||||
inflate(context, R.layout.field_dropdown, this);
|
||||
_spinner = findViewById(R.id.field_spinner);
|
||||
|
|
|
@ -23,6 +23,8 @@ interface IField {
|
|||
|
||||
var reference: Any?;
|
||||
|
||||
val searchContent: String?;
|
||||
|
||||
fun fromField(obj : Any, field : Field, formField: FormField? = null) : IField;
|
||||
fun setField();
|
||||
|
||||
|
|
|
@ -3,12 +3,14 @@ package com.futo.platformplayer.views.fields
|
|||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
@ -24,11 +26,12 @@ import kotlin.reflect.full.hasAnnotation
|
|||
import kotlin.reflect.jvm.javaField
|
||||
import kotlin.reflect.jvm.javaMethod
|
||||
import kotlin.streams.asStream
|
||||
import kotlin.streams.toList
|
||||
|
||||
class FieldForm : LinearLayout {
|
||||
|
||||
private val _root : LinearLayout;
|
||||
private val _containerSearch: FrameLayout;
|
||||
private val _editSearch: EditText;
|
||||
private val _fieldsContainer : LinearLayout;
|
||||
|
||||
val onChanged = Event2<IField, Any>();
|
||||
|
||||
|
@ -36,11 +39,45 @@ class FieldForm : LinearLayout {
|
|||
|
||||
constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs) {
|
||||
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) {
|
||||
_root.removeAllViews();
|
||||
_fieldsContainer.removeAllViews();
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
val newFields = getFieldsFromObject(context, obj);
|
||||
|
@ -50,7 +87,7 @@ class FieldForm : LinearLayout {
|
|||
if (field !is View)
|
||||
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 ->
|
||||
onChanged.emit(a1, a2);
|
||||
};
|
||||
|
@ -62,13 +99,13 @@ class FieldForm : LinearLayout {
|
|||
}
|
||||
}
|
||||
fun fromObject(obj : Any) {
|
||||
_root.removeAllViews();
|
||||
_fieldsContainer.removeAllViews();
|
||||
val newFields = getFieldsFromObject(context, obj);
|
||||
for(field in newFields) {
|
||||
if(field !is View)
|
||||
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 ->
|
||||
onChanged.emit(a1, a2);
|
||||
};
|
||||
|
@ -76,7 +113,7 @@ class FieldForm : LinearLayout {
|
|||
_fields = newFields;
|
||||
}
|
||||
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);
|
||||
if (newFields.isEmpty()) {
|
||||
return;
|
||||
|
@ -87,7 +124,7 @@ class FieldForm : LinearLayout {
|
|||
if(field.second !is View)
|
||||
throw java.lang.IllegalStateException("Only views can be IFields");
|
||||
finalizePluginSettingField(field.first, field.second, newFields);
|
||||
_root.addView(field as View);
|
||||
_fieldsContainer.addView(field as View);
|
||||
}
|
||||
_fields = newFields.map { it.second };
|
||||
} else {
|
||||
|
@ -96,7 +133,7 @@ class FieldForm : LinearLayout {
|
|||
}
|
||||
val group = GroupField(context, groupTitle, groupDescription)
|
||||
.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>>) {
|
||||
|
@ -210,7 +247,6 @@ class FieldForm : LinearLayout {
|
|||
.asStream()
|
||||
.filter { it.hasAnnotation<FormField>() && it.javaField != null }
|
||||
.map { Pair<KProperty<*>, FormField>(it, it.findAnnotation()!!) }
|
||||
.toList()
|
||||
|
||||
//TODO: Rewrite fields to properties so no map is required
|
||||
val propertyMap = mutableMapOf<Field, KProperty<*>>();
|
||||
|
@ -252,7 +288,6 @@ class FieldForm : LinearLayout {
|
|||
.asStream()
|
||||
.filter { it.hasAnnotation<FormField>() && it.javaField == null && it.getter.javaMethod != null}
|
||||
.map { Pair<Method, FormField>(it.getter.javaMethod!!, it.findAnnotation()!!) }
|
||||
.toList();
|
||||
|
||||
for(prop in objProps) {
|
||||
prop.first.isAccessible = true;
|
||||
|
@ -270,7 +305,6 @@ class FieldForm : LinearLayout {
|
|||
.asStream()
|
||||
.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)) }
|
||||
.toList();
|
||||
|
||||
for(meth in objMethods) {
|
||||
meth.first.isAccessible = true;
|
||||
|
|
|
@ -39,6 +39,8 @@ class GroupField : LinearLayout, IField {
|
|||
|
||||
override val value: Any? = null;
|
||||
|
||||
override val searchContent: String? get() = "${_title.text} ${_subtitle.text}";
|
||||
|
||||
constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs) {
|
||||
inflate(context, R.layout.field_group, this);
|
||||
_title = findViewById(R.id.field_group_title);
|
||||
|
@ -142,6 +144,9 @@ class GroupField : LinearLayout, IField {
|
|||
field.setField();
|
||||
}
|
||||
}
|
||||
fun getFields(): List<IField> {
|
||||
return _fields;
|
||||
}
|
||||
|
||||
override fun setValue(value: Any) {}
|
||||
}
|
|
@ -34,6 +34,9 @@ class ReadOnlyTextField : TableRow, IField {
|
|||
|
||||
override val value: Any? = null;
|
||||
|
||||
override val searchContent: String?
|
||||
get() = "${_title.text}";
|
||||
|
||||
constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs){
|
||||
inflate(context, R.layout.field_readonly_text, this);
|
||||
_title = findViewById(R.id.field_title);
|
||||
|
|
|
@ -38,6 +38,9 @@ class ToggleField : TableRow, IField {
|
|||
|
||||
override val value: Any get() = _lastValue;
|
||||
|
||||
override val searchContent: String?
|
||||
get() = "${_title.text} ${_description.text}";
|
||||
|
||||
constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs){
|
||||
inflate(context, R.layout.field_toggle, this);
|
||||
_toggle = findViewById(R.id.field_toggle);
|
||||
|
|
|
@ -308,13 +308,21 @@ class LiveChatOverlay : LinearLayout {
|
|||
}
|
||||
};
|
||||
}
|
||||
private var _dedupHackfix = "";
|
||||
fun addDonation(donation: LiveEventDonation) {
|
||||
val uniqueIdentifier = "${donation.name}${donation.amount}${donation.message}";
|
||||
if(donation.hasExpired()) {
|
||||
Logger.i(TAG, "Donation that is already expired: [${donation.amount}]" + donation.name + ":" + donation.message + " EXPIRE: ${donation.expire}");
|
||||
return;
|
||||
}
|
||||
else if(_dedupHackfix == uniqueIdentifier) {
|
||||
Logger.i(TAG, "Donation duplicate found, ignoring");
|
||||
return;
|
||||
}
|
||||
else
|
||||
Logger.i(TAG, "Donation Added: [${donation.amount}]" + donation.name + ":" + donation.message + " EXPIRE: ${donation.expire}");
|
||||
_dedupHackfix = uniqueIdentifier;
|
||||
|
||||
val view = LiveChatDonationPill(context, donation);
|
||||
view.setOnClickListener {
|
||||
showDonation(donation);
|
||||
|
|
7
app/src/main/res/drawable/background_donation.xml
Normal file
7
app/src/main/res/drawable/background_donation.xml
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#2A2A2A" />
|
||||
<corners android:radius="500dp" />
|
||||
<size android:height="20dp" />
|
||||
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
|
||||
</shape>
|
|
@ -2,8 +2,44 @@
|
|||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical"
|
||||
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>
|
|
@ -9,7 +9,7 @@
|
|||
android:paddingStart="7dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:layout_marginEnd="5dp"
|
||||
android:background="@drawable/background_pill"
|
||||
android:background="@drawable/background_donation"
|
||||
android:orientation="vertical"
|
||||
android:id="@+id/root">
|
||||
|
||||
|
@ -24,7 +24,7 @@
|
|||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:layout_marginRight="5dp"
|
||||
android:layout_marginLeft="5dp"
|
||||
android:layout_marginLeft="0dp"
|
||||
android:scaleType="fitCenter"
|
||||
app:srcCompat="@drawable/placeholder_profile" />
|
||||
|
||||
|
@ -32,7 +32,7 @@
|
|||
android:id="@+id/donation_amount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginLeft="5dp"
|
||||
android:layout_marginLeft="3dp"
|
||||
app:layout_constraintLeft_toRightOf="@id/donation_author_image"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
android:textColor="@color/white"
|
||||
|
|
|
@ -32,6 +32,20 @@
|
|||
android:scaleType="centerCrop"
|
||||
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
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
|
|
@ -117,6 +117,20 @@
|
|||
android:layout_gravity="end"
|
||||
android:layout_marginStart="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>
|
||||
</FrameLayout>
|
||||
|
||||
|
|
|
@ -125,7 +125,13 @@
|
|||
android:layout_height="match_parent"
|
||||
android:background="#BB000000"
|
||||
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
|
||||
android:id="@+id/container_unavailable"
|
||||
|
|
|
@ -46,10 +46,9 @@
|
|||
app:layout_constraintLeft_toRightOf="@id/ic_viewers"
|
||||
tools:text="1536 viewers"/>
|
||||
|
||||
<ScrollView
|
||||
<HorizontalScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="35dp"
|
||||
android:orientation="horizontal"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent">
|
||||
|
@ -61,7 +60,7 @@
|
|||
android:layout_height="match_parent">
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</HorizontalScrollView>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/button_close"
|
||||
|
|
|
@ -8,7 +8,10 @@
|
|||
<string name="lorem_ipsum" translatable="false">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</string>
|
||||
<string name="add_to_queue">Add to queue</string>
|
||||
<string name="general">General</string>
|
||||
<string name="channel">Channel</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="more">More</string>
|
||||
<string name="playlists">Playlists</string>
|
||||
|
@ -26,6 +29,7 @@
|
|||
<string name="defaults">Defaults</string>
|
||||
<string name="home_screen">Home Screen</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="close">Close</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="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="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="auto_update">Auto Update</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_in_app_browser_cookies">Clears in-app browser cookies</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_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>
|
||||
|
@ -350,8 +359,11 @@
|
|||
<string name="player">Player</string>
|
||||
<string name="plugins">Plugins</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_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_description">Default quality while previewing a video in a feed</string>
|
||||
<string name="primary_language">Primary Language</string>
|
||||
<string name="default_comment_section">Default Comment Section</string>
|
||||
<string name="reinstall_embedded_plugins">Reinstall Embedded Plugins</string>
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit d0b7a2c1b4939c27b4ec04ee52b5a16380c27afb
|
||||
Subproject commit 396dd16987fba87e6f455a900426a5d7f22cbde3
|
|
@ -1 +1 @@
|
|||
Subproject commit a8bc4ff91301ef70f8fbabf181e78bbed828156d
|
||||
Subproject commit 6ea204605d4a27867702d7b024237506904d53c7
|
|
@ -1 +1 @@
|
|||
Subproject commit 9e26b7032e64ed03315a8e75d2174cb4253030d1
|
||||
Subproject commit 55aef15f4b4ad1359266bb77908b48726d32594b
|
|
@ -1 +1 @@
|
|||
Subproject commit 6732a56cd60522f995478399173dd020d8ffc828
|
||||
Subproject commit 8d978dd7bd749f837f13322742329c8f769a1a57
|
|
@ -1 +1 @@
|
|||
Subproject commit d0b7a2c1b4939c27b4ec04ee52b5a16380c27afb
|
||||
Subproject commit 396dd16987fba87e6f455a900426a5d7f22cbde3
|
|
@ -1 +1 @@
|
|||
Subproject commit a8bc4ff91301ef70f8fbabf181e78bbed828156d
|
||||
Subproject commit 6ea204605d4a27867702d7b024237506904d53c7
|
|
@ -1 +1 @@
|
|||
Subproject commit 339b44e9f00521ab4cfe755a343fd9e6e5338d04
|
||||
Subproject commit 55aef15f4b4ad1359266bb77908b48726d32594b
|
|
@ -1 +1 @@
|
|||
Subproject commit 6732a56cd60522f995478399173dd020d8ffc828
|
||||
Subproject commit 8d978dd7bd749f837f13322742329c8f769a1a57
|
|
@ -1 +1 @@
|
|||
Subproject commit 7de4d54c25f087a2bc76a2704e575a6f9441987b
|
||||
Subproject commit 839e4c4a4f5ed6cb6f68047f88b26c5831e6e703
|
Loading…
Add table
Reference in a new issue