Compare commits

...

30 Commits

Author SHA1 Message Date
Kelvin f3fa208680 Kick subs fix, dedup fix 2023-11-22 18:04:29 +01:00
Kelvin 502602e27a Reordering progress bar settings 2023-11-22 16:50:54 +01:00
Kelvin 5054b093a4 Stable refs 2023-11-22 16:15:05 +01:00
Kelvin 0ffaec6bc2 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-22 16:05:39 +01:00
Kelvin ef8ea9eecf Fix whitelist checking for dev-portal 2023-11-22 16:05:27 +01:00
Koen b09d22e479 Added historical time bars to videos. 2023-11-22 14:49:34 +01:00
Koen 01787b6229 Added backfill exception printing to announcements. 2023-11-22 12:46:39 +01:00
Koen 4c022698d3 Quality selection overlay now properly closes when pressing the back button. 2023-11-22 11:32:51 +01:00
Koen bfdcab0e84 Properly handle V1 encrypted secrets in the upgrade process from V0 to V1. 2023-11-22 11:21:18 +01:00
Koen aaea5cc963 Only close the app on closeSegment if there is no video playing. 2023-11-22 10:38:04 +01:00
Koen 23d9c33406 Added support for v6 Odysee URLs. 2023-11-22 10:27:35 +01:00
Koen fad1b216df Further extended HLS spec that is implemented. 2023-11-22 09:32:52 +01:00
Kelvin e221b508d3 Improved notifications, experimental scheduled notifications 2023-11-21 23:31:26 +01:00
Koen dfafac7d99 Merge branch 'hls-live-stream-proxy' into 'master'
Finished implementation of HLS proxying.

See merge request videostreaming/grayjay!5
2023-11-21 15:12:09 +00:00
Koen 2246f8cee2 Finished implementation of HLS proxying. 2023-11-21 15:12:09 +00:00
Koen 8661ff88c0 Another iteration on the HttpContext fix. 2023-11-20 12:14:03 +01:00
Koen 0bba7fa373 Keep screen on fixes. 2023-11-17 16:44:16 +01:00
Kelvin 0c1822b118 Locked content support 2023-11-17 00:34:21 +01:00
Kelvin 6df8f84421 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-16 17:13:50 +01:00
Kelvin 7fa80ec048 Fix communication issues devportal 2023-11-16 17:13:23 +01:00
Koen b3f9b81984 Do not allow empty polycentric comments. 2023-11-16 15:51:34 +01:00
Kelvin 1393c489c1 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-15 20:02:36 +01:00
Kelvin 640c2cbed0 Chapter accuracy now sub-second 2023-11-15 20:02:28 +01:00
Koen e55509f549 Merge branch 'casting-fixes' into 'master'
Content length is now set correctly for HttpConstantHandler.

