mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-17 21:32:39 +02:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3fa208680 | |||
| 502602e27a | |||
| 5054b093a4 | |||
| 0ffaec6bc2 | |||
| ef8ea9eecf | |||
| b09d22e479 | |||
| 01787b6229 | |||
| 4c022698d3 | |||
| bfdcab0e84 | |||
| aaea5cc963 | |||
| 23d9c33406 | |||
| fad1b216df | |||
| e221b508d3 | |||
| dfafac7d99 | |||
| 2246f8cee2 | |||
| 8661ff88c0 | |||
| 0bba7fa373 | |||
| 0c1822b118 | |||
| 6df8f84421 | |||
| 7fa80ec048 | |||
| b3f9b81984 | |||
| 1393c489c1 | |||
| 640c2cbed0 | |||
| e55509f549 | |||
| 27c7fb0c12 | |||
| 88f3815585 | |||
| 2e9405cfdb | |||
| 9c1b543ed6 | |||
| d34cb0f9c1 | |||
| 116dc90d21 |
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
-1
@@ -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);
|
||||
}
|
||||
|
||||
+13
-22
@@ -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 {
|
||||
|
||||
+149
-6
@@ -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);
|
||||
|
||||
+13
@@ -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
@@ -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");
|
||||
};
|
||||
}
|
||||
|
||||
+62
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -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);
|
||||
}
|
||||
|
||||
+36
@@ -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("&", "&")}\",FORMAT=\"$audioFormat\"")
|
||||
}
|
||||
|
||||
// Subtitles
|
||||
if (subtitleSource != null && subtitleUrl != null) {
|
||||
val subtitleFormat = subtitleSource.format ?: "text/vtt"
|
||||
writer.println("#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",LANGUAGE=\"en\",NAME=\"English\",AUTOSELECT=YES,DEFAULT=YES,URI=\"${subtitleUrl.replace("&", "&")}\",FORMAT=\"$subtitleFormat\"")
|
||||
}
|
||||
|
||||
// Video
|
||||
val videoFormat = vidSource.container.substringAfter("/")
|
||||
writer.println("#EXT-X-STREAM-INF:BANDWIDTH=100000,CODECS=\"${vidSource.codec}\",RESOLUTION=${vidSource.width}x${vidSource.height}${if (audioSource != null) ",AUDIO=\"audio\"" else ""}${if (subtitleSource != null) ",SUBTITLES=\"subs\"" else ""},FORMAT=\"$videoFormat\"")
|
||||
writer.println(vidUrl.replace("&", "&"))
|
||||
}
|
||||
|
||||
return hlsBuilder.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,14 @@ class ChannelContentCache {
|
||||
uncacheContent(content);
|
||||
}
|
||||
}
|
||||
fun clearToday() {
|
||||
val yesterday = OffsetDateTime.now().minusDays(1);
|
||||
synchronized(_channelContents) {
|
||||
for(channel in _channelContents)
|
||||
for(content in channel.value.getItems().filter { it.datetime?.isAfter(yesterday) == true })
|
||||
uncacheContent(content);
|
||||
}
|
||||
}
|
||||
|
||||
fun getChannelCachePager(channelUrl: String): PlatformContentPager {
|
||||
val validID = channelUrl.toSafeFileName();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+10
-10
@@ -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);
|
||||
|
||||
+3
@@ -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 -> {
|
||||
|
||||
+67
-31
@@ -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)
|
||||
|
||||
+8
@@ -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 {
|
||||
|
||||
+7
@@ -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();
|
||||
|
||||
+39
-30
@@ -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()) {
|
||||
|
||||
+3
-3
@@ -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)
|
||||
|
||||
+10
-1
@@ -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)
|
||||
|
||||
-3
@@ -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();
|
||||
}
|
||||
|
||||
+29
-6
@@ -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)
|
||||
|
||||
+16
-3
@@ -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();
|
||||
+134
@@ -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"
|
||||
}
|
||||
}
|
||||
+46
@@ -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
-1
@@ -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
|
||||
+5
-5
@@ -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);
|
||||
+2
-1
@@ -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
|
||||
|
||||
|
||||
+3
-1
@@ -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
-1
@@ -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
|
||||
+5
-2
@@ -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);
|
||||
}
|
||||
+33
-3
@@ -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;
|
||||
}
|
||||
|
||||
+7
-2
@@ -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;
|
||||
|
||||
+16
-4
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Submodule app/src/stable/assets/sources/kick updated: d0b7a2c1b4...396dd16987
Submodule app/src/stable/assets/sources/odysee updated: a8bc4ff913...6ea204605d
Submodule app/src/stable/assets/sources/patreon updated: 9e26b7032e...55aef15f4b
Submodule app/src/stable/assets/sources/youtube updated: 4f89b4072f...8f10daba1e
Submodule app/src/unstable/assets/sources/kick updated: d0b7a2c1b4...396dd16987
Submodule app/src/unstable/assets/sources/odysee updated: a8bc4ff913...6ea204605d
Submodule app/src/unstable/assets/sources/patreon updated: 9e26b7032e...55aef15f4b
Submodule app/src/unstable/assets/sources/youtube updated: 4f89b4072f...8f10daba1e
+1
-1
Submodule dep/polycentricandroid updated: 7de4d54c25...839e4c4a4f
+1
-1
@@ -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..."
|
||||
|
||||
Reference in New Issue
Block a user