See merge request videostreaming/grayjay!4
2023-11-15 16:18:38 +00:00
Koen 27c7fb0c12 Content length is now set correctly for HttpConstantHandler. 2023-11-15 16:18:38 +00:00
Koen 88f3815585 When clicking on a video it is added to queue instead of replacing queue. 2023-11-15 11:49:22 +01:00
Koen 2e9405cfdb Exit full screen swipe is now a down gesture. 2023-11-15 11:40:09 +01:00
Koen 9c1b543ed6 Added 'Add to new playlist' button in options menu. 2023-11-14 14:58:24 +01:00
Koen d34cb0f9c1 Added support for long-press gesture to open options menu. 2023-11-14 14:42:41 +01:00
Koen 116dc90d21 Changed the way the changelog is written. 2023-11-14 14:18:17 +01:00
89 changed files with 2877 additions and 491 deletions
+3
View File
@@ -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"
+10
View File
@@ -211,6 +211,16 @@ class PlatformNestedMediaContent extends PlatformContent {
this.contentThumbnails = obj.contentThumbnails ?? new Thumbnails();
}
}
class PlatformLockedContent extends PlatformContent {
constructor(obj) {
super(obj, 70);
obj = obj ?? {};
this.contentName = obj.contentName;
this.contentThumbnails = obj.contentThumbnails ?? new Thumbnails();
this.unlockUrl = obj.unlockUrl ?? "";
this.lockDescription = obj.lockDescription;
}
}
class PlatformVideo extends PlatformContent {
constructor(obj) {
super(obj, 1);
@@ -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,29 @@ 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 String?.toURIRobust(): URI? {
if (this == null) {
return null
}
try {
return URI(this)
} catch (e: URISyntaxException) {
val parts = this.split("\\?".toRegex(), 2)
if (parts.size < 2) {
return null
}
val beforeQuery = parts[0]
val query = parts[1]
val encodedQuery = URLEncoder.encode(query, "UTF-8")
val rebuiltUrl = "$beforeQuery?$encodedQuery"
return URI(rebuiltUrl)
}
}
@@ -109,11 +109,29 @@ inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextN
else
return this.expectOrThrow<V8ValueLong>(config, contextName).value.toLong() as T
};
Float::class -> {
if(this is V8ValueDouble)
return this.value.toFloat() as T;
else if(this is V8ValueInteger)
return this.value.toFloat() as T;
else if(this is V8ValueLong)
return this.value.toFloat() as T;
else
return this.expectOrThrow<V8ValueDouble>(config, contextName).value.toDouble() as T
};
Double::class -> {
if(this is V8ValueDouble)
return this.value.toDouble() as T;
else if(this is V8ValueInteger)
return this.value.toDouble() as T;
else if(this is V8ValueLong)
return this.value.toDouble() as T;
else
return this.expectOrThrow<V8ValueDouble>(config, contextName).value.toDouble() as T
};
V8ValueObject::class -> this.expectOrThrow<V8ValueObject>(config, contextName) as T
V8ValueArray::class -> this.expectOrThrow<V8ValueArray>(config, contextName) as T;
Boolean::class -> this.expectOrThrow<V8ValueBoolean>(config, contextName).value as T;
Float::class -> this.expectOrThrow<V8ValueDouble>(config, contextName).value.toFloat() as T;
Double::class -> this.expectOrThrow<V8ValueDouble>(config, contextName).value as T;
HashMap::class -> this.expectOrThrow<V8ValueObject>(config, contextName).let { V8ObjectToHashMap(it) } as T;
Map::class -> this.expectOrThrow<V8ValueObject>(config, contextName).let { V8ObjectToHashMap(it) } as T;
List::class -> this.expectOrThrow<V8ValueArray>(config, contextName).let { V8ArrayToStringList(it) } as T;
@@ -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 {
@@ -337,16 +356,39 @@ class Settings : FragmentedStorageFileJson() {
return false;
}
@FormField(R.string.live_chat_webview, FieldForm.TOGGLE, R.string.use_the_live_chat_web_window_when_available_over_native_implementation, 8)
@FormField(R.string.chapter_update_fps_title, FieldForm.DROPDOWN, R.string.chapter_update_fps_description, 8)
@DropdownFieldOptionsId(R.array.chapter_fps)
var chapterUpdateFPS: Int = 0;
fun getChapterUpdateFrames(): Int {
return when(chapterUpdateFPS) {
0 -> 24
1 -> 30
2 -> 60
3 -> 120
else -> 1
};
}
@FormField(R.string.live_chat_webview, FieldForm.TOGGLE, R.string.use_the_live_chat_web_window_when_available_over_native_implementation, 9)
var useLiveChatWindow: Boolean = true;
@FormField(R.string.background_switch_audio, FieldForm.TOGGLE, R.string.background_switch_audio_description, 8)
@FormField(R.string.background_switch_audio, FieldForm.TOGGLE, R.string.background_switch_audio_description, 10)
var backgroundSwitchToAudio: Boolean = true;
}
@FormField(R.string.downloads, "group", R.string.configure_downloading_of_videos, 5)
@FormField(R.string.comments, "group", R.string.comments_description, 6)
var comments = CommentSettings();
@Serializable
class CommentSettings {
@FormField(R.string.default_comment_section, FieldForm.DROPDOWN, -1, 0)
@DropdownFieldOptionsId(R.array.comment_sections)
var defaultCommentSection: Int = 0;
}
@FormField(R.string.downloads, "group", R.string.configure_downloading_of_videos, 7)
var downloads = Downloads();
@Serializable
class Downloads {
@@ -386,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 {
@@ -395,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 {
@@ -403,6 +445,9 @@ class Settings : FragmentedStorageFileJson() {
@Serializable(with = FlexibleBooleanSerializer::class)
var enabled: Boolean = true;
@FormField(R.string.keep_screen_on, FieldForm.TOGGLE, R.string.keep_screen_on_while_casting, 1)
@Serializable(with = FlexibleBooleanSerializer::class)
var keepScreenOn: Boolean = true;
/*TODO: Should we have a different casting quality?
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
@@ -420,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 {
@@ -445,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 {
@@ -458,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
@@ -495,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 {
@@ -529,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 {
@@ -611,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 {
@@ -664,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 {
@@ -681,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 {
@@ -690,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
@@ -5,9 +5,12 @@ import android.graphics.Color
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
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
@@ -52,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;
@@ -60,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? {
@@ -369,6 +386,33 @@ class UISlideOverlays {
return overlay;
}
fun showCreatePlaylistOverlay(container: ViewGroup, onCreate: (String) -> Unit): SlideUpMenuOverlay {
val nameInput = SlideUpMenuTextInput(container.context, container.context.getString(R.string.name));
val addPlaylistOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.create_new_playlist), container.context.getString(R.string.ok), false, nameInput);
addPlaylistOverlay.onOK.subscribe {
val text = nameInput.text;
if (text.isBlank()) {
return@subscribe;
}
addPlaylistOverlay.hide();
nameInput.deactivate();
nameInput.clear();
onCreate(text)
};
addPlaylistOverlay.onCancel.subscribe {
nameInput.deactivate();
nameInput.clear();
};
addPlaylistOverlay.show();
nameInput.activate();
return addPlaylistOverlay
}
fun showVideoOptionsOverlay(video: IPlatformVideo, container: ViewGroup, vararg actions: SlideUpMenuItem): SlideUpMenuOverlay {
val items = arrayListOf<View>();
val lastUpdated = StatePlaylists.instance.getLastUpdatedPlaylist();
@@ -407,6 +451,13 @@ class UISlideOverlays {
));
val playlistItems = arrayListOf<SlideUpMenuItem>();
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, container.context.getString(R.string.new_playlist), container.context.getString(R.string.add_to_new_playlist), "add_to_new_playlist", {
showCreatePlaylistOverlay(container) {
val playlist = Playlist(it, arrayListOf(SerializedPlatformVideo.fromVideo(video)));
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
};
}, false))
for (playlist in allPlaylists) {
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, "${container.context.getString(R.string.add_to)} " + playlist.name + "", "${playlist.videos.size} " + container.context.getString(R.string.videos), "",
{
@@ -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));
@@ -8,16 +8,20 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.BufferedReader
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.io.StringWriter
import java.net.SocketTimeoutException
class HttpContext : AutoCloseable {
private val _stream: BufferedReader;
private val _inputStream: InputStream;
private var _responseStream: OutputStream? = null;
var id: String? = null;
var head: String = "";
var headers: HttpHeaders = HttpHeaders();
@@ -39,76 +43,130 @@ class HttpContext : AutoCloseable {
private val _responseHeaders: HttpHeaders = HttpHeaders();
constructor(stream: BufferedReader, responseStream: OutputStream? = null, requestId: String? = null, timeout: Int? = null) {
_stream = stream;
constructor(inputStream: InputStream, responseStream: OutputStream? = null, requestId: String? = null, timeout: Int? = null) {
_inputStream = inputStream;
_responseStream = responseStream;
this.id = requestId;
try {
head = stream.readLine() ?: throw EmptyRequestException("No head found");
}
catch(ex: SocketTimeoutException) {
if((timeout ?: 0) > 0)
throw KeepAliveTimeoutException("Keep-Alive timedout", ex);
throw ex;
}
val methodEndIndex = head.indexOf(' ');
val urlEndIndex = head.indexOf(' ', methodEndIndex + 1);
if (methodEndIndex == -1 || urlEndIndex == -1) {
Logger.w(TAG, "Skipped request, wrong format.");
throw IllegalStateException("Invalid request");
}
method = head.substring(0, methodEndIndex);
path = head.substring(methodEndIndex + 1, urlEndIndex);
if (path.contains("?")) {
val queryPartIndex = path.indexOf("?");
val queryParts = path.substring(queryPartIndex + 1).split("&");
path = path.substring(0, queryPartIndex);
for(queryPart in queryParts) {
val eqIndex = queryPart.indexOf("=");
if(eqIndex > 0)
query.put(queryPart.substring(0, eqIndex), queryPart.substring(eqIndex + 1));
else
query.put(queryPart, "");
val headerBytes = readHeaderBytes()
ByteArrayInputStream(headerBytes).use {
val reader = it.bufferedReader(Charsets.UTF_8)
try {
head = reader.readLine() ?: throw EmptyRequestException("No head found");
}
catch(ex: SocketTimeoutException) {
if((timeout ?: 0) > 0)
throw KeepAliveTimeoutException("Keep-Alive timedout", ex);
throw ex;
}
}
while (true) {
val line = stream.readLine();
val headerEndIndex = line.indexOf(":");
if (headerEndIndex == -1)
break;
val methodEndIndex = head.indexOf(' ');
val urlEndIndex = head.indexOf(' ', methodEndIndex + 1);
if (methodEndIndex == -1 || urlEndIndex == -1) {
Logger.w(TAG, "Skipped request, wrong format.");
throw IllegalStateException("Invalid request");
}
val headerKey = line.substring(0, headerEndIndex).lowercase()
val headerValue = line.substring(headerEndIndex + 1).trim();
headers[headerKey] = headerValue;
method = head.substring(0, methodEndIndex);
path = head.substring(methodEndIndex + 1, urlEndIndex);
when(headerKey) {
"content-length" -> contentLength = headerValue.toLong();
"content-type" -> contentType = headerValue;
"connection" -> keepAlive = headerValue.lowercase() == "keep-alive";
"keep-alive" -> {
val keepAliveParams = headerValue.split(",");
for(keepAliveParam in keepAliveParams) {
val eqIndex = keepAliveParam.indexOf("=");
if(eqIndex > 0){
when(keepAliveParam.substring(0, eqIndex)) {
"timeout" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt();
"max" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt();
if (path.contains("?")) {
val queryPartIndex = path.indexOf("?");
val queryParts = path.substring(queryPartIndex + 1).split("&");
path = path.substring(0, queryPartIndex);
for(queryPart in queryParts) {
val eqIndex = queryPart.indexOf("=");
if(eqIndex > 0)
query.put(queryPart.substring(0, eqIndex), queryPart.substring(eqIndex + 1));
else
query.put(queryPart, "");
}
}
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;
"connection" -> keepAlive = headerValue.lowercase() == "keep-alive";
"keep-alive" -> {
val keepAliveParams = headerValue.split(",");
for(keepAliveParam in keepAliveParams) {
val eqIndex = keepAliveParam.indexOf("=");
if(eqIndex > 0){
when(keepAliveParam.substring(0, eqIndex)) {
"timeout" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt();
"max" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt();
}
}
}
}
}
if(line.isNullOrEmpty())
break;
}
if(line.isNullOrEmpty())
break;
}
}
private fun readHeaderBytes(): ByteArray {
val headerBytes = ByteArrayOutputStream()
var crlfCount = 0
while (crlfCount < 4) {
val b = _inputStream.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 readContentBytes(buffer: ByteArray, length: Int): Int {
val remainingBytes = (contentLength - _totalRead).coerceAtMost(length.toLong()).toInt()
val read = _inputStream.read(buffer, 0, remainingBytes);
if (read > 0) {
_totalRead += read
}
return read;
}
fun readContentString(): String {
val byteArrayOutputStream = ByteArrayOutputStream()
val buffer = ByteArray(4096)
var read: Int
while (true) {
read = readContentBytes(buffer, buffer.size)
if (read <= 0) break
byteArrayOutputStream.write(buffer, 0, read)
}
return byteArrayOutputStream.toString(Charsets.UTF_8.name())
}
inline fun <reified T> readContentJson() : T {
return Serializer.json.decodeFromString(readContentString());
}
fun skipBody() {
if (contentLength > 0)
_inputStream.skip(contentLength - _totalRead)
}
fun getHttpHeaderString(): String {
val writer = StringWriter();
writer.write(head + "\r\n");
@@ -161,8 +219,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") {
@@ -172,40 +229,9 @@ class HttpContext : AutoCloseable {
statusCode = status;
}
fun readContentBytes(buffer: CharArray, length: Int) : Int {
val reading = Math.min(length, (contentLength - _totalRead).toInt());
val read = _stream.read(buffer, 0, reading);
_totalRead += read;
//TODO: Fix this properly
if(contentLength - _totalRead < 400 && read < length) {
_totalRead = contentLength;
}
return read;
}
fun readContentString() : String{
val writer = StringWriter();
var read = 0;
val buffer = CharArray(4096);
do {
read = readContentBytes(buffer, buffer.size);
writer.write(buffer, 0, read);
} while(read > 0);// && _stream.ready());
//if(!_stream.ready())
// _totalRead = contentLength;
return writer.toString();
}
inline fun <reified T> readContentJson() : T {
return Serializer.json.decodeFromString(readContentString());
}
fun skipBody() {
if(contentLength > 0)
_stream.skip(contentLength - _totalRead);
}
override fun close() {
if(!keepAlive) {
_stream?.close();
_inputStream.close();
_responseStream?.close();
}
}
@@ -5,8 +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 java.io.BufferedReader
import java.io.InputStreamReader
import java.io.BufferedInputStream
import java.io.OutputStream
import java.lang.reflect.Field
import java.lang.reflect.Method
@@ -18,6 +17,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();
@@ -29,7 +29,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
@@ -76,12 +77,12 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
private fun handleClientRequest(socket: Socket) {
_workerPool?.submit {
val requestReader = BufferedReader(InputStreamReader(socket.getInputStream()))
val requestStream = BufferedInputStream(socket.getInputStream());
val responseStream = socket.getOutputStream();
val requestId = UUID.randomUUID().toString().substring(0, 5);
try {
keepAliveLoop(requestReader, responseStream, requestId) { req ->
keepAliveLoop(requestStream, responseStream, requestId) { req ->
req.use { httpContext ->
if(!httpContext.path.startsWith("/plugin/"))
Logger.i(TAG, "[${req.id}] ${httpContext.method}: ${httpContext.path}")
@@ -107,7 +108,7 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
Logger.e(TAG, "Failed to handle client request.", e);
}
finally {
requestReader.close();
requestStream.close();
responseStream.close();
}
};
@@ -115,32 +116,61 @@ 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 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) {
@@ -188,7 +218,7 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
}
}
private fun keepAliveLoop(requestReader: BufferedReader, responseStream: OutputStream, requestId: String, handler: (HttpContext)->Unit) {
private fun keepAliveLoop(requestReader: BufferedInputStream, responseStream: OutputStream, requestId: String, handler: (HttpContext)->Unit) {
val stopCount = _stopCount;
var keepAlive = false;
var requestsMax = 0;
@@ -200,7 +230,7 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
handler(req);
requestsTotal++;
if(req.keepAlive){// && requestReader.ready()) {
if(req.keepAlive) {
keepAlive = true;
if(req.keepAliveMax > 0)
requestsMax = req.keepAliveMax;
@@ -7,7 +7,6 @@ class HttpConstantHandler(method: String, path: String, val content: String, val
val headers = this.headers.clone();
if(contentType != null)
headers["Content-Type"] = contentType;
headers["Content-Length"] = content.length.toString();
httpContext.respondCode(200, headers, content);
}
@@ -1,14 +1,16 @@
package com.futo.platformplayer.api.http.server.handlers
import com.futo.platformplayer.api.http.server.HttpContext
import com.futo.platformplayer.api.http.server.HttpHeaders
import com.futo.platformplayer.logging.Logger
import java.io.ByteArrayOutputStream
import java.io.File
import java.nio.file.Files
import java.text.SimpleDateFormat
import java.util.*
import java.util.zip.GZIPOutputStream
class HttpFileHandler(method: String, path: String, private val contentType: String, private val filePath: String, private val closeAfterRequest: Boolean = false): HttpHandler(method, path) {
class HttpFileHandler(method: String, path: String, private val contentType: String, private val filePath: String): HttpHandler(method, path) {
override fun handle(httpContext: HttpContext) {
val requestHeaders = httpContext.headers;
val responseHeaders = this.headers.clone();
@@ -30,19 +32,13 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
responseHeaders["Content-Disposition"] = "attachment; filename=\"${file.name.replace("\"", "\\\"")}\""
val acceptEncoding = requestHeaders["Accept-Encoding"]
val shouldGzip = acceptEncoding != null && acceptEncoding.split(',').any { it.trim().equals("gzip", ignoreCase = true) || it == "*" }
if (shouldGzip) {
responseHeaders["Content-Encoding"] = "gzip"
}
val range = requestHeaders["Range"]
var start: Long
val start: Long
val end: Long
if (range != null && range.startsWith("bytes=")) {
val parts = range.substring(6).split("-")
start = parts[0].toLong()
end = parts.getOrNull(1)?.toLong() ?: (file.length() - 1)
end = parts.getOrNull(1)?.toLongOrNull() ?: (file.length() - 1)
responseHeaders["Content-Range"] = "bytes $start-$end/${file.length()}"
} else {
start = 0
@@ -51,18 +47,19 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
var totalBytesSent = 0
val contentLength = end - start + 1
Logger.i(TAG, "Sending $contentLength bytes (start: $start, end: $end, shouldGzip: $shouldGzip)")
responseHeaders["Content-Length"] = contentLength.toString()
Logger.i(TAG, "Sending $contentLength bytes (start: $start, end: $end)")
file.inputStream().use { inputStream ->
httpContext.respond(if (range == null) 200 else 206, responseHeaders) { responseStream ->
httpContext.respond(if (range != null) 206 else 200, responseHeaders) { responseStream ->
try {
val buffer = ByteArray(8192)
inputStream.skip(start)
var current = start
val outputStream = if (shouldGzip) GZIPOutputStream(responseStream) else responseStream
val outputStream = responseStream
while (true) {
val expectedBytesRead = (end - start + 1).coerceAtMost(buffer.size.toLong());
val expectedBytesRead = (end - current + 1).coerceAtMost(buffer.size.toLong());
val bytesRead = inputStream.read(buffer);
if (bytesRead < 0) {
Logger.i(TAG, "End of file reached")
@@ -73,27 +70,21 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
outputStream.write(buffer, 0, bytesToSend)
totalBytesSent += bytesToSend
Logger.v(TAG, "Sent bytes $start-${start + bytesToSend}, totalBytesSent=$totalBytesSent")
Logger.v(TAG, "Sent bytes $current-${current + bytesToSend}, totalBytesSent=$totalBytesSent")
start += bytesToSend.toLong()
if (start >= end) {
current += bytesToSend.toLong()
if (current >= end) {
Logger.i(TAG, "Expected amount of bytes sent")
break
}
}
Logger.i(TAG, "Finished sending file (segment)")
if (shouldGzip) (outputStream as GZIPOutputStream).finish()
outputStream.flush()
} catch (e: Exception) {
httpContext.respondCode(500, headers)
}
}
if (closeAfterRequest) {
httpContext.keepAlive = false;
}
}
}
@@ -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 {
@@ -1,11 +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;
@@ -17,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;
@@ -34,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);
@@ -44,8 +60,8 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
else -> _client.requestMethod(useMethod, targetUrl, proxyHeaders);
};
//Logger.i(TAG, "Proxied Response [${resp.code}]");
val headersFiltered = HttpHeaders(resp.getHeadersFlat().filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) });
Logger.i(TAG, "Proxied Response [${resp.code}]");
val headersFiltered = HttpHeaders(resp.getHeadersFlat().filter { !_ignoreResponseHeaders.contains(it.key.lowercase()) });
for(newHeader in headers)
headersFiltered.put(newHeader.key, newHeader.value);
@@ -65,6 +81,129 @@ 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}: ${targetUrl}");
Logger.i(TAG, "handleWithTcp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
val requestBuilder = StringBuilder()
requestBuilder.append("$useMethod $targetUrl 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)
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 != -1) {
Logger.i(TAG, "handleWithTcp transferFixedLengthContent $contentLength");
transferFixedLengthContent(inputStream, responseStream, contentLength)
} else {
Logger.i(TAG, "handleWithTcp transferUntilEndOfStream");
transferUntilEndOfStream(inputStream, responseStream)
}
}
}
}
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)
Logger.i(TAG, "handleWithTcp handleChunkedTransfer chunk size $size")
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;
@@ -92,4 +231,8 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
_ignoreRequestHeaders.add("referer");
return this;
}
companion object {
private const val TAG = "HttpProxyHandler"
}
}
@@ -6,8 +6,8 @@ import com.futo.platformplayer.api.media.models.contents.ContentType
interface IChapter {
val name: String;
val type: ChapterType;
val timeStart: Int;
val timeEnd: Int;
val timeStart: Double;
val timeEnd: Double;
}
enum class ChapterType(val value: Int) {
@@ -13,6 +13,7 @@ enum class ContentType(val value: Int) {
NESTED_VIDEO(11),
LOCKED(70),
PLACEHOLDER(90),
DEFERRED(91);
@@ -0,0 +1,13 @@
package com.futo.platformplayer.api.media.models.locked
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
interface IPlatformLockedContent: IPlatformContent {
val lockContentType: ContentType;
val lockDescription: String?;
val unlockUrl: String?;
val contentName: String?;
val contentThumbnails: Thumbnails;
}
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.video
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.locked.IPlatformLockedContent
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.serializers.PlatformContentSerializer
@@ -18,6 +19,7 @@ interface SerializedPlatformContent: IPlatformContent {
ContentType.MEDIA -> SerializedPlatformVideo.fromVideo(content as IPlatformVideo);
ContentType.NESTED_VIDEO -> SerializedPlatformNestedContent.fromNested(content as IPlatformNestedContent);
ContentType.POST -> SerializedPlatformPost.fromPost(content as IPlatformPost);
ContentType.LOCKED -> SerializedPlatformLockedContent.fromLocked(content as IPlatformLockedContent);
else -> throw NotImplementedError("Content type ${content.contentType} not implemented");
};
}
@@ -0,0 +1,62 @@
package com.futo.platformplayer.api.media.models.video
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.states.StatePlatform
import com.futo.polycentric.core.combineHashCodes
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.OffsetDateTime
@kotlinx.serialization.Serializable
open class SerializedPlatformLockedContent(
override val id: PlatformID,
override val name: String,
override val author: PlatformAuthorLink,
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override val datetime: OffsetDateTime?,
override val url: String,
override val shareUrl: String,
override val lockContentType: ContentType,
override val contentName: String?,
override val lockDescription: String? = null,
override val unlockUrl: String? = null,
override val contentThumbnails: Thumbnails
) : IPlatformLockedContent, SerializedPlatformContent {
final override val contentType: ContentType get() = ContentType.LOCKED;
override fun toJson() : String {
return Json.encodeToString(this);
}
override fun fromJson(str : String) : SerializedPlatformLockedContent {
return Serializer.json.decodeFromString<SerializedPlatformLockedContent>(str);
}
override fun fromJsonArray(str : String) : Array<SerializedPlatformContent> {
return Serializer.json.decodeFromString<Array<SerializedPlatformContent>>(str);
}
companion object {
fun fromLocked(content: IPlatformLockedContent) : SerializedPlatformLockedContent {
return SerializedPlatformLockedContent(
content.id,
content.name,
content.author,
content.datetime,
content.url,
content.shareUrl,
content.lockContentType,
content.contentName,
content.lockDescription,
content.unlockUrl,
content.contentThumbnails
);
}
}
}
@@ -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;
}
@@ -23,6 +23,7 @@ interface IJSContent: IPlatformContent {
ContentType.POST -> JSPost(config, obj);
ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj);
ContentType.PLAYLIST -> JSPlaylist(config, obj);
ContentType.LOCKED -> JSLockedContent(config, obj);
else -> throw NotImplementedError("Unknown content type ${type}");
}
}
@@ -12,10 +12,10 @@ import com.futo.platformplayer.getOrThrow
class JSChapter : IChapter {
override val name: String;
override val type: ChapterType;
override val timeStart: Int;
override val timeEnd: Int;
override val timeStart: Double;
override val timeEnd: Double;
constructor(name: String, timeStart: Int, timeEnd: Int, type: ChapterType = ChapterType.NORMAL) {
constructor(name: String, timeStart: Double, timeEnd: Double, type: ChapterType = ChapterType.NORMAL) {
this.name = name;
this.timeStart = timeStart;
this.timeEnd = timeEnd;
@@ -29,8 +29,8 @@ class JSChapter : IChapter {
val name = obj.getOrThrow<String>(config,"name", context);
val type = ChapterType.fromInt(obj.getOrDefault<Int>(config, "type", context, ChapterType.NORMAL.value) ?: ChapterType.NORMAL.value);
val timeStart = obj.getOrThrow<Int>(config, "timeStart", context);
val timeEnd = obj.getOrThrow<Int>(config, "timeEnd", context);
val timeStart = obj.getOrThrow<Double>(config, "timeStart", context);
val timeEnd = obj.getOrThrow<Double>(config, "timeEnd", context);
return JSChapter(name, timeStart, timeEnd, type);
}
@@ -0,0 +1,36 @@
package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.states.StatePlatform
//TODO: Refactor into video-only
class JSLockedContent: IPlatformLockedContent, JSContent {
override val contentType: ContentType get() = ContentType.LOCKED;
override val lockContentType: ContentType get() = ContentType.MEDIA;
override val lockDescription: String?;
override val unlockUrl: String?;
override val contentName: String?;
override val contentThumbnails: Thumbnails;
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
val contextName = "PlatformLockedContent";
this.contentName = obj.getOrDefault(config, "contentName", contextName, null);
this.contentThumbnails = obj.getOrDefault<V8ValueObject?>(config, "contentThumbnails", contextName, null)?.let {
return@let Thumbnails.fromV8(config, it);
} ?: Thumbnails();
lockDescription = obj.getOrDefault(config, "lockDescription", contextName, null);
unlockUrl = obj.getOrDefault(config, "unlockUrl", contextName, null);
}
}
@@ -59,8 +59,6 @@ abstract class JSPager<T> : IPager<T> {
}
override fun getResults(): List<T> {
warnIfMainThread("JSPager.getResults");
val previousResults = _lastResults?.let {
if(!_resultChanged)
return@let it;
@@ -70,6 +68,7 @@ abstract class JSPager<T> : IPager<T> {
if(previousResults != null)
return previousResults;
warnIfMainThread("JSPager.getResults");
val items = pager.getOrThrow<V8ValueArray>(config, "results", "JSPager");
val newResults = items.toArray()
.map { convertResult(it as V8ValueObject) }
@@ -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("&", "&amp;")}\",FORMAT=\"$audioFormat\"")
}
// Subtitles
if (subtitleSource != null && subtitleUrl != null) {
val subtitleFormat = subtitleSource.format ?: "text/vtt"
writer.println("#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",LANGUAGE=\"en\",NAME=\"English\",AUTOSELECT=YES,DEFAULT=YES,URI=\"${subtitleUrl.replace("&", "&amp;")}\",FORMAT=\"$subtitleFormat\"")
}
// Video
val videoFormat = vidSource.container.substringAfter("/")
writer.println("#EXT-X-STREAM-INF:BANDWIDTH=100000,CODECS=\"${vidSource.codec}\",RESOLUTION=${vidSource.width}x${vidSource.height}${if (audioSource != null) ",AUDIO=\"audio\"" else ""}${if (subtitleSource != null) ",SUBTITLES=\"subs\"" else ""},FORMAT=\"$videoFormat\"")
writer.println(vidUrl.replace("&", "&amp;"))
}
return hlsBuilder.toString()
}
}
}
@@ -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();
@@ -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;
@@ -354,14 +357,22 @@ 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());
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.getVideoUrl(), 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)
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 && video.isLive) {
castHlsIndirect(video, videoSource.url, resumePosition);
} else {
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 && video.isLive) {
castHlsIndirect(video, audioSource.url, resumePosition);
} else {
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble());
}
} else if (videoSource is LocalVideoSource)
castLocalVideo(video, videoSource, resumePosition);
else if (audioSource is LocalAudioSource)
castLocalAudio(video, audioSource, resumePosition);
@@ -405,7 +416,7 @@ class StateCasting {
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;
@@ -424,7 +435,7 @@ 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;
@@ -444,7 +455,7 @@ class StateCasting {
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}"
@@ -486,7 +497,7 @@ class StateCasting {
}
if (subtitleSource != null) {
_castServer.addHandler(
HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath, true)
HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath)
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
_castServer.addHandler(
@@ -505,7 +516,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}";
@@ -547,11 +558,132 @@ class StateCasting {
return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "");
}
private fun castHlsIndirect(video: IPlatformVideoDetails, sourceUrl: String, resumePosition: Double): List<String> {
_castServer.removeAllHandlers("castHlsIndirectMaster")
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.addHandler(HttpFuntionHandler("GET", hlsPath) { masterContext ->
_castServer.removeAllHandlers("castHlsIndirectVariant")
val headers = masterContext.headers.clone()
headers["Content-Type"] = "application/vnd.apple.mpegurl";
val masterPlaylist = HLS.downloadAndParseMasterPlaylist(_client, sourceUrl)
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.addHandler(HttpFuntionHandler("GET", newPlaylistPath) { vpContext ->
val vpHeaders = vpContext.headers.clone()
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
val variantPlaylist = HLS.downloadAndParseVariantPlaylist(_client, variantPlaylistRef.url)
val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist)
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castHlsIndirectVariant")
newVariantPlaylistRefs.add(HLS.VariantPlaylistReference(
newPlaylistUrl,
variantPlaylistRef.streamInfo
))
}
for (mediaRendition in masterPlaylist.mediaRenditions) {
val playlistId = UUID.randomUUID();
val newPlaylistPath = "/hls-playlist-${playlistId}"
val newPlaylistUrl = url + newPlaylistPath;
if (mediaRendition.uri != null) {
_castServer.addHandler(HttpFuntionHandler("GET", newPlaylistPath) { vpContext ->
val vpHeaders = vpContext.headers.clone()
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
val variantPlaylist = HLS.downloadAndParseVariantPlaylist(_client, mediaRendition.uri)
val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist)
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castHlsIndirectVariant")
}
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("castHlsIndirectMaster")
Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath).");
ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble());
return listOf(hlsUrl);
}
private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, proxySegments: Boolean = true): HLS.VariantPlaylist {
val newSegments = arrayListOf<HLS.Segment>()
if (proxySegments) {
variantPlaylist.segments.forEachIndexed { index, segment ->
val sequenceNumber = variantPlaylist.mediaSequence + 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,
newSegments
)
}
private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long): HLS.Segment {
val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}"
val newSegmentUrl = url + newSegmentPath;
if (_castServer.getHandler("GET", newSegmentPath) == null) {
_castServer.addHandler(
HttpProxyHandler("GET", newSegmentPath, segment.uri, true)
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant")
}
return HLS.Segment(
segment.duration,
newSegmentUrl
)
}
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}";
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
Logger.i(TAG, "DASH url: $url");
val id = UUID.randomUUID();
@@ -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");
@@ -301,7 +306,7 @@ class DeveloperEndpoints(private val context: Context) {
if(method != "isLoggedIn")
Logger.i(TAG, "Remote Call [${objId}].${method}(...)");
val parameters = context.readContentString(); //TODO: Temporary
val parameters = context.readContentString();
val remoteObj = getRemoteObject(objId);
val paras = JsonParser.parseString(parameters);
@@ -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");
@@ -95,6 +95,8 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
_buttonUpdate.visibility = Button.GONE;
setCancelable(false);
setCanceledOnTouchOutside(false);
Logger.i(TAG, "Keep screen on set update")
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
_text.text = context.resources.getText(R.string.downloading_update);
@@ -178,6 +180,7 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
}
} finally {
withContext(Dispatchers.Main) {
Logger.i(TAG, "Keep screen on unset install")
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
}
@@ -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
@@ -85,6 +86,11 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
return@setOnClickListener;
}
if (_editComment.text.isBlank()) {
UIDialogs.toast(context, "Comment should not be blank.");
return@setOnClickListener;
}
val comment = _editComment.text.toString();
val processHandle = StatePolycentric.instance.processHandle!!
val eventPointer = processHandle.post(comment, null, ref)
@@ -92,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);
@@ -134,6 +134,8 @@ class ImportDialog : AlertDialog {
setCancelable(false);
setCanceledOnTouchOutside(false);
Logger.i(TAG, "Keep screen on set import")
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
_updateSpinner.drawable?.assume<Animatable>()?.start();
@@ -201,6 +203,7 @@ class ImportDialog : AlertDialog {
} catch (e: Throwable) {
Logger.e(TAG, "Failed to update import UI.", e)
} finally {
Logger.i(TAG, "Keep screen on unset update")
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
}
@@ -144,6 +144,7 @@ class MigrateDialog : AlertDialog {
setCancelable(false);
setCanceledOnTouchOutside(false);
Logger.i(TAG, "Keep screen on set restore")
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
_updateSpinner.drawable?.assume<Animatable>()?.start();
@@ -214,6 +215,7 @@ class MigrateDialog : AlertDialog {
} catch (e: Throwable) {
Logger.e(TAG, "Failed to update import UI.", e)
} finally {
Logger.i(TAG, "Keep screen on unset restore")
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
}
@@ -4,7 +4,6 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
@@ -12,8 +11,8 @@ 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.UISlideOverlays
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.contents.ContentType
@@ -36,7 +35,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.PreviewContentListAdapter
import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import kotlinx.coroutines.Dispatchers
@@ -56,6 +55,7 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
val onContentClicked = Event2<IPlatformContent, Long>();
val onContentUrlClicked = Event2<String, ContentType>();
val onUrlClicked = Event1<String>();
val onChannelClicked = Event1<PlatformAuthorLink>();
val onAddToClicked = Event1<IPlatformContent>();
val onAddToQueueClicked = Event1<IPlatformContent>();
@@ -75,15 +75,14 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
}
private val _taskLoadVideos = TaskHandler<IPlatformChannel, IPager<IPlatformContent>>({lifecycleScope}, {
return@TaskHandler getContentPager(it);
val livePager = getContentPager(it);
return@TaskHandler if(_channel?.let { StateSubscriptions.instance.isSubscribed(it) } == true)
ChannelContentCache.cachePagerResults(lifecycleScope, livePager);
else livePager;
}).success { livePager ->
setLoading(false);
val pager = if(_channel?.let { StateSubscriptions.instance.isSubscribed(it) } == true)
ChannelContentCache.cachePagerResults(lifecycleScope, livePager);
else livePager;
setPager(pager);
setPager(livePager);
}
.exception<ScriptCaptchaRequiredException> { }
.exception<Throwable> {
@@ -153,8 +152,9 @@ 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);
@@ -210,6 +210,9 @@ class ChannelFragment : MainFragment() {
UIDialogs.toast(context, "Queued [$name]", false);
}
}
adapter.onUrlClicked.subscribe { url ->
fragment.navigate<BrowserFragment>(url);
}
adapter.onContentUrlClicked.subscribe { url, contentType ->
when(contentType) {
ContentType.MEDIA -> {
@@ -1,6 +1,5 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import android.view.LayoutInflater
@@ -17,16 +16,16 @@ import com.futo.platformplayer.api.media.structures.*
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.video.PlayerManager
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.PreviewContentListAdapter
import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.InsertedViewHolder
import com.futo.platformplayer.views.adapters.PreviewNestedVideoViewHolder
import com.futo.platformplayer.views.adapters.PreviewVideoViewHolder
import com.futo.platformplayer.views.adapters.feedtypes.PreviewNestedVideoViewHolder
import com.futo.platformplayer.views.adapters.feedtypes.PreviewVideoViewHolder
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import kotlin.math.floor
abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent, IPlatformContent, IPager<IPlatformContent>, ContentPreviewViewHolder> where TFragment : MainFragment {
@@ -37,6 +36,8 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
private var _previewsEnabled: Boolean = true;
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,39 +58,22 @@ 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);
}
}
private fun attachAdapterEvents(adapter: PreviewContentListAdapter) {
adapter.onContentUrlClicked.subscribe(this, this@ContentFeedView::onContentUrlClicked);
adapter.onUrlClicked.subscribe(this, this@ContentFeedView::onUrlClicked);
adapter.onContentClicked.subscribe(this) { content, time ->
this@ContentFeedView.onContentClicked(content, time);
};
adapter.onChannelClicked.subscribe(this) { fragment.navigate<ChannelFragment>(it) };
adapter.onAddToClicked.subscribe(this) { content ->
//TODO: Reconstruct search video from detail if search is null
_overlayContainer.let {
if(content is IPlatformVideo)
UISlideOverlays.showVideoOptionsOverlay(content, it, SlideUpMenuItem(context, R.drawable.ic_visibility_off, context.getString(R.string.hide), context.getString(R.string.hide_from_home), "hide",
{ StateMeta.instance.addHiddenVideo(content.url);
if (fragment is HomeFragment) {
val removeIndex = recyclerData.results.indexOf(content);
if (removeIndex >= 0) {
recyclerData.results.removeAt(removeIndex);
recyclerData.adapter.notifyItemRemoved(recyclerData.adapter.childToParentPosition(removeIndex));
}
}
}),
SlideUpMenuItem(context, R.drawable.ic_playlist, context.getString(R.string.play_feed_as_queue), context.getString(R.string.play_entire_feed), "playFeed",
{
val newQueue = listOf(content) + recyclerData.results
.filterIsInstance<IPlatformVideo>()
.filter { it != content };
StatePlayer.instance.setQueue(newQueue, StatePlayer.TYPE_QUEUE, "Feed Queue", true, false);
})
);
if(content is IPlatformVideo) {
showVideoOptionsOverlay(content)
}
};
adapter.onAddToQueueClicked.subscribe(this) {
@@ -99,15 +83,61 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
UIDialogs.toast(context, context.getString(R.string.queued) + " [$name]", false);
}
};
adapter.onLongPress.subscribe(this) {
if (it is IPlatformVideo) {
showVideoOptionsOverlay(it)
}
};
}
fun onBackPressed(): Boolean {
val videoOptionsOverlay = _videoOptionsOverlay
if (videoOptionsOverlay != null) {
if (videoOptionsOverlay.isVisible) {
videoOptionsOverlay.hide();
_videoOptionsOverlay = null
return true;
}
_videoOptionsOverlay = null
return false
}
return false
}
private fun showVideoOptionsOverlay(content: IPlatformVideo) {
_overlayContainer.let {
_videoOptionsOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it, SlideUpMenuItem(context, R.drawable.ic_visibility_off, context.getString(R.string.hide), context.getString(R.string.hide_from_home), "hide",
{ StateMeta.instance.addHiddenVideo(content.url);
if (fragment is HomeFragment) {
val removeIndex = recyclerData.results.indexOf(content);
if (removeIndex >= 0) {
recyclerData.results.removeAt(removeIndex);
recyclerData.adapter.notifyItemRemoved(recyclerData.adapter.childToParentPosition(removeIndex));
}
}
}),
SlideUpMenuItem(context, R.drawable.ic_playlist, context.getString(R.string.play_feed_as_queue), context.getString(R.string.play_entire_feed), "playFeed",
{
val newQueue = listOf(content) + recyclerData.results
.filterIsInstance<IPlatformVideo>()
.filter { it != content };
StatePlayer.instance.setQueue(newQueue, StatePlayer.TYPE_QUEUE, "Feed Queue", true, false);
})
);
}
}
private fun detachAdapterEvents() {
val adapter = recyclerData.adapter as PreviewContentListAdapter? ?: return;
adapter.onContentUrlClicked.remove(this);
adapter.onUrlClicked.remove(this);
adapter.onContentClicked.remove(this);
adapter.onChannelClicked.remove(this);
adapter.onAddToClicked.remove(this);
adapter.onAddToQueueClicked.remove(this);
adapter.onLongPress.remove(this);
}
override fun onRestoreCachedData(cachedData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>) {
@@ -137,11 +167,14 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
protected open fun onContentClicked(content: IPlatformContent, time: Long) {
if(content is IPlatformVideo) {
StatePlayer.instance.clearQueue();
if (Settings.instance.playback.shouldResumePreview(time))
fragment.navigate<VideoDetailFragment>(content.withTimestamp(time)).maximizeVideoDetail();
else
fragment.navigate<VideoDetailFragment>(content).maximizeVideoDetail();
if (StatePlayer.instance.hasQueue) {
StatePlayer.instance.addToQueue(content)
} else {
if (Settings.instance.playback.shouldResumePreview(time))
fragment.navigate<VideoDetailFragment>(content.withTimestamp(time)).maximizeVideoDetail();
else
fragment.navigate<VideoDetailFragment>(content).maximizeVideoDetail();
}
} else if (content is IPlatformPlaylist) {
fragment.navigate<PlaylistFragment>(content);
} else if (content is IPlatformPost) {
@@ -159,6 +192,9 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
else -> {};
}
}
protected open fun onUrlClicked(url: String) {
fragment.navigate<BrowserFragment>(url);
}
private fun playPreview() {
if(feedStyle == FeedStyle.THUMBNAIL)
@@ -62,6 +62,13 @@ class ContentSearchResultsFragment : MainFragment() {
_view = null;
}
override fun onBackPressed(): Boolean {
if (_view?.onBackPressed() == true)
return true
return super.onBackPressed()
}
fun setPreviewsEnabled(previewsEnabled: Boolean) {
_view?.setPreviewsEnabled(previewsEnabled && Settings.instance.search.previewFeedItems);
}
@@ -77,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 ->
@@ -66,6 +66,13 @@ class HomeFragment : MainFragment() {
return view;
}
override fun onBackPressed(): Boolean {
if (_view?.onBackPressed() == true)
return true
return super.onBackPressed()
}
override fun onDestroyMainView() {
super.onDestroyMainView();
@@ -88,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 {
@@ -44,6 +44,13 @@ class PlaylistSearchResultsFragment : MainFragment() {
return view;
}
override fun onBackPressed(): Boolean {
if (_view?.onBackPressed() == true)
return true
return super.onBackPressed()
}
override fun onDestroyMainView() {
super.onDestroyMainView();
_view?.cleanup();
@@ -17,6 +17,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.R
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.assume
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
@@ -54,6 +55,14 @@ class PlaylistsFragment : MainFragment() {
_view?.onShown(parameter, isBack);
}
override fun onBackPressed(): Boolean {
if (_view?.onBackPressed() == true) {
return true;
}
return super.onBackPressed()
}
@SuppressLint("ViewConstructor")
class PlaylistsView : LinearLayout {
private val _fragment: PlaylistsFragment;
@@ -64,6 +73,7 @@ class PlaylistsFragment : MainFragment() {
private var _adapterWatchLater: VideoListHorizontalAdapter;
private var _adapterPlaylist: PlaylistsAdapter;
private var _layoutWatchlist: ConstraintLayout;
private var _slideUpOverlay: SlideUpMenuOverlay? = null;
constructor(fragment: PlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) {
_fragment = fragment;
@@ -92,41 +102,24 @@ class PlaylistsFragment : MainFragment() {
recyclerPlaylists.adapter = _adapterPlaylist;
recyclerPlaylists.layoutManager = LinearLayoutManager(context);
val nameInput = SlideUpMenuTextInput(context, context.getString(R.string.name));
val addPlaylistOverlay = SlideUpMenuOverlay(context, findViewById<FrameLayout>(R.id.overlay_create_playlist), context.getString(R.string.create_new_playlist), context.getString(R.string.ok), false, nameInput);
val buttonCreatePlaylist = findViewById<ImageButton>(R.id.button_create_playlist);
buttonCreatePlaylist.setOnClickListener {
_slideUpOverlay = UISlideOverlays.showCreatePlaylistOverlay(findViewById<FrameLayout>(R.id.overlay_create_playlist)) {
val playlist = Playlist(it, arrayListOf());
playlists.add(0, playlist);
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
_adapterPlaylist.notifyItemInserted(0);
};
};
_adapterPlaylist.onClick.subscribe { p -> _fragment.navigate<PlaylistFragment>(p); };
_adapterPlaylist.onPlay.subscribe { p ->
StatePlayer.instance.setPlaylist(p, 0, true);
};
addPlaylistOverlay.onOK.subscribe {
val text = nameInput.text;
if (text.isBlank()) {
return@subscribe;
}
val playlist = Playlist(text, arrayListOf());
playlists.add(0, playlist);
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
_adapterPlaylist.notifyItemInserted(0);
addPlaylistOverlay.hide();
nameInput.deactivate();
nameInput.clear();
};
addPlaylistOverlay.onCancel.subscribe {
nameInput.deactivate();
nameInput.clear();
};
val buttonCreatePlaylist = findViewById<ImageButton>(R.id.button_create_playlist);
buttonCreatePlaylist.setOnClickListener {
addPlaylistOverlay.show();
nameInput.activate();
};
_appBar = findViewById(R.id.app_bar);
_layoutWatchlist = findViewById(R.id.layout_watchlist);
@@ -142,12 +135,28 @@ class PlaylistsFragment : MainFragment() {
fun onShown(parameter: Any?, isBack: Boolean) {
playlists.clear()
playlists.addAll(StatePlaylists.instance.getPlaylists().sortedByDescending { maxOf(it.datePlayed, it.dateUpdate, it.dateCreation) });
playlists.addAll(
StatePlaylists.instance.getPlaylists()
.sortedByDescending { maxOf(it.datePlayed, it.dateUpdate, it.dateCreation) });
_adapterPlaylist.notifyDataSetChanged();
updateWatchLater();
}
fun onBackPressed(): Boolean {
val slideUpOverlay = _slideUpOverlay;
if (slideUpOverlay != null) {
if (slideUpOverlay.isVisible) {
slideUpOverlay.hide();
return true;
}
return false;
}
return false;
}
private fun updateWatchLater() {
val watchList = StatePlaylists.instance.getWatchLater();
if (watchList.isNotEmpty()) {
@@ -20,7 +20,6 @@ import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
@@ -32,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
@@ -46,7 +46,7 @@ import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.platformplayer.views.others.Toggle
import com.futo.platformplayer.views.adapters.PreviewPostView
import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView
import com.futo.platformplayer.views.overlays.RepliesOverlay
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
import com.futo.polycentric.core.ApiMethods
@@ -364,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)
@@ -80,12 +80,21 @@ class SubscriptionsFeedFragment : MainFragment() {
}
}
override fun onBackPressed(): Boolean {
if (_view?.onBackPressed() == true)
return true
return super.onBackPressed()
}
fun setPreviewsEnabled(previewsEnabled: Boolean) {
_view?.setPreviewsEnabled(previewsEnabled && Settings.instance.subscriptions.previewFeedItems);
}
@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 ->
@@ -273,7 +282,7 @@ class SubscriptionsFeedFragment : MainFragment() {
override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
val nowSoon = OffsetDateTime.now().plusMinutes(5);
return results.filter {
val allowedContentType = _filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO) ContentType.MEDIA else it.contentType);
val allowedContentType = _filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType);
if(it.datetime?.isAfter(nowSoon) == true) {
if(!_filterSettings.allowPlanned)
@@ -68,8 +68,6 @@ class VideoDetailFragment : MainFragment {
super.onShownWithView(parameter, isBack);
Logger.i(TAG, "onShownWithView parameter=$parameter")
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
if(parameter is IPlatformVideoDetails)
_viewDetail?.setVideoDetails(parameter, true);
else if (parameter is IPlatformVideo)
@@ -176,7 +174,6 @@ class VideoDetailFragment : MainFragment {
_viewDetail?.onStop();
close();
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
StatePlayer.instance.clearQueue();
StatePlayer.instance.setPlayerClosed();
}
@@ -22,6 +22,7 @@ import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.view.WindowManager
import android.widget.*
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.lifecycleScope
@@ -59,6 +60,7 @@ import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.downloads.VideoLocal
import com.futo.platformplayer.engine.exceptions.ScriptAgeException
import com.futo.platformplayer.engine.exceptions.ScriptException
@@ -216,6 +218,9 @@ class VideoDetailView : ConstraintLayout {
private var _lastAudioSource: IAudioSource? = null;
private var _lastSubtitleSource: ISubtitleSource? = null;
private var _isCasting: Boolean = false;
var isPlaying: Boolean = false
private set;
var lastPositionMilliseconds: Long = 0
private set;
private var _historicalPosition: Long = 0;
@@ -419,7 +424,7 @@ class VideoDetailView : ConstraintLayout {
_layoutSkip.visibility = VISIBLE;
}
else if(chapter?.type == ChapterType.SKIP) {
_player.seekTo(chapter.timeEnd.toLong() * 1000);
_player.seekTo((chapter.timeEnd * 1000).toLong());
UIDialogs.toast(context, "Skipped chapter [${chapter.name}]", false);
}
}
@@ -600,6 +605,8 @@ class VideoDetailView : ConstraintLayout {
_lastSubtitleSource = null;
video = null;
_playbackTracker = null;
Logger.i(TAG, "Keep screen on unset onClose")
fragment.activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
};
_layoutResume.setOnClickListener {
@@ -615,7 +622,7 @@ class VideoDetailView : ConstraintLayout {
_layoutSkip.setOnClickListener {
val currentChapter = _player.getCurrentChapter(_player.position);
if(currentChapter?.type == ChapterType.SKIPPABLE) {
_player.seekTo(currentChapter.timeEnd.toLong() * 1000);
_player.seekTo((currentChapter.timeEnd * 1000).toLong());
}
}
}
@@ -1087,7 +1094,7 @@ class VideoDetailView : ConstraintLayout {
_player.setMetadata(video.name, video.author.name);
_toggleCommentType.setValue(false, false);
_toggleCommentType.setValue(Settings.instance.comments.defaultCommentSection == 1, false);
updateCommentType(true);
//UI
@@ -1174,7 +1181,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)
@@ -1479,6 +1486,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() {
@@ -1680,14 +1688,28 @@ class VideoDetailView : ConstraintLayout {
if(playing) {
_minimize_controls_pause.visibility = View.VISIBLE;
_minimize_controls_play.visibility = View.GONE;
if (_isCasting) {
if (Settings.instance.casting.keepScreenOn) {
Logger.i(TAG, "Keep screen on set handlePlayChanged casting")
fragment.activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
} else {
Logger.i(TAG, "Keep screen on set handlePlayChanged player")
fragment.activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
}
else {
_minimize_controls_pause.visibility = View.GONE;
_minimize_controls_play.visibility = View.VISIBLE;
Logger.i(TAG, "Keep screen on unset handlePlayChanged")
fragment.activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
isPlaying = playing;
onPlayChanged.emit(playing);
updateTracker(_player.position, playing, true);
updateTracker(lastPositionMilliseconds, playing, true);
}
private fun handleSelectVideoTrack(videoSource: IVideoSource) {
@@ -2031,7 +2053,8 @@ class VideoDetailView : ConstraintLayout {
StatePlaylists.instance.updateHistoryPosition(v, true, (positionMilliseconds.toFloat() / 1000.0f).toLong());
_lastPositionSaveTime = currentTime;
}
updateTracker(positionMilliseconds, _player.playing, false);
updateTracker(positionMilliseconds, isPlaying, false);
}
private fun updateTracker(positionMs: Long, isPlaying: Boolean, forceUpdate: Boolean = false) {
@@ -0,0 +1,299 @@
package com.futo.platformplayer.parsers
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.toURIRobust
import com.futo.platformplayer.yesNoToBoolean
import java.net.URI
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
class HLS {
companion object {
fun downloadAndParseMasterPlaylist(client: ManagedHttpClient, sourceUrl: String): MasterPlaylist {
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 baseUrl = sourceUrl.toURIRobust()!!.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(client, 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 downloadAndParseVariantPlaylist(client: ManagedHttpClient, sourceUrl: String): VariantPlaylist {
val response = client.get(sourceUrl)
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
val content = response.body?.string()
?: throw Exception("Variant playlist content is empty")
val lines = content.lines()
val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull() ?: 3
val targetDuration = lines.find { it.startsWith("#EXT-X-TARGETDURATION:") }?.substringAfter(":")?.toIntOrNull()
?: throw Exception("Target duration not found in variant playlist")
val mediaSequence = lines.find { it.startsWith("#EXT-X-MEDIA-SEQUENCE:") }?.substringAfter(":")?.toLongOrNull() ?: 0
val discontinuitySequence = lines.find { it.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE:") }?.substringAfter(":")?.toIntOrNull() ?: 0
val programDateTime = lines.find { it.startsWith("#EXT-X-PROGRAM-DATE-TIME:") }?.substringAfter(":")?.let {
ZonedDateTime.parse(it, DateTimeFormatter.ISO_DATE_TIME)
}
val segments = mutableListOf<Segment>()
var currentSegment: Segment? = null
lines.forEach { line ->
when {
line.startsWith("#EXTINF:") -> {
val duration = line.substringAfter(":").substringBefore(",").toDoubleOrNull()
?: throw Exception("Invalid segment duration format")
currentSegment = Segment(duration = duration)
}
line.startsWith("#") -> {
// Handle other tags if necessary
}
else -> {
currentSegment?.let {
it.uri = line
segments.add(it)
}
currentSegment = null
}
}
}
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, segments)
}
private fun resolveUrl(baseUrl: String, url: String): String {
return if (url.toURIRobust()!!.isAbsolute) url else baseUrl + url
}
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"],
closedCaptions = attributes["CLOSED-CAPTIONS"]
)
}
private fun parseMediaRendition(client: ManagedHttpClient, 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")
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 closedCaptions: String?
)
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?.toString()?.uppercase(),
"AUTOSELECT" to isAutoSelect?.toString()?.uppercase(),
"FORCED" to isForced?.toString()?.uppercase()
)
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("#EXT-X-STREAM-INF:")
appendAttributes(this,
"BANDWIDTH" to streamInfo.bandwidth?.toString(),
"RESOLUTION" to streamInfo.resolution,
"CODECS" to streamInfo.codecs,
"FRAME-RATE" to streamInfo.frameRate,
"VIDEO-RANGE" to streamInfo.videoRange,
"AUDIO" to streamInfo.audio,
"CLOSED-CAPTIONS" to streamInfo.closedCaptions
)
append("\n$url\n")
}
}
data class VariantPlaylist(
val version: Int,
val targetDuration: Int,
val mediaSequence: Long,
val discontinuitySequence: Int,
val programDateTime: ZonedDateTime?,
val segments: List<Segment>
) {
fun buildM3U8(): String = buildString {
append("#EXTM3U\n")
append("#EXT-X-VERSION:$version\n")
append("#EXT-X-TARGETDURATION:$targetDuration\n")
append("#EXT-X-MEDIA-SEQUENCE:$mediaSequence\n")
append("#EXT-X-DISCONTINUITY-SEQUENCE:$discontinuitySequence\n")
programDateTime?.let {
append("#EXT-X-PROGRAM-DATE-TIME:${it.format(DateTimeFormatter.ISO_DATE_TIME)}\n")
}
segments.forEach { segment ->
append("#EXTINF:${segment.duration},\n")
append(segment.uri + "\n")
}
}
}
data class Segment(
val duration: Double,
var uri: String = ""
)
}
@@ -0,0 +1,64 @@
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 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;
}
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;
}
}
}
}
@@ -54,6 +54,12 @@ class StatePlayer {
var queueShuffle: Boolean = false
private set;
val hasQueue: Boolean get() {
synchronized(_queue) {
return _queue.isNotEmpty()
}
}
val queueName: String get() = _queueName ?: _queueType;
//Events
@@ -31,7 +31,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> {
@@ -0,0 +1,5 @@
package com.futo.platformplayer.stores.db
class ManagedDBIndex {
}
@@ -0,0 +1,5 @@
package com.futo.platformplayer.stores.db
class ManagedDBStore {
}
@@ -15,6 +15,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec
private val _cache: Array<Fragment?> = arrayOfNulls(4);
val onContentUrlClicked = Event2<String, ContentType>();
val onUrlClicked = Event1<String>();
val onContentClicked = Event2<IPlatformContent, Long>();
val onChannelClicked = Event1<PlatformAuthorLink>();
val onAddToClicked = Event1<IPlatformContent>();
@@ -50,6 +51,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec
0 -> ChannelContentsFragment.newInstance().apply {
onContentClicked.subscribe(this@ChannelViewPagerAdapter.onContentClicked::emit);
onContentUrlClicked.subscribe(this@ChannelViewPagerAdapter.onContentUrlClicked::emit);
onUrlClicked.subscribe(this@ChannelViewPagerAdapter.onUrlClicked::emit);
onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit);
onAddToClicked.subscribe(this@ChannelViewPagerAdapter.onAddToClicked::emit);
onAddToQueueClicked.subscribe(this@ChannelViewPagerAdapter.onAddToQueueClicked::emit);
@@ -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)
@@ -1,4 +1,4 @@
package com.futo.platformplayer.views.adapters
package com.futo.platformplayer.views.adapters.feedtypes
import android.content.Context
import android.util.Log
@@ -18,6 +18,9 @@ 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.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.EmptyPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewViewHolder> {
private var _initialPlay = true;
@@ -26,12 +29,15 @@ 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>();
val onContentClicked = Event2<IPlatformContent, Long>();
val onChannelClicked = Event1<PlatformAuthorLink>();
val onAddToClicked = Event1<IPlatformContent>();
val onAddToQueueClicked = Event1<IPlatformContent>();
val onLongPress = Event1<IPlatformContent>();
private var _taskLoadContent = TaskHandler<Pair<ContentPreviewViewHolder, IPlatformContent>, Pair<ContentPreviewViewHolder, IPlatformContentDetails>>(
StateApp.instance.scopeGetter, { (viewHolder, video) ->
@@ -43,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;
@@ -71,6 +78,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
ContentType.POST -> createPostViewHolder(viewGroup);
ContentType.PLAYLIST -> createPlaylistViewHolder(viewGroup);
ContentType.NESTED_VIDEO -> createNestedViewHolder(viewGroup);
ContentType.LOCKED -> createLockedViewHolder(viewGroup);
else -> EmptyPreviewViewHolder(viewGroup)
}
}
@@ -86,13 +94,17 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
this.onAddToClicked.subscribe(this@PreviewContentListAdapter.onAddToClicked::emit);
this.onAddToQueueClicked.subscribe(this@PreviewContentListAdapter.onAddToQueueClicked::emit);
};
private fun createLockedViewHolder(viewGroup: ViewGroup): PreviewLockedViewHolder = PreviewLockedViewHolder(viewGroup, _feedStyle).apply {
this.onLockedUrlClicked.subscribe(this@PreviewContentListAdapter.onUrlClicked::emit);
};
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);
this.onAddToQueueClicked.subscribe(this@PreviewContentListAdapter.onAddToQueueClicked::emit);
this.onLongPress.subscribe(this@PreviewContentListAdapter.onLongPress::emit);
};
private fun createPlaylistViewHolder(viewGroup: ViewGroup): PreviewPlaylistViewHolder = PreviewPlaylistViewHolder(viewGroup, _feedStyle).apply {
this.onPlaylistClicked.subscribe { this@PreviewContentListAdapter.onContentClicked.emit(it, 0L) };
@@ -141,6 +153,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
fun release() {
_taskLoadContent.dispose();
onContentUrlClicked.clear();
onUrlClicked.clear();
onContentClicked.clear();
onChannelClicked.clear();
onAddToClicked.clear();
@@ -0,0 +1,134 @@
package com.futo.platformplayer.views.adapters.feedtypes
import android.animation.ObjectAnimator
import android.content.Context
import android.content.res.Resources
import android.util.Log
import android.util.TypedValue
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.bumptech.glide.Glide
import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
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.locked.IPlatformLockedContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.images.GlideHelper.Companion.loadThumbnails
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.video.PlayerManager
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.video.FutoThumbnailPlayer
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.google.android.material.imageview.ShapeableImageView
class PreviewLockedView : LinearLayout {
protected val _feedStyle : FeedStyle;
private val _textChannelName: TextView;
private val _textVideoName: TextView;
//private val _imageNeoPassChannel: ImageView;
private val _imageChannelThumbnail: ImageView;
private val _imageVideoThumbnail: ImageView;
private val _platformIndicator: PlatformIndicator;
private val _textLockedDescription: TextView;
//private val _textBrowserOpen: TextView;
private val _textLockedUrl: TextView;
private val _textVideoMetadata: TextView;
val onLockedUrlClicked = Event1<String>();
constructor(context: Context, feedStyle : FeedStyle) : super(context) {
inflate(feedStyle);
_feedStyle = feedStyle;
_textVideoName = findViewById(R.id.text_video_name);
_textChannelName = findViewById(R.id.text_channel_name);
//_imageNeoPassChannel = findViewById(R.id.image_neopass_channel);
_imageChannelThumbnail = findViewById(R.id.image_channel_thumbnail);
_imageVideoThumbnail = findViewById(R.id.image_video_thumbnail);
_platformIndicator = findViewById(R.id.thumbnail_platform)
_textLockedDescription = findViewById(R.id.text_locked_description);
//_textBrowserOpen = findViewById(R.id.text_browser_open);
_textLockedUrl = findViewById(R.id.text_locked_url);
_textVideoMetadata = findViewById(R.id.text_video_metadata);
setOnClickListener {
if(!_textLockedUrl.text.isNullOrEmpty())
onLockedUrlClicked.emit(_textLockedUrl.text.toString());
}
}
protected open fun inflate(feedStyle: FeedStyle) {
inflate(context, when(feedStyle) {
FeedStyle.PREVIEW -> R.layout.list_locked_preview
else -> R.layout.list_locked_thumbnail
}, this)
}
open fun bind(content: IPlatformContent) {
_textVideoName.text = content.name;
_textChannelName.text = content.author.name;
_platformIndicator.setPlatformFromClientID(content.id.pluginId);
if(content is IPlatformLockedContent) {
_imageVideoThumbnail.loadThumbnails(content.contentThumbnails, false) {
it.placeholder(R.drawable.placeholder_video_thumbnail)
.into(_imageVideoThumbnail);
};
Glide.with(_imageChannelThumbnail)
.load(content.author.thumbnail)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.into(_imageChannelThumbnail);
_textLockedDescription.text = content.lockDescription ?: "";
_textLockedUrl.text = content.unlockUrl ?: "";
}
else {
_imageChannelThumbnail.setImageResource(0);
_imageVideoThumbnail.setImageResource(0);
_textLockedDescription.text = "";
_textLockedUrl.text = "";
}
if(_textLockedUrl.text.isNullOrEmpty()) {
_textLockedUrl.visibility = GONE;
_textVideoMetadata.text = "";
}
else {
_textLockedUrl.visibility = VISIBLE;
_textVideoMetadata.text = "Tap to open in browser";
}
}
open fun preview(video: IPlatformContentDetails?, paused: Boolean) {
}
companion object {
private val TAG = "PreviewLockedView"
}
}
@@ -0,0 +1,46 @@
package com.futo.platformplayer.views.adapters.feedtypes
import android.content.Context
import android.graphics.drawable.Animatable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import com.futo.platformplayer.*
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.contents.PlatformContentPlaceholder
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.platform.PlatformIndicator
class PreviewLockedViewHolder : ContentPreviewViewHolder {
override var content: IPlatformContent? = null;
private val view: PreviewLockedView get() = itemView as PreviewLockedView;
val onLockedUrlClicked = Event1<String>();
val context: Context;
constructor(viewGroup: ViewGroup, feedStyle: FeedStyle) : super(PreviewLockedView(viewGroup.context, feedStyle)) {
context = itemView.context;
view.onLockedUrlClicked.subscribe(onLockedUrlClicked::emit);
}
override fun bind(content: IPlatformContent) = view.bind(content);
override fun preview(video: IPlatformContentDetails?, paused: Boolean) { }
override fun stopPreview() { }
override fun pausePreview() { }
override fun resumePreview() { }
companion object {
private val TAG = "PlaceholderPreviewViewHolder"
}
}
@@ -1,4 +1,4 @@
package com.futo.platformplayer.views.adapters
package com.futo.platformplayer.views.adapters.feedtypes
import android.content.Context
import android.view.View
@@ -1,9 +1,6 @@
package com.futo.platformplayer.views.adapters
package com.futo.platformplayer.views.adapters.feedtypes
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.FrameLayout
import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
@@ -13,6 +10,7 @@ import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.video.PlayerManager
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
class PreviewNestedVideoViewHolder : ContentPreviewViewHolder {
@@ -25,7 +23,9 @@ class PreviewNestedVideoViewHolder : ContentPreviewViewHolder {
override val content: IPlatformContent? get() = view.content;
private val view: PreviewNestedVideoView get() = itemView as PreviewNestedVideoView;
constructor(viewGroup: ViewGroup, feedStyle : FeedStyle, exoPlayer: PlayerManager? = null): super(PreviewNestedVideoView(viewGroup.context, feedStyle, exoPlayer)) {
constructor(viewGroup: ViewGroup, feedStyle : FeedStyle, exoPlayer: PlayerManager? = null): super(
PreviewNestedVideoView(viewGroup.context, feedStyle, exoPlayer)
) {
view.onContentUrlClicked.subscribe(onContentUrlClicked::emit);
view.onVideoClicked.subscribe(onVideoClicked::emit);
view.onChannelClicked.subscribe(onChannelClicked::emit);
@@ -1,4 +1,4 @@
package com.futo.platformplayer.views.adapters
package com.futo.platformplayer.views.adapters.feedtypes
import android.content.Context
import android.graphics.drawable.Animatable
@@ -12,6 +12,7 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.contents.PlatformContentPlaceholder
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.platform.PlatformIndicator
@@ -1,4 +1,4 @@
package com.futo.platformplayer.views.adapters
package com.futo.platformplayer.views.adapters.feedtypes
import android.view.ViewGroup
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
@@ -7,6 +7,8 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.PlaylistView
class PreviewPlaylistViewHolder : ContentPreviewViewHolder {
@@ -1,4 +1,4 @@
package com.futo.platformplayer.views.adapters
package com.futo.platformplayer.views.adapters.feedtypes
import android.animation.ObjectAnimator
import android.content.Context
@@ -1,4 +1,4 @@
package com.futo.platformplayer.views.adapters
package com.futo.platformplayer.views.adapters.feedtypes
import android.view.ViewGroup
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
@@ -7,6 +7,7 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.video.PlayerManager
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
class PreviewPostViewHolder : ContentPreviewViewHolder {
@@ -18,7 +19,9 @@ class PreviewPostViewHolder : ContentPreviewViewHolder {
private val view: PreviewPostView get() = itemView as PreviewPostView;
constructor(viewGroup: ViewGroup, feedStyle : FeedStyle, exoPlayer: PlayerManager? = null): super(PreviewPostView(viewGroup.context, feedStyle)) {
constructor(viewGroup: ViewGroup, feedStyle : FeedStyle, exoPlayer: PlayerManager? = null): super(
PreviewPostView(viewGroup.context, feedStyle)
) {
view.onContentClicked.subscribe(onContentClicked::emit);
view.onChannelClicked.subscribe(onChannelClicked::emit);
}
@@ -1,4 +1,4 @@
package com.futo.platformplayer.views.adapters
package com.futo.platformplayer.views.adapters.feedtypes
import android.animation.ObjectAnimator
import android.content.Context
@@ -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,7 +69,10 @@ 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>();
val onAddToClicked = Event1<IPlatformVideo>();
val onAddToQueueClicked = Event1<IPlatformVideo>();
@@ -76,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;
@@ -116,10 +123,17 @@ 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
setOnClickListener { onOpenClicked() };
setOnLongClickListener {
onLongPress()
true
};
setOnClickListener {
onOpenClicked()
};
_imageChannel.setOnClickListener { currentVideo?.let { onChannelClicked.emit(it.author) } };
_textChannelName.setOnClickListener { currentVideo?.let { onChannelClicked.emit(it.author) } };
_textVideoMetadata.setOnClickListener { currentVideo?.let { onChannelClicked.emit(it.author) } };
@@ -145,6 +159,12 @@ open class PreviewVideoView : LinearLayout {
}
}
protected open fun onLongPress() {
currentVideo?.let {
onLongPress.emit(it);
}
}
open fun bind(content: IPlatformContent) {
_taskLoadProfile.cancel();
@@ -222,13 +242,23 @@ open class PreviewVideoView : LinearLayout {
_containerLive.visibility = GONE;
_containerDuration.visibility = VISIBLE;
}
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;
}
@@ -1,4 +1,4 @@
package com.futo.platformplayer.views.adapters
package com.futo.platformplayer.views.adapters.feedtypes
import android.view.ViewGroup
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
@@ -9,6 +9,7 @@ import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.video.PlayerManager
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
class PreviewVideoViewHolder : ContentPreviewViewHolder {
@@ -17,6 +18,7 @@ class PreviewVideoViewHolder : ContentPreviewViewHolder {
val onChannelClicked = Event1<PlatformAuthorLink>();
val onAddToClicked = Event1<IPlatformVideo>();
val onAddToQueueClicked = Event1<IPlatformVideo>();
val onLongPress = Event1<IPlatformVideo>();
//val context: Context;
val currentVideo: IPlatformVideo? get() = view.currentVideo;
@@ -25,11 +27,14 @@ 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);
view.onAddToClicked.subscribe(onAddToClicked::emit);
view.onAddToQueueClicked.subscribe(onAddToQueueClicked::emit);
view.onLongPress.subscribe(onLongPress::emit);
}
@@ -6,6 +6,7 @@ import android.animation.ObjectAnimator
import android.content.Context
import android.graphics.drawable.Animatable
import android.util.AttributeSet
import android.util.Log
import android.view.GestureDetector
import android.view.LayoutInflater
import android.view.MotionEvent
@@ -57,8 +58,10 @@ class GestureControlView : LinearLayout {
private var _isFullScreen = false;
private var _animatorBrightness: ObjectAnimator? = null;
private val _layoutControlsFullscreen: FrameLayout;
private var _adjustingFullscreen: Boolean = false;
private var _fullScreenFactor = 1.0f;
private var _adjustingFullscreenUp: Boolean = false;
private var _adjustingFullscreenDown: Boolean = false;
private var _fullScreenFactorUp = 1.0f;
private var _fullScreenFactorDown = 1.0f;
val onSeek = Event1<Long>();
val onBrightnessAdjusted = Event1<Float>();
@@ -100,10 +103,14 @@ class GestureControlView : LinearLayout {
_soundFactor = (_soundFactor + adjustAmount).coerceAtLeast(0.0f).coerceAtMost(1.0f);
_progressSound.progress = _soundFactor;
onSoundAdjusted.emit(_soundFactor);
} else if (_adjustingFullscreen) {
} else if (_adjustingFullscreenUp) {
val adjustAmount = (distanceY * 2) / height;
_fullScreenFactor = (_fullScreenFactor + adjustAmount).coerceAtLeast(0.0f).coerceAtMost(1.0f);
_layoutControlsFullscreen.transitionAlpha = _fullScreenFactor;
_fullScreenFactorUp = (_fullScreenFactorUp + adjustAmount).coerceAtLeast(0.0f).coerceAtMost(1.0f);
_layoutControlsFullscreen.alpha = _fullScreenFactorUp;
} else if (_adjustingFullscreenDown) {
val adjustAmount = (-distanceY * 2) / height;
_fullScreenFactorDown = (_fullScreenFactorDown + adjustAmount).coerceAtLeast(0.0f).coerceAtMost(1.0f);
_layoutControlsFullscreen.alpha = _fullScreenFactorDown;
} else {
val rx = p0.x / width;
val ry = p0.y / height;
@@ -114,7 +121,11 @@ class GestureControlView : LinearLayout {
} else if (_isFullScreen && rx > 0.6) {
startAdjustingSound();
} else if (rx >= 0.4 && rx <= 0.6) {
startAdjustingFullscreen();
if (_isFullScreen) {
startAdjustingFullscreenDown();
} else {
startAdjustingFullscreenUp();
}
}
}
}
@@ -180,11 +191,18 @@ class GestureControlView : LinearLayout {
stopAdjustingBrightness();
}
if (_adjustingFullscreen && ev.action == MotionEvent.ACTION_UP) {
if (_fullScreenFactor > 0.5) {
if (_adjustingFullscreenUp && ev.action == MotionEvent.ACTION_UP) {
if (_fullScreenFactorUp > 0.5) {
onToggleFullscreen.emit();
}
stopAdjustingFullscreen();
stopAdjustingFullscreenUp();
}
if (_adjustingFullscreenDown && ev.action == MotionEvent.ACTION_UP) {
if (_fullScreenFactorDown > 0.5) {
onToggleFullscreen.emit();
}
stopAdjustingFullscreenDown();
}
startHideJobIfNecessary();
@@ -210,7 +228,8 @@ class GestureControlView : LinearLayout {
hideControls();
} catch (e: Throwable) {
Logger.e(TAG, "Failed to hide controls", e);
if(e !is CancellationException)
Logger.e(TAG, "Failed to hide controls", e);
}
};
}
@@ -468,15 +487,27 @@ class GestureControlView : LinearLayout {
_animatorSound?.start();
}
private fun startAdjustingFullscreen() {
_adjustingFullscreen = true;
_fullScreenFactor = 0f;
_layoutControlsFullscreen.transitionAlpha = 0f;
private fun startAdjustingFullscreenUp() {
_adjustingFullscreenUp = true;
_fullScreenFactorUp = 0f;
_layoutControlsFullscreen.alpha = 0f;
_layoutControlsFullscreen.visibility = View.VISIBLE;
}
private fun stopAdjustingFullscreen() {
_adjustingFullscreen = false;
private fun stopAdjustingFullscreenUp() {
_adjustingFullscreenUp = false;
_layoutControlsFullscreen.visibility = View.GONE;
}
private fun startAdjustingFullscreenDown() {
_adjustingFullscreenDown = true;
_fullScreenFactorDown = 0f;
_layoutControlsFullscreen.alpha = 0f;
_layoutControlsFullscreen.visibility = View.VISIBLE;
}
private fun stopAdjustingFullscreenDown() {
_adjustingFullscreenDown = false;
_layoutControlsFullscreen.visibility = View.GONE;
}
@@ -510,7 +541,7 @@ class GestureControlView : LinearLayout {
//onSoundAdjusted.emit(1.0f);
stopAdjustingBrightness();
stopAdjustingSound();
stopAdjustingFullscreen();
stopAdjustingFullscreenUp();
}
_isFullScreen = isFullScreen;
@@ -134,6 +134,10 @@ class SlideUpMenuOverlay : RelativeLayout {
}
fun show(){
if (isVisible) {
return;
}
isVisible = true;
_container?.post {
_container?.visibility = View.VISIBLE;
@@ -146,8 +150,8 @@ class SlideUpMenuOverlay : RelativeLayout {
_viewBackground.alpha = 0f;
val animations = arrayListOf<Animator>();
animations.add(ObjectAnimator.ofFloat(_viewBackground, "alpha", 0.0f, 1.0f).setDuration(500));
animations.add(ObjectAnimator.ofFloat(_viewOverlayContainer, "translationY", _viewOverlayContainer.measuredHeight.toFloat(), 0.0f).setDuration(500));
animations.add(ObjectAnimator.ofFloat(_viewBackground, "alpha", 0.0f, 1.0f).setDuration(ANIMATION_DURATION_MS));
animations.add(ObjectAnimator.ofFloat(_viewOverlayContainer, "translationY", _viewOverlayContainer.measuredHeight.toFloat(), 0.0f).setDuration(ANIMATION_DURATION_MS));
val animatorSet = AnimatorSet();
animatorSet.playTogether(animations);
@@ -159,11 +163,15 @@ class SlideUpMenuOverlay : RelativeLayout {
}
fun hide(animate: Boolean = true){
if (!isVisible) {
return
}
isVisible = false;
if (_animated && animate) {
val animations = arrayListOf<Animator>();
animations.add(ObjectAnimator.ofFloat(_viewBackground, "alpha", 1.0f, 0.0f).setDuration(500));
animations.add(ObjectAnimator.ofFloat(_viewOverlayContainer, "translationY", 0.0f, _viewOverlayContainer.measuredHeight.toFloat()).setDuration(500));
animations.add(ObjectAnimator.ofFloat(_viewBackground, "alpha", 1.0f, 0.0f).setDuration(ANIMATION_DURATION_MS));
animations.add(ObjectAnimator.ofFloat(_viewOverlayContainer, "translationY", 0.0f, _viewOverlayContainer.measuredHeight.toFloat()).setDuration(ANIMATION_DURATION_MS));
val animatorSet = AnimatorSet();
animatorSet.doOnEnd {
@@ -180,4 +188,8 @@ class SlideUpMenuOverlay : RelativeLayout {
_container?.visibility = View.GONE;
}
}
companion object {
private const val ANIMATION_DURATION_MS = 350L
}
}
@@ -4,6 +4,7 @@ import android.content.Context
import android.content.res.Resources
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.os.Handler
import android.util.AttributeSet
import android.util.Log
import android.util.TypedValue
@@ -16,6 +17,7 @@ import android.widget.RelativeLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.setMargins
import androidx.lifecycle.LifecycleOwner
import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
@@ -35,6 +37,10 @@ import com.google.android.exoplayer2.ui.PlayerControlView
import com.google.android.exoplayer2.ui.StyledPlayerView
import com.google.android.exoplayer2.ui.TimeBar
import com.google.android.exoplayer2.video.VideoSize
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlin.math.abs
@@ -91,6 +97,10 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
var isFitMode : Boolean = false
private set;
private var _isScrubbing = false;
private val _currentChapterLoopLock = Object();
private var _currentChapterLoopActive = false;
private var _currentChapterLoopId: Int = 0;
private var _currentChapter: IChapter? = null;
@@ -186,18 +196,30 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
if (!attrShowMinimize)
_control_minimize.visibility = View.GONE;
var lastScrubPosition = 0L;
_time_bar_listener = object : TimeBar.OnScrubListener {
override fun onScrubStart(timeBar: TimeBar, position: Long) {
_isScrubbing = true;
Logger.i(TAG, "Scrubbing started");
gestureControl.restartHideJob();
}
override fun onScrubMove(timeBar: TimeBar, position: Long) {
gestureControl.restartHideJob();
updateCurrentChapter(position);
val playerPosition = position;
val scrubDelta = abs(lastScrubPosition - position);
lastScrubPosition = position;
if(scrubDelta > 1000 || Math.abs(position - playerPosition) > 500)
_currentChapterUpdateExecuter.execute {
updateCurrentChapter(position);
}
}
override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) {
_isScrubbing = false;
Logger.i(TAG, "Scrubbing stopped");
gestureControl.restartHideJob();
}
};
@@ -239,15 +261,16 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
UIDialogs.showCastingDialog(context);
};
var lastPos = 0L;
videoControls.setProgressUpdateListener { position, bufferedPosition ->
onTimeBarChanged.emit(position, bufferedPosition);
val delta = position - lastPos;
if(delta > 1000 || delta < 0) {
lastPos = position;
updateCurrentChapter();
}
if(!_currentChapterLoopActive)
synchronized(_currentChapterLoopLock) {
if(!_currentChapterLoopActive) {
_currentChapterLoopActive = true;
updateChaptersLoop(++_currentChapterLoopId);
}
}
}
if(!isInEditMode) {
@@ -255,24 +278,58 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
}
}
private val _currentChapterUpdateInterval: Long = 1000L / Settings.instance.playback.getChapterUpdateFrames();
private var _currentChapterUpdateLastPos = 0L;
private val _currentChapterUpdateExecuter = Executors.newSingleThreadScheduledExecutor();
private fun updateChaptersLoop(loopId: Int) {
if(_currentChapterLoopId == loopId) {
_currentChapterLoopActive = true;
_currentChapterUpdateExecuter.schedule({
try {
if(!_isScrubbing) {
var pos: Long = runBlocking(Dispatchers.Main) { position; };
val delta = pos - _currentChapterUpdateLastPos;
if(delta > _currentChapterUpdateInterval || delta < 0) {
_currentChapterUpdateLastPos = pos;
if(updateCurrentChapter(pos))
Logger.i(TAG, "Updated chapter to [${_currentChapter?.name}] with speed ${delta}ms (${pos - (_currentChapter?.timeStart?.times(1000)?.toLong() ?: 0)}ms late [${_currentChapter?.timeStart}s])");
}
}
if(playing)
updateChaptersLoop(loopId);
else
_currentChapterLoopActive = false;
}
catch(ex: Throwable) {
_currentChapterLoopActive = false;
}
}, _currentChapterUpdateInterval, TimeUnit.MILLISECONDS);
}
else
_currentChapterLoopActive = false;
}
fun attachPlayer() {
exoPlayer?.attach(_videoView, PLAYER_STATE_NAME);
}
fun updateCurrentChapter(pos: Long? = null) {
val chaptPos = pos ?: position;
fun updateCurrentChapter(chaptPos: Long, isScrub: Boolean = false): Boolean {
val currentChapter = getCurrentChapter(chaptPos);
if(_currentChapter != currentChapter) {
_currentChapter = currentChapter;
if (currentChapter != null) {
_control_chapter.text = "" + currentChapter.name;
_control_chapter_fullscreen.text = "" + currentChapter.name;
} else {
_control_chapter.text = "";
_control_chapter_fullscreen.text = "";
runBlocking(Dispatchers.Main) {
if (currentChapter != null) {
_control_chapter.text = "" + currentChapter.name;
_control_chapter_fullscreen.text = "" + currentChapter.name;
} else {
_control_chapter.text = "";
_control_chapter_fullscreen.text = "";
}
onChapterChanged.emit(currentChapter, isScrub);
}
onChapterChanged.emit(currentChapter, pos != null);
return true;
}
return false;
}
fun setArtwork(drawable: Drawable?) {
@@ -1,6 +1,7 @@
package com.futo.platformplayer.views.video
import android.content.Context
import android.media.session.PlaybackState
import android.net.Uri
import android.util.AttributeSet
import android.widget.RelativeLayout
@@ -60,7 +61,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
private set;
val exoPlayerStateName: String;
val playing: Boolean get() = exoPlayer?.player?.playWhenReady ?: false;
var playing: Boolean = false;
val position: Long get() = exoPlayer?.player?.currentPosition ?: 0;
val duration: Long get() = exoPlayer?.player?.duration ?: 0;
@@ -98,11 +99,23 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
}
}
}
updatePlaying();
}
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
super.onPlayWhenReadyChanged(playWhenReady, reason)
onPlayChanged.emit(playWhenReady);
updatePlaying();
}
fun updatePlaying() {
val newPlaying = exoPlayer?.let { it.player.playWhenReady && it.player.playbackState != Player.STATE_ENDED && it.player.playbackState != Player.STATE_IDLE } ?: false
if (newPlaying == playing) {
return;
}
playing = newPlaying;
onPlayChanged.emit(playing);
}
override fun onVideoSizeChanged(videoSize: VideoSize) {
@@ -167,6 +180,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
}
fun seekTo(ms: Long) {
Logger.i(TAG, "Seeking to [${ms}ms]");
exoPlayer?.player?.seekTo(ms);
}
fun seekToEnd(ms: Long = 0) {
@@ -218,7 +232,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
return _chapters?.let { it.toList() } ?: listOf();
}
fun getCurrentChapter(pos: Long): IChapter? {
return _chapters?.let { chaps -> chaps.find { pos / 1000 > it.timeStart && pos / 1000 < it.timeEnd } };
return _chapters?.let { chaps -> chaps.find { pos.toDouble() / 1000 > it.timeStart && pos.toDouble() / 1000 < it.timeEnd } };
}
fun setSource(videoSource: IVideoSource?, audioSource: IAudioSource? = null, play: Boolean = false, keepSubtitles: Boolean = false) {
@@ -0,0 +1,333 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintDimensionRatio="H,16:13">
<FrameLayout
android:id="@+id/player_container"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintDimensionRatio="H,16:9">
<ImageView
android:id="@+id/image_video_thumbnail"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:adjustViewBounds="true"
android:contentDescription="@string/thumbnail"
android:scaleType="centerCrop"
android:layout_marginBottom="6dp"
tools:srcCompat="@drawable/placeholder_video_thumbnail" />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<!--
<com.futo.platformplayer.views.platform.PlatformIndicator
android:id="@+id/thumbnail_platform_nested"
android:layout_width="25dp"
android:layout_height="25dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:layout_margin="5dp"
android:scaleType="centerInside"
tools:src="@drawable/ic_peertube"/> -->
<LinearLayout
android:id="@+id/thumbnail_live_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:paddingStart="2dp"
android:paddingEnd="2dp"
android:layout_marginEnd="4dp"
android:layout_marginBottom="13dp"
android:paddingTop="0dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:background="@drawable/background_thumbnail_live"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent">
<TextView
android:id="@+id/thumbnail_live"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:includeFontPadding="false"
android:paddingLeft="2dp"
android:paddingRight="2dp"
android:textColor="#FFFFFF"
android:textSize="12dp"
android:text="@string/live"
android:layout_gravity="center"
android:textStyle="normal" />
</LinearLayout>
<LinearLayout
android:id="@+id/thumbnail_duration_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:paddingStart="2dp"
android:paddingEnd="2dp"
android:layout_marginEnd="4dp"
android:layout_marginBottom="13dp"
android:paddingTop="0dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:background="@drawable/background_thumbnail_duration"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent">
<TextView
android:id="@+id/thumbnail_duration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:includeFontPadding="false"
android:paddingLeft="2dp"
android:paddingRight="2dp"
android:textColor="#FFFFFF"
android:textSize="12dp"
tools:text="0:00"
android:layout_gravity="center"
android:textStyle="normal" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout
android:id="@+id/container_loader"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginBottom="6dp"
android:background="#DD000000"
android:visibility="gone"
android:orientation="vertical">
</LinearLayout>
<LinearLayout
android:id="@+id/container_locked"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginBottom="6dp"
android:background="#DD000000"
android:visibility="visible"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#AAAA"
android:layout_marginTop="50dp"
android:textSize="12dp"
android:layout_weight="1"
android:text="@string/locked_content_description"
android:textAlignment="center" />
<TextView
android:id="@+id/text_locked_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="-10dp"
android:layout_marginLeft="20dp"
android:layout_marginRight="20dp"
android:textSize="16dp"
android:layout_weight="1"
android:text="Lorem ipsum something something, and something more perhaps"
android:textAlignment="center" />
<TextView
android:id="@+id/text_browser_open"
android:textColor="#AAAA"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="13dp"
android:text="@string/tap_to_open_in_browser"
android:textAlignment="center" />
<TextView
android:id="@+id/text_locked_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="30dp"
android:maxLines="1"
android:ellipsize="end"
android:layout_marginLeft="10dp"
android:layout_marginRight="10dp"
android:textSize="12dp"
android:textColor="#828EFF"
android:layout_weight="1"
android:text="@string/unknown"
android:textAlignment="center" />
</LinearLayout>
</FrameLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="-6dp"
app:layout_constraintTop_toBottomOf="@id/player_container"
app:layout_constraintBottom_toBottomOf="parent"
android:orientation="vertical"
android:gravity="center_vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:gravity="top"
android:orientation="horizontal"
android:layout_gravity="center_vertical"
android:paddingBottom="5dp">
<com.futo.platformplayer.views.others.CreatorThumbnail
android:id="@+id/creator_thumbnail"
android:layout_width="32dp"
android:layout_height="32dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginStart="10dp"
android:layout_marginTop="10dp" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="@id/creator_thumbnail"
app:layout_constraintLeft_toRightOf="@id/creator_thumbnail"
app:layout_constraintRight_toLeftOf="@id/container_info"
android:orientation="vertical"
android:paddingStart="10dp">
<TextView
android:id="@+id/text_video_name"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:textAlignment="viewStart"
android:layout_marginTop="-3dp"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
tools:text="I Thought FSD is Terrible in SNOW | 8 inch SNOW | FSD Beta 10.69.2.4"
android:maxLines="2" />
<TextView
android:id="@+id/text_channel_name"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:maxLines="1"
android:textSize="12dp"
android:textColor="@color/gray_e0"
android:fontFamily="@font/inter_extra_light"
tools:text="Two Minute Papers" />
<TextView
android:id="@+id/text_video_metadata"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:maxLines="1"
android:gravity="center_vertical"
android:textSize="12dp"
android:textColor="@color/gray_e0"
android:fontFamily="@font/inter_extra_light"
tools:text="57K views • 1 day ago" />
</LinearLayout>
<LinearLayout
android:id="@+id/container_info"
android:layout_width="wrap_content"
android:layout_height="30dp"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginEnd="6dp"
android:paddingLeft="10dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<!--
<FrameLayout android:id="@+id/layout_downloaded"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="8dp">
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitXY"
app:srcCompat="@drawable/download_for_offline" />
</FrameLayout> -->
<com.futo.platformplayer.views.platform.PlatformIndicator
android:id="@+id/thumbnail_platform"
android:layout_width="25dp"
android:layout_height="25dp"
android:scaleType="centerInside"
tools:src="@drawable/ic_peertube"/>
</LinearLayout>
<LinearLayout
android:id="@+id/container_buttons"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:orientation="horizontal"
android:paddingEnd="6dp">
<!--
<ImageButton
android:id="@+id/button_add_to_queue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="1dp"
android:paddingTop="7dp"
android:paddingStart="6dp"
android:paddingEnd="5dp"
android:paddingBottom="3dp"
app:srcCompat="@drawable/ic_queue_16dp"
android:background="@drawable/edit_text_background"
android:contentDescription="@string/add_to_queue" />
<LinearLayout
android:id="@+id/button_add_to"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="@drawable/edit_text_background"
android:layout_marginStart="4dp"
android:gravity="center_vertical"
android:padding="4dp">
<ImageButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
app:srcCompat="@drawable/ic_add_white_8dp"
android:layout_marginStart="4dp"
android:contentDescription="@string/options" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/options"
android:background="@color/transparent"
android:textSize="12dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_light"
android:layout_marginEnd="4dp"/>
</LinearLayout> -->
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,317 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="115dp"
android:orientation="vertical">
<LinearLayout
android:id="@+id/item_video"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="5dp"
android:layout_marginBottom="5dp">
<FrameLayout
android:id="@+id/player_container"
android:layout_width="178dp"
android:layout_height="100dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/image_video_thumbnail"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:contentDescription="@string/thumbnail"
app:shapeAppearanceOverlay="@style/roundedCorners_4dp"
app:srcCompat="@drawable/placeholder_video_thumbnail"
android:background="@drawable/video_thumbnail_outline" />
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<!--
<LinearLayout
android:id="@+id/thumbnail_live_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_alignParentEnd="true"
android:layout_gravity="end"
android:paddingStart="2dp"
android:paddingEnd="2dp"
android:layout_marginEnd="4dp"
android:layout_marginBottom="4dp"
android:paddingTop="0dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:background="@drawable/background_thumbnail_live">
<TextView
android:id="@+id/thumbnail_live"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:includeFontPadding="false"
android:paddingLeft="2dp"
android:paddingRight="2dp"
android:textColor="#FFFFFF"
android:textSize="12dp"
android:text="@string/live"
android:layout_gravity="center"
android:textStyle="normal" />
</LinearLayout> -->
<!--
<LinearLayout
android:id="@+id/thumbnail_duration_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_alignParentEnd="true"
android:layout_gravity="end"
android:paddingStart="2dp"
android:paddingEnd="2dp"
android:layout_marginEnd="4dp"
android:layout_marginBottom="4dp"
android:paddingTop="0dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:background="@drawable/background_thumbnail_duration">
<TextView
android:id="@+id/thumbnail_duration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:includeFontPadding="false"
android:paddingLeft="2dp"
android:paddingRight="2dp"
android:textColor="#FFFFFF"
android:textSize="12dp"
tools:text="0:00"
android:layout_gravity="center"
android:textStyle="normal" />
</LinearLayout> -->
<!--
<FrameLayout android:id="@+id/layout_downloaded"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_alignParentEnd="true"
android:layout_alignParentTop="true"
android:background="@drawable/background_pill_black"
android:layout_marginTop="2dp"
android:layout_marginEnd="2dp">
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitXY"
app:srcCompat="@drawable/download_for_offline" />
</FrameLayout> -->
</RelativeLayout>
<LinearLayout
android:id="@+id/container_loader"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#BB000000"
android:visibility="gone"
android:orientation="vertical" />
<LinearLayout
android:id="@+id/container_locked"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#BB000000"
android:visibility="visible"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginTop="10dp"
android:textSize="10dp"
android:text="@string/locked_content_description"
android:textAlignment="center" />
<TextView
android:id="@+id/text_locked_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="12dp"
android:maxLines="3"
android:ellipsize="end"
android:layout_marginLeft="5dp"
android:layout_marginRight="5dp"
android:layout_weight="1"
android:text="Lorem ipsum something something, and something more perhaps"
android:textAlignment="center" />
<!--
<TextView
android:id="@+id/text_browser_open"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="10dp"
android:layout_weight="1"
android:text="@string/tap_to_open_in_browser"
android:textAlignment="center" /> -->
<TextView
android:id="@+id/text_locked_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:layout_marginLeft="5dp"
android:layout_marginRight="5dp"
android:maxLines="1"
android:ellipsize="end"
android:textColor="#828EFF"
android:layout_weight="1"
android:textSize="10dp"
android:text="@string/unknown"
android:textAlignment="center" />
</LinearLayout>
</FrameLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="6dp"
android:layout_marginEnd="6dp">
<!--
<ImageButton
android:id="@+id/button_add_to_queue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="1dp"
android:paddingTop="7dp"
android:paddingStart="6dp"
android:paddingEnd="5dp"
android:paddingBottom="3dp"
app:srcCompat="@drawable/ic_queue_16dp"
android:background="@drawable/edit_text_background"
android:contentDescription="@string/add_to_queue"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<LinearLayout
android:id="@+id/button_add_to"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="@drawable/edit_text_background"
android:layout_marginStart="4dp"
android:gravity="center_vertical"
android:padding="4dp"
app:layout_constraintLeft_toRightOf="@id/button_add_to_queue"
app:layout_constraintBottom_toBottomOf="parent">
<ImageButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
app:srcCompat="@drawable/ic_add_white_8dp"
android:layout_marginStart="4dp"
android:contentDescription="@string/options" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/options"
android:background="@color/transparent"
android:textSize="12dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_light"
android:layout_marginEnd="4dp"/>
</LinearLayout> -->
<TextView
android:id="@+id/text_video_name"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:padding="2dp"
android:layout_weight="1"
android:gravity="center_vertical"
android:textSize="13dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_light"
tools:text="Legendary grant recipient: Marvin Wißfeld of MicroG Very loong title"
android:maxLines="2"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintRight_toRightOf="parent" />
<ImageView
android:id="@+id/image_channel_thumbnail"
android:layout_width="28dp"
android:layout_height="28dp"
android:background="@drawable/rounded_outline"
android:contentDescription="@string/channel_image"
tools:src="@drawable/placeholder_channel_thumbnail"
android:clipToOutline="true"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_video_name" />
<TextView
android:id="@+id/text_channel_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_vertical"
android:maxLines="1"
android:ellipsize="end"
android:textSize="10dp"
android:textColor="@color/gray_e0"
android:fontFamily="@font/inter_extra_light"
tools:text="Two Minute Papers"
app:layout_constrainedWidth="true"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintLeft_toRightOf="@id/image_channel_thumbnail"
app:layout_constraintRight_toLeftOf="@id/image_neopass_channel"
app:layout_constraintTop_toBottomOf="@id/text_video_name"
android:layout_marginStart="4dp" />
<ImageView
android:id="@+id/image_neopass_channel"
android:layout_width="10dp"
android:layout_height="10dp"
android:contentDescription="@string/neopass_channel"
app:srcCompat="@drawable/neopass"
app:layout_constraintLeft_toRightOf="@id/text_channel_name"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="@id/text_channel_name"
app:layout_constraintBottom_toBottomOf="@id/text_channel_name"
android:layout_marginStart="4dp"
android:visibility="gone"/>
<TextView
android:id="@+id/text_video_metadata"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:maxLines="1"
android:gravity="center_vertical"
android:textSize="10dp"
android:textColor="@color/gray_e0"
android:fontFamily="@font/inter_extra_light"
tools:text="57K views • 1 day ago"
app:layout_constraintLeft_toRightOf="@id/image_channel_thumbnail"
app:layout_constraintTop_toBottomOf="@id/text_channel_name"
app:layout_constraintRight_toRightOf="parent"
android:layout_marginStart="4dp"/>
<com.futo.platformplayer.views.platform.PlatformIndicator
android:id="@+id/thumbnail_platform"
android:layout_width="20dp"
android:layout_height="20dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:layout_margin="4dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
</LinearLayout>
@@ -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>
+27
View File
@@ -8,7 +8,10 @@
<string name="lorem_ipsum" translatable="false">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</string>
<string name="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>
@@ -60,6 +63,8 @@
<string name="view_all">View all</string>
<string name="creators">Creators</string>
<string name="enabled">Enabled</string>
<string name="keep_screen_on">Keep screen on</string>
<string name="keep_screen_on_while_casting">Keep screen on while casting</string>
<string name="discover">Discover</string>
<string name="find_new_video_sources_to_add">Find new video sources to add</string>
<string name="these_sources_have_been_disabled">These sources have been disabled</string>
@@ -80,6 +85,7 @@
<string name="developer">Developer</string>
<string name="remove_historical_suggestion">Remove historical suggestion</string>
<string name="comments">Comments</string>
<string name="comments_description">The comment section underneath content</string>
<string name="merchandise">Merchandise</string>
<string name="reached_the_end_of_the_playlist">Reached the end of the playlist</string>
<string name="the_playlist_will_restart_after_the_video_is_finished">The playlist will restart after the video is finished</string>
@@ -219,6 +225,7 @@
<string name="construction">CONSTRUCTION</string>
<string name="disable">Disable</string>
<string name="the_following_content_cannot_be_opened_in_grayjay_due_to_a_missing_plugin">The following content cannot be opened in Grayjay due to a missing plugin.</string>
<string name="locked_content_description">This content is locked</string>
<string name="unknown">Unknown</string>
<string name="tap_to_open_in_browser">Tap to open in browser</string>
<string name="missing_plugin">Missing Plugin</string>
@@ -263,6 +270,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>
@@ -292,6 +302,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>
@@ -349,6 +361,7 @@
<string name="preferred_metered_quality">Preferred Metered Quality</string>
<string name="preferred_preview_quality">Preferred Preview Quality</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>
<string name="remove_cached_version">Remove Cached Version</string>
<string name="remove_the_last_downloaded_version">Remove the last downloaded version</string>
@@ -360,6 +373,8 @@
<string name="restore_a_previous_automatic_backup">Restore a previous automatic backup</string>
<string name="resume_after_preview">Resume After Preview</string>
<string name="review_the_current_and_past_changelogs">Review the current and past changelogs</string>
<string name="chapter_update_fps_title">Chapter Update FPS</string>
<string name="chapter_update_fps_description">Change accuracy of chapter updating, higher might cost more performance</string>
<string name="set_automatic_backup">Set Automatic Backup</string>
<string name="shortly_after_opening_the_app_start_fetching_subscriptions">Shortly after opening the app, start fetching subscriptions</string>
<string name="show_faq">Show FAQ</string>
@@ -657,6 +672,8 @@
<string name="stopped_after_requestcount_to_avoid_rate_limit_click_load_more_to_load_more">Stopped after {requestCount} to avoid rate limit, click load more to load more.</string>
<string name="this_creator_has_not_setup_any_monetization_features">This creator has not setup any monetization features</string>
<string name="plus_tax">" + Tax"</string>
<string name="new_playlist">New playlist</string>
<string name="add_to_new_playlist">Add to new playlist</string>
<string-array name="home_screen_array">
<item>Recommendations</item>
<item>Subscriptions</item>
@@ -779,6 +796,16 @@
<item>Resume After 10s</item>
<item>Always Resume</item>
</string-array>
<string-array name="chapter_fps">
<item>24</item>
<item>30</item>
<item>60</item>
<item>120</item>
</string-array>
<string-array name="comment_sections">
<item>Polycentric</item>
<item>Platform</item>
</string-array>
<string-array name="audio_languages">
<item>English</item>
<item>Spanish</item>
+1 -1
View File
@@ -25,7 +25,7 @@ cp ./app/build/outputs/apk/stable/release/app-stable-arm64-v8a-release.apk $DOCU
VERSION=$(git describe --tags)
echo $VERSION > $DOCUMENT_ROOT/version.txt
mkdir -p $DOCUMENT_ROOT/changelogs
git tag -l $VERSION -n1000 | awk '{$1=""; print $0}' | sed -e 's/^[ \t]*//' > $DOCUMENT_ROOT/changelogs/$VERSION
git tag -l --format='%(contents)' $VERSION > $DOCUMENT_ROOT/changelogs/$VERSION
# Notify Cloudflare to wipe the CDN cache
echo "Purging Cloudflare cache for zone $CLOUDFLARE_ZONE_ID..."