Compare commits

...

57 Commits

Author SHA1 Message Date
Kelvin eb3dd854d4 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-23 17:28:23 +01:00
Kelvin c529446219 Attempt to fetch live videos for offline videos 2023-11-23 17:28:14 +01:00
Koen fa2f8c3447 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-23 16:45:09 +01:00
Koen 840d1ae534 Fixes to adhere closer to the HLS spec and Twitch VODs no longer start at end. 2023-11-23 16:44:58 +01:00
Kelvin 2530c6eb58 Live chat improvements and fixes 2023-11-23 16:35:13 +01:00
Koen ee3761c780 Added full support for HLS casting to Airplay. 2023-11-23 13:18:09 +01:00
Koen e4c89e9aa9 Extended HLS spec, fixes to YES NO booleans, started on implementing HLS stream combiner. 2023-11-23 12:48:16 +01:00
Koen 9d5888ddf7 Fixed VODs not working properly for YouTube and Twitch. 2023-11-23 11:48:50 +01:00
Koen ecc94920d7 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-22 22:33:05 +01:00
Koen 5cafbf243e Fixed channel contents long press and fixed a crash due to time bars. 2023-11-22 22:32:44 +01:00
Kelvin f3fa208680 Kick subs fix, dedup fix 2023-11-22 18:04:29 +01:00
Kelvin 502602e27a Reordering progress bar settings 2023-11-22 16:50:54 +01:00
Kelvin 5054b093a4 Stable refs 2023-11-22 16:15:05 +01:00
Kelvin 0ffaec6bc2 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-22 16:05:39 +01:00
Kelvin ef8ea9eecf Fix whitelist checking for dev-portal 2023-11-22 16:05:27 +01:00
Koen b09d22e479 Added historical time bars to videos. 2023-11-22 14:49:34 +01:00
Koen 01787b6229 Added backfill exception printing to announcements. 2023-11-22 12:46:39 +01:00
Koen 4c022698d3 Quality selection overlay now properly closes when pressing the back button. 2023-11-22 11:32:51 +01:00
Koen bfdcab0e84 Properly handle V1 encrypted secrets in the upgrade process from V0 to V1. 2023-11-22 11:21:18 +01:00
Koen aaea5cc963 Only close the app on closeSegment if there is no video playing. 2023-11-22 10:38:04 +01:00
Koen 23d9c33406 Added support for v6 Odysee URLs. 2023-11-22 10:27:35 +01:00
Koen fad1b216df Further extended HLS spec that is implemented. 2023-11-22 09:32:52 +01:00
Kelvin e221b508d3 Improved notifications, experimental scheduled notifications 2023-11-21 23:31:26 +01:00
Koen dfafac7d99 Merge branch 'hls-live-stream-proxy' into 'master'
Finished implementation of HLS proxying.

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

See merge request videostreaming/grayjay!4
2023-11-15 16:18:38 +00:00
Koen 27c7fb0c12 Content length is now set correctly for HttpConstantHandler. 2023-11-15 16:18:38 +00:00
Koen 88f3815585 When clicking on a video it is added to queue instead of replacing queue. 2023-11-15 11:49:22 +01:00
Koen 2e9405cfdb Exit full screen swipe is now a down gesture. 2023-11-15 11:40:09 +01:00
Koen 9c1b543ed6 Added 'Add to new playlist' button in options menu. 2023-11-14 14:58:24 +01:00
Koen d34cb0f9c1 Added support for long-press gesture to open options menu. 2023-11-14 14:42:41 +01:00
Koen 116dc90d21 Changed the way the changelog is written. 2023-11-14 14:18:17 +01:00
Kelvin 17b9853bb6 Better subscription behavior reporting 2023-11-14 00:27:13 +01:00
Kelvin 8bfb8abd20 Fix notifications 2023-11-13 23:35:25 +01:00
Kelvin 9ee3f1f26e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-13 22:10:43 +01:00
Kelvin 5dcff29d8d Language on all activities, Fix plugin initial state, Allow rotation prevention bypass, Settings warning support 2023-11-13 22:10:31 +01:00
Koen 6cfbd0c8bf Added + Tax indicator 2023-11-13 15:58:01 +01:00
Koen 01d96cce16 Made export request folder to export to. 2023-11-13 15:49:55 +01:00
Koen 58c376f011 Fixed Rumble subscription import. 2023-11-13 12:06:46 +01:00
Kelvin 439d339330 Fix channel membership reset 2023-11-11 16:47:14 +01:00
Kelvin 44eacc2a47 Stable refs 2023-11-10 20:38:46 +01:00
Kelvin 8135d61398 Fix language issue 2023-11-10 20:34:43 +01:00
Kelvin 66208f8265 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-10 19:49:20 +01:00
Kelvin f52251e23a Hide creators, Fix hide video, 2023-11-10 19:49:09 +01:00
Koen dbea93efe5 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-10 12:10:30 +01:00
Koen 3bf0740bd1 Fixed control cast on non-fullscreen. 2023-11-10 12:10:12 +01:00
Kelvin fa7f1b11f3 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-09 19:49:51 +01:00
Kelvin ff914bbdf4 Temporary subscription workarounds 2023-11-09 19:49:45 +01:00
Koen b822078d4b Fixed support tab falsely showing when a creator does not have a polycentric profile. 2023-11-09 19:12:54 +01:00
134 changed files with 3709 additions and 692 deletions
+3
View File
@@ -9,6 +9,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="com.android.alarm.permission.SET_ALARM"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<application
android:allowBackup="true"
@@ -39,6 +41,7 @@
<receiver android:name=".receivers.MediaControlReceiver" />
<receiver android:name=".receivers.AudioNoisyReceiver" />
<receiver android:name=".receivers.PlannedNotificationReceiver" />
<activity
android:name=".activities.MainActivity"
+5 -2
View File
@@ -540,6 +540,8 @@
<script>
IS_TESTING = true;
let lastScriptTag = null;
let shouldDevLog = true;
let shouldLoginCheck = true;
new Vue({
el: '#app',
data: {
@@ -603,7 +605,7 @@
};
setInterval(()=>{
try{
if(!this.Plugin.currentPlugin)
if(!this.Plugin.currentPlugin || !shouldDevLog)
return;
getDevLogs(this.Integration.lastLogIndex, (newLogs)=> {
@@ -638,7 +640,8 @@
}, 1000);
setInterval(()=>{
try{
this.isTestLoggedIn();
if(shouldLoginCheck)
this.isTestLoggedIn();
}catch(ex){}
}, 2500);
},
+12 -1
View File
@@ -10,7 +10,8 @@ let Type = {
Videos: "VIDEOS",
Streams: "STREAMS",
Mixed: "MIXED",
Live: "LIVE"
Live: "LIVE",
Subscriptions: "SUBSCRIPTIONS"
},
Order: {
Chronological: "CHRONOLOGICAL"
@@ -210,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,12 @@ inline fun <reified T, R> Any.assume(cb: (T) -> R): R? {
if(result != null)
return cb(result);
return null;
}
fun String?.yesNoToBoolean(): Boolean {
return this?.uppercase() == "YES"
}
fun Boolean?.toYesNo(): String {
return if (this == true) "YES" else "NO"
}
@@ -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;
@@ -8,6 +8,7 @@ import android.webkit.CookieManager
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.*
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.cache.ChannelContentCache
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
import com.futo.platformplayer.logging.Logger
@@ -21,6 +22,7 @@ import com.futo.platformplayer.views.fields.DropdownFieldOptionsId
import com.futo.platformplayer.views.fields.FormField
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.FormFieldButton
import com.futo.platformplayer.views.fields.FormFieldWarning
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -43,10 +45,7 @@ class Settings : FragmentedStorageFileJson() {
@Transient
val onTabsChanged = Event0();
@FormField(
R.string.manage_polycentric_identity, FieldForm.BUTTON,
R.string.manage_your_polycentric_identity, -5
)
@FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -5)
@FormFieldButton(R.drawable.ic_person)
fun managePolycentricIdentity() {
SettingsActivity.getActivity()?.let {
@@ -58,10 +57,7 @@ class Settings : FragmentedStorageFileJson() {
}
}
@FormField(
R.string.show_faq, FieldForm.BUTTON,
R.string.get_answers_to_common_questions, -4
)
@FormField(R.string.show_faq, FieldForm.BUTTON, R.string.get_answers_to_common_questions, -4)
@FormFieldButton(R.drawable.ic_quiz)
fun openFAQ() {
try {
@@ -71,10 +67,7 @@ class Settings : FragmentedStorageFileJson() {
//Ignored
}
}
@FormField(
R.string.show_issues, FieldForm.BUTTON,
R.string.a_list_of_user_reported_and_self_reported_issues, -3
)
@FormField(R.string.show_issues, FieldForm.BUTTON, R.string.a_list_of_user_reported_and_self_reported_issues, -3)
@FormFieldButton(R.drawable.ic_data_alert)
fun openIssues() {
try {
@@ -106,10 +99,7 @@ class Settings : FragmentedStorageFileJson() {
}
}*/
@FormField(
R.string.manage_tabs, FieldForm.BUTTON,
R.string.change_tabs_visible_on_the_home_screen, -2
)
@FormField(R.string.manage_tabs, FieldForm.BUTTON, R.string.change_tabs_visible_on_the_home_screen, -2)
@FormFieldButton(R.drawable.ic_tabs)
fun manageTabs() {
try {
@@ -123,7 +113,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.home, "group", R.string.configure_how_your_home_tab_works_and_feels, 0)
@FormField(R.string.language, "group", -1, 0)
var language = LanguageSettings();
@Serializable
class LanguageSettings {
@@ -166,6 +156,21 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
var previewFeedItems: Boolean = true;
@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();
StateMeta.instance.removeAllHiddenVideos();
SettingsActivity.getActivity()?.let {
UIDialogs.toast(it, "Creators and videos should show up again");
}
}
}
@FormField(R.string.search, "group", -1, 2)
@@ -184,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 {
@@ -194,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 {
@@ -212,11 +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.background_update, FieldForm.DROPDOWN, R.string.experimental_background_update_for_subscriptions_cache, 7)
@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, 9)
@DropdownFieldOptionsId(R.array.background_interval)
var subscriptionsBackgroundUpdateInterval: Int = 0;
@@ -232,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;
@@ -240,15 +263,25 @@ 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, 13)
var alwaysReloadFromCache: Boolean = false;
@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();
UIDialogs.toast(SettingsActivity.getActivity()!!, "Finished clearing");
}
}
@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 {
@@ -323,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 {
@@ -372,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 {
@@ -381,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 {
@@ -389,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)
@@ -406,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 {
@@ -415,10 +473,7 @@ class Settings : FragmentedStorageFileJson() {
@DropdownFieldOptionsId(R.array.log_levels)
var logLevel: Int = 0;
@FormField(
R.string.submit_logs, FieldForm.BUTTON,
R.string.submit_logs_to_help_us_narrow_down_issues, 1
)
@FormField(R.string.submit_logs, FieldForm.BUTTON, R.string.submit_logs_to_help_us_narrow_down_issues, 1)
fun submitLogs() {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
@@ -434,23 +489,26 @@ 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 {
@FormField(
R.string.reset_announcements, FieldForm.BUTTON,
R.string.reset_hidden_announcements, 1
)
@FormField(R.string.reset_announcements, FieldForm.BUTTON, R.string.reset_hidden_announcements, 1)
fun resetAnnouncements() {
StateAnnouncement.instance.resetAnnouncements();
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.announcements_reset)); };
}
}
@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
@@ -459,18 +517,12 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
var clearCookiesOnLogout: Boolean = true;
@FormField(
R.string.clear_cookies, FieldForm.BUTTON,
R.string.clears_in_app_browser_cookies, 1
)
@FormField(R.string.clear_cookies, FieldForm.BUTTON, R.string.clears_in_app_browser_cookies, 1)
fun clearCookies() {
val cookieManager: CookieManager = CookieManager.getInstance();
cookieManager.removeAllCookies(null);
}
@FormField(
R.string.reinstall_embedded_plugins, FieldForm.BUTTON,
R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1
)
@FormField(R.string.reinstall_embedded_plugins, FieldForm.BUTTON, R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1)
fun reinstallEmbedded() {
StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) {
try {
@@ -493,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 {
@@ -517,10 +569,17 @@ class Settings : FragmentedStorageFileJson() {
StateApp.instance.changeExternalDownloadDirectory(it);
}
}
@FormField(R.string.clear_external_downloads_directory, FieldForm.BUTTON, R.string.clear_the_external_storage_for_download_files, 5)
fun clearStorageDownload() {
Settings.instance.storage.storage_download = null;
Settings.instance.save();
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, "Cleared download storage directory") };
}
}
@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 {
@@ -549,10 +608,7 @@ class Settings : FragmentedStorageFileJson() {
return check == 0 && !BuildConfig.IS_PLAYSTORE_BUILD;
}
@FormField(
R.string.manual_check, FieldForm.BUTTON,
R.string.manually_check_for_updates, 3
)
@FormField(R.string.manual_check, FieldForm.BUTTON, R.string.manually_check_for_updates, 3)
fun manualCheck() {
if (!BuildConfig.IS_PLAYSTORE_BUILD) {
SettingsActivity.getActivity()?.let {
@@ -569,10 +625,7 @@ class Settings : FragmentedStorageFileJson() {
}
}
@FormField(
R.string.view_changelog, FieldForm.BUTTON,
R.string.review_the_current_and_past_changelogs, 4
)
@FormField(R.string.view_changelog, FieldForm.BUTTON, R.string.review_the_current_and_past_changelogs, 4)
fun viewChangelog() {
SettingsActivity.getActivity()?.let {
UIDialogs.toast(it.getString(R.string.retrieving_changelog));
@@ -592,10 +645,7 @@ class Settings : FragmentedStorageFileJson() {
};
}
@FormField(
R.string.remove_cached_version, FieldForm.BUTTON,
R.string.remove_the_last_downloaded_version, 5
)
@FormField(R.string.remove_cached_version, FieldForm.BUTTON, R.string.remove_the_last_downloaded_version, 5)
fun removeCachedVersion() {
StateApp.withContext {
val outputDirectory = File(it.filesDir, "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,16 @@ class Settings : FragmentedStorageFileJson() {
}
}
@FormField(R.string.info, FieldForm.GROUP, -1, 15)
@FormField(R.string.other, FieldForm.GROUP, -1, 18)
var other = Other();
@Serializable
class Other {
@FormField(R.string.bypass_rotation_prevention, FieldForm.TOGGLE, R.string.bypass_rotation_prevention_description, 1)
@FormFieldWarning(R.string.bypass_rotation_prevention_warning)
var bypassRotationPrevention: Boolean = false;
}
@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();
@@ -389,8 +433,13 @@ class UISlideOverlays {
val watchLater = StatePlaylists.instance.getWatchLater();
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
(listOf(
SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), container.context.getString(R.string.download),
{ showDownloadVideoOverlay(video, container, true); }, false))
SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), container.context.getString(R.string.download), {
showDownloadVideoOverlay(video, container, true);
}, false),
SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, container.context.getString(R.string.hide_creator_from_home), "", "hide_creator", {
StateMeta.instance.addHiddenCreator(video.author.url);
UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home");
}))
+ actions)
));
items.add(
@@ -402,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), "",
{
@@ -164,9 +164,7 @@ fun Int.sp(resources: Resources): Int {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, this.toFloat(), resources.displayMetrics).toInt()
}
fun File.share(context: Context) {
val uri = FileProvider.getUriForFile(context, context.resources.getString(R.string.authority), this);
fun DocumentFile.share(context: Context) {
val shareIntent = Intent();
shareIntent.action = Intent.ACTION_SEND;
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Animatable
import android.os.Bundle
@@ -45,6 +46,10 @@ class AddSourceActivity : AppCompatActivity() {
private var _config: SourcePluginConfig? = null;
private var _script: String? = null;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
@@ -7,6 +8,7 @@ import android.widget.*
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.*
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.buttons.BigButton
import com.google.zxing.integration.android.IntentIntegrator
import com.journeyapps.barcodescanner.CaptureActivity
@@ -43,6 +45,10 @@ class AddSourceOptionsActivity : AppCompatActivity() {
}
}
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_add_source_options);
@@ -18,6 +18,7 @@ import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.CaptchaWebViewClient
import com.futo.platformplayer.others.LoginWebViewClient
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
@@ -31,6 +32,10 @@ class CaptchaActivity : AppCompatActivity() {
private lateinit var _webView: WebView;
private lateinit var _buttonClose: Button;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_captcha);
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
@@ -11,6 +12,7 @@ import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
import com.futo.platformplayer.logging.LogLevel
import com.futo.platformplayer.logging.Logging
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -27,6 +29,10 @@ class ExceptionActivity : AppCompatActivity() {
private var _file: File? = null;
private var _submitted = false;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_exception);
@@ -17,6 +17,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.LoginWebViewClient
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
@@ -28,6 +29,9 @@ class LoginActivity : AppCompatActivity() {
private lateinit var _textUrl: TextView;
private lateinit var _buttonClose: ImageButton;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
@@ -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
@@ -327,6 +328,15 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
fragCurrent.onOrientationChanged(it);
if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED)
_fragVideoDetail.onOrientationChanged(it);
else if(Settings.instance.other.bypassRotationPrevention)
{
requestedOrientation = when(orientation) {
OrientationManager.Orientation.PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
OrientationManager.Orientation.LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
OrientationManager.Orientation.REVERSED_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
OrientationManager.Orientation.REVERSED_LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
}
}
};
_orientationManager.enable();
@@ -875,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();
})
}
}
}
}
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.os.Bundle
import android.widget.ImageButton
import androidx.appcompat.app.AppCompatActivity
@@ -10,6 +11,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.AnyAdapterView
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.adapters.ItemMoveCallback
@@ -23,6 +25,10 @@ class ManageTabsActivity : AppCompatActivity() {
private lateinit var _recyclerTabs: RecyclerView;
private lateinit var _touchHelper: ItemTouchHelper;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_manage_tabs);
@@ -16,6 +16,7 @@ import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.R
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.buttons.BigButton
import com.futo.polycentric.core.*
@@ -33,6 +34,10 @@ class PolycentricBackupActivity : AppCompatActivity() {
private lateinit var _exportBundle: String;
private lateinit var _textQR: TextView;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_backup);
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.EditText
@@ -9,8 +10,10 @@ 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
import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.ProcessHandle
import com.futo.polycentric.core.Store
@@ -28,6 +31,10 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
private var _creating = false;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_create_profile);
@@ -76,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);
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
@@ -15,6 +16,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.dp
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.buttons.BigButton
import com.futo.polycentric.core.Store
@@ -27,6 +29,10 @@ class PolycentricHomeActivity : AppCompatActivity() {
private lateinit var _buttonImportProfile: BigButton;
private lateinit var _layoutButtons: LinearLayout;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_home);
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.EditText
@@ -12,6 +13,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.*
import com.google.zxing.integration.android.IntentIntegrator
@@ -39,6 +41,10 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
}
}
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_import_profile);
@@ -2,6 +2,7 @@ package com.futo.platformplayer.activities
import android.app.Activity
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
@@ -18,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
@@ -48,6 +50,10 @@ class PolycentricProfileActivity : AppCompatActivity() {
private lateinit var _imagePolycentric: ImageView;
private var _avatarUri: Uri? = null;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_profile);
@@ -189,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));
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
@@ -7,12 +8,17 @@ import android.widget.ImageButton
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.R
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.buttons.BigButton
class PolycentricWhyActivity : AppCompatActivity() {
private lateinit var _buttonVideo: BigButton;
private lateinit var _buttonTechnical: BigButton;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_why);
@@ -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");
@@ -139,8 +197,13 @@ class HttpContext : AutoCloseable {
}
fun respondCode(status: Int, headers: HttpHeaders, body: String? = null) {
val bytes = body?.toByteArray(Charsets.UTF_8);
if(body != null && headers.get("content-length").isNullOrEmpty())
headers.put("content-length", bytes!!.size.toString());
if(headers.get("content-length").isNullOrEmpty()) {
if (body != null) {
headers.put("content-length", bytes!!.size.toString());
} else {
headers.put("content-length", "0")
}
}
respond(status, headers) { responseStream ->
if(body != null) {
responseStream.write(bytes!!);
@@ -161,8 +224,7 @@ class HttpContext : AutoCloseable {
headersToRespond.put("keep-alive", "timeout=5, max=1000");
}
val responseHeader = HttpResponse(status, headers);
val responseHeader = HttpResponse(status, headersToRespond);
responseStream.write(responseHeader.getHttpHeaderBytes());
if(method != "HEAD") {
@@ -172,38 +234,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);
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;
@@ -7,7 +7,6 @@ class HttpConstantHandler(method: String, path: String, val content: String, val
val headers = this.headers.clone();
if(contentType != null)
headers["Content-Type"] = contentType;
headers["Content-Length"] = content.length.toString();
httpContext.respondCode(200, headers, content);
}
@@ -1,14 +1,16 @@
package com.futo.platformplayer.api.http.server.handlers
import com.futo.platformplayer.api.http.server.HttpContext
import com.futo.platformplayer.api.http.server.HttpHeaders
import com.futo.platformplayer.logging.Logger
import java.io.ByteArrayOutputStream
import java.io.File
import java.nio.file.Files
import java.text.SimpleDateFormat
import java.util.*
import java.util.zip.GZIPOutputStream
class HttpFileHandler(method: String, path: String, private val contentType: String, private val filePath: String, private val closeAfterRequest: Boolean = false): HttpHandler(method, path) {
class HttpFileHandler(method: String, path: String, private val contentType: String, private val filePath: String): HttpHandler(method, path) {
override fun handle(httpContext: HttpContext) {
val requestHeaders = httpContext.headers;
val responseHeaders = this.headers.clone();
@@ -30,19 +32,13 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
responseHeaders["Content-Disposition"] = "attachment; filename=\"${file.name.replace("\"", "\\\"")}\""
val acceptEncoding = requestHeaders["Accept-Encoding"]
val shouldGzip = acceptEncoding != null && acceptEncoding.split(',').any { it.trim().equals("gzip", ignoreCase = true) || it == "*" }
if (shouldGzip) {
responseHeaders["Content-Encoding"] = "gzip"
}
val range = requestHeaders["Range"]
var start: Long
val start: Long
val end: Long
if (range != null && range.startsWith("bytes=")) {
val parts = range.substring(6).split("-")
start = parts[0].toLong()
end = parts.getOrNull(1)?.toLong() ?: (file.length() - 1)
end = parts.getOrNull(1)?.toLongOrNull() ?: (file.length() - 1)
responseHeaders["Content-Range"] = "bytes $start-$end/${file.length()}"
} else {
start = 0
@@ -51,18 +47,19 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
var totalBytesSent = 0
val contentLength = end - start + 1
Logger.i(TAG, "Sending $contentLength bytes (start: $start, end: $end, shouldGzip: $shouldGzip)")
responseHeaders["Content-Length"] = contentLength.toString()
Logger.i(TAG, "Sending $contentLength bytes (start: $start, end: $end)")
file.inputStream().use { inputStream ->
httpContext.respond(if (range == null) 200 else 206, responseHeaders) { responseStream ->
httpContext.respond(if (range != null) 206 else 200, responseHeaders) { responseStream ->
try {
val buffer = ByteArray(8192)
inputStream.skip(start)
var current = start
val outputStream = if (shouldGzip) GZIPOutputStream(responseStream) else responseStream
val outputStream = responseStream
while (true) {
val expectedBytesRead = (end - start + 1).coerceAtMost(buffer.size.toLong());
val expectedBytesRead = (end - current + 1).coerceAtMost(buffer.size.toLong());
val bytesRead = inputStream.read(buffer);
if (bytesRead < 0) {
Logger.i(TAG, "End of file reached")
@@ -73,27 +70,21 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
outputStream.write(buffer, 0, bytesToSend)
totalBytesSent += bytesToSend
Logger.v(TAG, "Sent bytes $start-${start + bytesToSend}, totalBytesSent=$totalBytesSent")
Logger.v(TAG, "Sent bytes $current-${current + bytesToSend}, totalBytesSent=$totalBytesSent")
start += bytesToSend.toLong()
if (start >= end) {
current += bytesToSend.toLong()
if (current >= end) {
Logger.i(TAG, "Expected amount of bytes sent")
break
}
}
Logger.i(TAG, "Finished sending file (segment)")
if (shouldGzip) (outputStream as GZIPOutputStream).finish()
outputStream.flush()
} catch (e: Exception) {
httpContext.respondCode(500, headers)
}
}
if (closeAfterRequest) {
httpContext.keepAlive = false;
}
}
}
@@ -15,6 +15,7 @@ abstract class HttpHandler(val method: String, val path: String) {
headers.put(key, value);
return this;
}
fun withContentType(contentType: String) = withHeader("Content-Type", contentType);
fun withTag(tag: String) : HttpHandler {
@@ -4,17 +4,10 @@ import com.futo.platformplayer.api.http.server.HttpContext
class HttpOptionsAllowHandler(path: String) : HttpHandler("OPTIONS", path) {
override fun handle(httpContext: HttpContext) {
//Just allow whatever is requested
val requestedOrigin = httpContext.headers.getOrDefault("Access-Control-Request-Origin", "");
val requestedMethods = httpContext.headers.getOrDefault("Access-Control-Request-Method", "");
val requestedHeaders = httpContext.headers.getOrDefault("Access-Control-Request-Headers", "");
val newHeaders = headers.clone();
newHeaders.put("Allow", requestedMethods);
newHeaders.put("Access-Control-Allow-Methods", requestedMethods);
newHeaders.put("Access-Control-Allow-Headers", "*");
val newHeaders = headers.clone()
newHeaders.put("Access-Control-Allow-Origin", "*")
newHeaders.put("Access-Control-Allow-Methods", "*")
newHeaders.put("Access-Control-Allow-Headers", "*")
httpContext.respondCode(200, newHeaders);
}
}
@@ -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,140 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
}
}
private fun handleWithTcp(context: HttpContext) {
if (content != null)
throw NotImplementedError("Content body is not supported")
val proxyHeaders = HashMap<String, String>();
for (header in context.headers.filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) })
proxyHeaders[header.key] = header.value;
for (injectHeader in _injectRequestHeader)
proxyHeaders[injectHeader.first] = injectHeader.second;
val parsed = Uri.parse(targetUrl);
if(_injectHost)
proxyHeaders.put("Host", parsed.host!!);
if(_injectReferer)
proxyHeaders.put("Referer", targetUrl);
val useMethod = if (method == "inherit") context.method else method;
Logger.i(TAG, "handleWithTcp Proxied Request ${useMethod}: ${parsed}");
Logger.i(TAG, "handleWithTcp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
makeTcpRequest(proxyHeaders, useMethod, parsed, context)
}
private fun makeTcpRequest(proxyHeaders: HashMap<String, String>, useMethod: String, parsed: Uri, context: HttpContext) {
val requestBuilder = StringBuilder()
requestBuilder.append("$useMethod $parsed HTTP/1.1\r\n")
proxyHeaders.forEach { (key, value) -> requestBuilder.append("$key: $value\r\n") }
requestBuilder.append("\r\n")
val port = if (parsed.port == -1) {
when (parsed.scheme) {
"https" -> 443
"http" -> 80
else -> throw Exception("Unhandled scheme")
}
} else {
parsed.port
}
val socket = if (parsed.scheme == "https") {
val sslSocketFactory = SSLSocketFactory.getDefault() as SSLSocketFactory
sslSocketFactory.createSocket(parsed.host, port)
} else {
Socket(parsed.host, port)
}
socket.use { s ->
s.getOutputStream().write(requestBuilder.toString().encodeToByteArray())
val inputStream = s.getInputStream()
val resp = HttpResponseParser(inputStream)
if (resp.statusCode == 302) {
val location = resp.location!!
Logger.i(TAG, "handleWithTcp Proxied ${resp.statusCode} following redirect to $location");
makeTcpRequest(proxyHeaders, useMethod, Uri.parse(location)!!, context)
} else {
val isChunked = resp.transferEncoding.equals("chunked", ignoreCase = true)
val contentLength = resp.contentLength.toInt()
val headersFiltered = HttpHeaders(resp.headers.filter { !_ignoreResponseHeaders.contains(it.key.lowercase()) });
for (newHeader in headers)
headersFiltered.put(newHeader.key, newHeader.value);
context.respond(resp.statusCode, headersFiltered) { responseStream ->
if (isChunked) {
Logger.i(TAG, "handleWithTcp handleChunkedTransfer");
handleChunkedTransfer(inputStream, responseStream)
} else if (contentLength > 0) {
Logger.i(TAG, "handleWithTcp transferFixedLengthContent $contentLength");
transferFixedLengthContent(inputStream, responseStream, contentLength)
} else if (contentLength == -1) {
Logger.i(TAG, "handleWithTcp transferUntilEndOfStream");
transferUntilEndOfStream(inputStream, responseStream)
} else {
Logger.i(TAG, "handleWithTcp no content");
}
}
}
}
}
private fun handleChunkedTransfer(inputStream: InputStream, responseStream: OutputStream) {
var line: String?
val buffer = ByteArray(8192)
while (inputStream.readLine().also { line = it } != null) {
val size = line!!.trim().toInt(16)
responseStream.write(line!!.encodeToByteArray())
responseStream.write("\r\n".encodeToByteArray())
if (size == 0) {
inputStream.skip(2)
responseStream.write("\r\n".encodeToByteArray())
break
}
var totalRead = 0
while (totalRead < size) {
val read = inputStream.read(buffer, 0, minOf(buffer.size, size - totalRead))
if (read == -1) break
responseStream.write(buffer, 0, read)
totalRead += read
}
inputStream.skip(2)
responseStream.write("\r\n".encodeToByteArray())
responseStream.flush()
}
}
private fun transferFixedLengthContent(inputStream: InputStream, responseStream: OutputStream, contentLength: Int) {
val buffer = ByteArray(8192)
var totalRead = 0
while (totalRead < contentLength) {
val read = inputStream.read(buffer, 0, minOf(buffer.size, contentLength - totalRead))
if (read == -1) break
responseStream.write(buffer, 0, read)
totalRead += read
}
responseStream.flush()
}
private fun transferUntilEndOfStream(inputStream: InputStream, responseStream: OutputStream) {
val buffer = ByteArray(8192)
var read: Int
while (inputStream.read(buffer).also { read = it } >= 0) {
responseStream.write(buffer, 0, read)
}
responseStream.flush()
}
fun withContent(body: String) : HttpProxyHandler {
this.content = body;
return this;
@@ -92,4 +242,8 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
_ignoreRequestHeaders.add("referer");
return this;
}
companion object {
private const val TAG = "HttpProxyHandler"
}
}
@@ -29,6 +29,7 @@ class ResultCapabilities(
const val TYPE_LIVE = "LIVE";
const val TYPE_POSTS = "POSTS";
const val TYPE_MIXED = "MIXED";
const val TYPE_SUBSCRIPTIONS = "SUBSCRIPTIONS";
const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL";
@@ -6,8 +6,8 @@ import com.futo.platformplayer.api.media.models.contents.ContentType
interface IChapter {
val name: String;
val type: ChapterType;
val timeStart: Int;
val timeEnd: Int;
val timeStart: Double;
val timeEnd: Double;
}
enum class ChapterType(val value: Int) {
@@ -13,6 +13,7 @@ enum class ContentType(val value: Int) {
NESTED_VIDEO(11),
LOCKED(70),
PLACEHOLDER(90),
DEFERRED(91);
@@ -0,0 +1,13 @@
package com.futo.platformplayer.api.media.models.locked
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
interface IPlatformLockedContent: IPlatformContent {
val lockContentType: ContentType;
val lockDescription: String?;
val unlockUrl: String?;
val contentName: String?;
val contentThumbnails: Thumbnails;
}
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.video
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.serializers.PlatformContentSerializer
@@ -18,6 +19,7 @@ interface SerializedPlatformContent: IPlatformContent {
ContentType.MEDIA -> SerializedPlatformVideo.fromVideo(content as IPlatformVideo);
ContentType.NESTED_VIDEO -> SerializedPlatformNestedContent.fromNested(content as IPlatformNestedContent);
ContentType.POST -> SerializedPlatformPost.fromPost(content as IPlatformPost);
ContentType.LOCKED -> SerializedPlatformLockedContent.fromLocked(content as IPlatformLockedContent);
else -> throw NotImplementedError("Content type ${content.contentType} not implemented");
};
}
@@ -0,0 +1,62 @@
package com.futo.platformplayer.api.media.models.video
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.states.StatePlatform
import com.futo.polycentric.core.combineHashCodes
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.OffsetDateTime
@kotlinx.serialization.Serializable
open class SerializedPlatformLockedContent(
override val id: PlatformID,
override val name: String,
override val author: PlatformAuthorLink,
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override val datetime: OffsetDateTime?,
override val url: String,
override val shareUrl: String,
override val lockContentType: ContentType,
override val contentName: String?,
override val lockDescription: String? = null,
override val unlockUrl: String? = null,
override val contentThumbnails: Thumbnails
) : IPlatformLockedContent, SerializedPlatformContent {
final override val contentType: ContentType get() = ContentType.LOCKED;
override fun toJson() : String {
return Json.encodeToString(this);
}
override fun fromJson(str : String) : SerializedPlatformLockedContent {
return Serializer.json.decodeFromString<SerializedPlatformLockedContent>(str);
}
override fun fromJsonArray(str : String) : Array<SerializedPlatformContent> {
return Serializer.json.decodeFromString<Array<SerializedPlatformContent>>(str);
}
companion object {
fun fromLocked(content: IPlatformLockedContent) : SerializedPlatformLockedContent {
return SerializedPlatformLockedContent(
content.id,
content.name,
content.author,
content.datetime,
content.url,
content.shareUrl,
content.lockContentType,
content.contentName,
content.lockDescription,
content.unlockUrl,
content.contentThumbnails
);
}
}
}
@@ -6,10 +6,13 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourceAuth
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.matchesDomain
class JSHttpClient : ManagedHttpClient {
private val _jsClient: JSClient?;
private val _jsConfig: SourcePluginConfig?;
private val _auth: SourceAuth?;
private val _captcha: SourceCaptchaData?;
@@ -20,8 +23,9 @@ class JSHttpClient : ManagedHttpClient {
private var _currentCookieMap: HashMap<String, HashMap<String, String>>;
constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null) : super() {
constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, config: SourcePluginConfig? = null) : super() {
_jsClient = jsClient;
_jsConfig = config;
_auth = auth;
_captcha = captcha;
@@ -87,7 +91,11 @@ class JSHttpClient : ManagedHttpClient {
}
}
_jsClient?.validateUrlOrThrow(request.url.toString());
if(_jsClient != null)
_jsClient?.validateUrlOrThrow(request.url.toString());
else if (_jsConfig != null && !_jsConfig.isUrlAllowed(request.url.toString()))
throw ScriptImplementationException(_jsConfig, "Attempted to access non-whitelisted url: ${request.url.toString()}\nAdd it to your config");
return newBuilder?.let { it.build() } ?: request;
}
@@ -23,6 +23,7 @@ interface IJSContent: IPlatformContent {
ContentType.POST -> JSPost(config, obj);
ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj);
ContentType.PLAYLIST -> JSPlaylist(config, obj);
ContentType.LOCKED -> JSLockedContent(config, obj);
else -> throw NotImplementedError("Unknown content type ${type}");
}
}
@@ -12,10 +12,10 @@ import com.futo.platformplayer.getOrThrow
class JSChapter : IChapter {
override val name: String;
override val type: ChapterType;
override val timeStart: Int;
override val timeEnd: Int;
override val timeStart: Double;
override val timeEnd: Double;
constructor(name: String, timeStart: Int, timeEnd: Int, type: ChapterType = ChapterType.NORMAL) {
constructor(name: String, timeStart: Double, timeEnd: Double, type: ChapterType = ChapterType.NORMAL) {
this.name = name;
this.timeStart = timeStart;
this.timeEnd = timeEnd;
@@ -29,8 +29,8 @@ class JSChapter : IChapter {
val name = obj.getOrThrow<String>(config,"name", context);
val type = ChapterType.fromInt(obj.getOrDefault<Int>(config, "type", context, ChapterType.NORMAL.value) ?: ChapterType.NORMAL.value);
val timeStart = obj.getOrThrow<Int>(config, "timeStart", context);
val timeEnd = obj.getOrThrow<Int>(config, "timeEnd", context);
val timeStart = obj.getOrThrow<Double>(config, "timeStart", context);
val timeEnd = obj.getOrThrow<Double>(config, "timeEnd", context);
return JSChapter(name, timeStart, timeEnd, type);
}
@@ -0,0 +1,36 @@
package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.states.StatePlatform
//TODO: Refactor into video-only
class JSLockedContent: IPlatformLockedContent, JSContent {
override val contentType: ContentType get() = ContentType.LOCKED;
override val lockContentType: ContentType get() = ContentType.MEDIA;
override val lockDescription: String?;
override val unlockUrl: String?;
override val contentName: String?;
override val contentThumbnails: Thumbnails;
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
val contextName = "PlatformLockedContent";
this.contentName = obj.getOrDefault(config, "contentName", contextName, null);
this.contentThumbnails = obj.getOrDefault<V8ValueObject?>(config, "contentThumbnails", contextName, null)?.let {
return@let Thumbnails.fromV8(config, it);
} ?: Thumbnails();
lockDescription = obj.getOrDefault(config, "lockDescription", contextName, null);
unlockUrl = obj.getOrDefault(config, "unlockUrl", contextName, null);
}
}
@@ -59,8 +59,6 @@ abstract class JSPager<T> : IPager<T> {
}
override fun getResults(): List<T> {
warnIfMainThread("JSPager.getResults");
val previousResults = _lastResults?.let {
if(!_resultChanged)
return@let it;
@@ -70,6 +68,7 @@ abstract class JSPager<T> : IPager<T> {
if(previousResults != null)
return previousResults;
warnIfMainThread("JSPager.getResults");
val items = pager.getOrThrow<V8ValueArray>(config, "results", "JSPager");
val newResults = items.toArray()
.map { convertResult(it as V8ValueObject) }
@@ -52,7 +52,7 @@ class DedupContentPager : IPager<IPlatformContent>, IAsyncPager<IPlatformContent
val sameItems = results.filter { isSameItem(result, it) };
val platformItemMap = sameItems.groupBy { it.id.pluginId }.mapValues { (_, items) -> items.first() }
val bestPlatform = _preferredPlatform.map { it.lowercase() }.firstOrNull { platformItemMap.containsKey(it) }
val bestItem = platformItemMap[bestPlatform] ?: sameItems.first()
val bestItem = platformItemMap[bestPlatform] ?: sameItems.firstOrNull();
resultsToRemove.addAll(sameItems.filter { it != bestItem });
}
@@ -6,16 +6,13 @@ 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
@@ -23,15 +20,11 @@ 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
@@ -53,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;
@@ -76,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());
@@ -93,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 ->
@@ -110,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);
}
@@ -134,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) {
@@ -164,20 +151,4 @@ class BackgroundWorker(private val appContext: Context, private val workerParams
.setSilent(true)
.setChannelId(notificationChannel.id).build());*/
}
fun notifyNewContent(manager: NotificationManager, notificationChannel: NotificationChannel, id: Int, sub: Subscription, content: IPlatformContent, thumbnail: Bitmap? = null) {
val notifBuilder = NotificationCompat.Builder(appContext, notificationChannel.id)
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground)
.setContentTitle("New by [${sub.channel.name}]")
.setContentText("${content.name}")
.setSilent(true)
.setContentIntent(PendingIntent.getActivity(this.appContext, 0, MainActivity.getVideoIntent(this.appContext, content.url),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
.setChannelId(notificationChannel.id);
if(thumbnail != null) {
//notifBuilder.setLargeIcon(thumbnail);
notifBuilder.setStyle(NotificationCompat.BigPictureStyle().bigPicture(thumbnail).bigLargeIcon(null as Bitmap?));
}
manager.notify(id, notifBuilder.build());
}
}
@@ -1,37 +0,0 @@
package com.futo.platformplayer.builders
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import java.io.PrintWriter
import java.io.StringWriter
class HlsBuilder {
companion object{
fun generateOnDemandHLS(vidSource: IVideoSource, vidUrl: String, audioSource: IAudioSource?, audioUrl: String?, subtitleSource: ISubtitleSource?, subtitleUrl: String?): String {
val hlsBuilder = StringWriter()
PrintWriter(hlsBuilder).use { writer ->
writer.println("#EXTM3U")
// Audio
if (audioSource != null && audioUrl != null) {
val audioFormat = audioSource.container.substringAfter("/")
writer.println("#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audio\",LANGUAGE=\"en\",NAME=\"English\",AUTOSELECT=YES,DEFAULT=YES,URI=\"${audioUrl.replace("&", "&amp;")}\",FORMAT=\"$audioFormat\"")
}
// Subtitles
if (subtitleSource != null && subtitleUrl != null) {
val subtitleFormat = subtitleSource.format ?: "text/vtt"
writer.println("#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",LANGUAGE=\"en\",NAME=\"English\",AUTOSELECT=YES,DEFAULT=YES,URI=\"${subtitleUrl.replace("&", "&amp;")}\",FORMAT=\"$subtitleFormat\"")
}
// Video
val videoFormat = vidSource.container.substringAfter("/")
writer.println("#EXT-X-STREAM-INF:BANDWIDTH=100000,CODECS=\"${vidSource.codec}\",RESOLUTION=${vidSource.width}x${vidSource.height}${if (audioSource != null) ",AUDIO=\"audio\"" else ""}${if (subtitleSource != null) ",SUBTITLES=\"subs\"" else ""},FORMAT=\"$videoFormat\"")
writer.println(vidUrl.replace("&", "&amp;"))
}
return hlsBuilder.toString()
}
}
}
@@ -51,6 +51,22 @@ class ChannelContentCache {
Logger.i(TAG, "ChannelContentCache time: ${initializeTime}ms channels: ${allFiles.size}, videos: ${totalItems}, trimmed: ${trimmed}, total: ${totalItems - trimmed}");
}
fun clear() {
synchronized(_channelContents) {
for(channel in _channelContents)
for(content in channel.value.getItems())
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();
@@ -83,7 +99,7 @@ class ChannelContentCache {
val items = validStores.flatMap { it.getItems() }
.sortedByDescending { it.datetime };
return DedupContentPager(PlatformContentPager(items, Math.min(150, items.size)), StatePlatform.instance.getEnabledClients().map { it.id });
return DedupContentPager(PlatformContentPager(items, Math.min(30, items.size)), StatePlatform.instance.getEnabledClients().map { it.id });
}
fun uncacheContent(content: SerializedPlatformContent) {
@@ -69,7 +69,7 @@ class ChromecastCastingDevice : CastingDevice {
return;
}
Logger.i(FastCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)");
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)");
time = resumePosition;
_streamType = streamType;
@@ -4,6 +4,7 @@ import android.content.ContentResolver
import android.content.Context
import android.os.Looper
import com.futo.platformplayer.*
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.http.server.ManagedHttpServer
import com.futo.platformplayer.api.http.server.handlers.*
import com.futo.platformplayer.api.media.models.streams.sources.*
@@ -15,6 +16,7 @@ import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.*
import java.net.InetAddress
@@ -45,6 +47,7 @@ class StateCasting {
val onActiveDevicePlayChanged = Event1<Boolean>();
val onActiveDeviceTimeChanged = Event1<Double>();
var activeDevice: CastingDevice? = null;
private val _client = ManagedHttpClient();
val isCasting: Boolean get() = activeDevice != null;
@@ -331,20 +334,25 @@ class StateCasting {
}
if (sourceCount > 1) {
if (ad is AirPlayCastingDevice) {
StateApp.withContext(false) { context -> UIDialogs.toast(context, "AirPlay does not support DASH. Try ChromeCast or FastCast for casting this video."); };
ad.stopCasting();
return false;
}
if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) {
castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition);
if (ad is AirPlayCastingDevice) {
Logger.i(TAG, "Casting as local HLS");
castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition);
} else {
Logger.i(TAG, "Casting as local DASH");
castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition);
}
} else {
StateApp.instance.scope.launch(Dispatchers.IO) {
try {
if (ad is FastCastCastingDevice) {
Logger.i(TAG, "Casting as DASH direct");
castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition);
} else if (ad is AirPlayCastingDevice) {
Logger.i(TAG, "Casting as HLS indirect");
castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition);
} else {
Logger.i(TAG, "Casting as DASH indirect");
castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition);
}
} catch (e: Throwable) {
@@ -353,19 +361,35 @@ class StateCasting {
}
}
} else {
if (videoSource is IVideoUrlSource)
ad.loadVideo("BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble());
else if(videoSource is IHLSManifestSource)
ad.loadVideo("BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble());
else if (audioSource is IAudioUrlSource)
ad.loadVideo("BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble());
else if(audioSource is IHLSManifestAudioSource)
ad.loadVideo("BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble());
else if (videoSource is LocalVideoSource)
if (videoSource is IVideoUrlSource) {
Logger.i(TAG, "Casting as singular video");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble());
} else if (audioSource is IAudioUrlSource) {
Logger.i(TAG, "Casting as singular audio");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble());
} else if(videoSource is IHLSManifestSource) {
if (ad is ChromecastCastingDevice) {
Logger.i(TAG, "Casting as proxied HLS");
castProxiedHls(video, videoSource.url, resumePosition);
} else {
Logger.i(TAG, "Casting as non-proxied HLS");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble());
}
} else if(audioSource is IHLSManifestAudioSource) {
if (ad is ChromecastCastingDevice) {
Logger.i(TAG, "Casting as proxied audio HLS");
castProxiedHls(video, audioSource.url, resumePosition);
} else {
Logger.i(TAG, "Casting as non-proxied audio HLS");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble());
}
} else if (videoSource is LocalVideoSource) {
Logger.i(TAG, "Casting as local video");
castLocalVideo(video, videoSource, resumePosition);
else if (audioSource is LocalAudioSource)
} else if (audioSource is LocalAudioSource) {
Logger.i(TAG, "Casting as local audio");
castLocalAudio(video, audioSource, resumePosition);
else {
} else {
var str = listOf(
if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null,
if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null,
@@ -402,10 +426,18 @@ class StateCasting {
return true;
}
private fun castVideoIndirect() {
}
private fun castAudioIndirect() {
}
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double) : List<String> {
val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress}:${_castServer.port}";
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
val id = UUID.randomUUID();
val videoPath = "/video-${id}"
val videoUrl = url + videoPath;
@@ -424,7 +456,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;
@@ -440,11 +472,106 @@ class StateCasting {
return listOf(audioUrl);
}
private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double): List<String> {
val ad = activeDevice ?: return listOf()
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"
val id = UUID.randomUUID()
val hlsPath = "/hls-${id}"
val videoPath = "/video-${id}"
val audioPath = "/audio-${id}"
val subtitlePath = "/subtitle-${id}"
val hlsUrl = url + hlsPath
val videoUrl = url + videoPath
val audioUrl = url + audioPath
val subtitleUrl = url + subtitlePath
val mediaRenditions = arrayListOf<HLS.MediaRendition>()
val variantPlaylistReferences = arrayListOf<HLS.VariantPlaylistReference>()
if (videoSource != null) {
_castServer.addHandler(
HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath)
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castLocalHls")
val duration = videoSource.duration
val videoVariantPlaylistPath = "/video-playlist-${id}"
val videoVariantPlaylistUrl = url + videoVariantPlaylistPath
val videoVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), videoUrl))
val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, videoVariantPlaylistSegments)
_castServer.addHandler(
HttpConstantHandler("GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(),
"application/vnd.apple.mpegurl")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castLocalHls")
variantPlaylistReferences.add(HLS.VariantPlaylistReference(videoVariantPlaylistUrl, HLS.StreamInfo(
videoSource.bitrate, "${videoSource.width}x${videoSource.height}", videoSource.codec, null, null, if (audioSource != null) "audio" else null, if (subtitleSource != null) "subtitles" else null, null, null)))
}
if (audioSource != null) {
_castServer.addHandler(
HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath)
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castLocalHls")
val duration = audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown")
val audioVariantPlaylistPath = "/audio-playlist-${id}"
val audioVariantPlaylistUrl = url + audioVariantPlaylistPath
val audioVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), audioUrl))
val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, audioVariantPlaylistSegments)
_castServer.addHandler(
HttpConstantHandler("GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(),
"application/vnd.apple.mpegurl")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castLocalHls")
mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "en", "english", true, true, true))
}
if (subtitleSource != null) {
_castServer.addHandler(
HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath)
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castLocalHls")
val duration = videoSource?.duration ?: audioSource?.duration ?: throw Exception("Duration unknown")
val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}"
val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath
val subtitleVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), subtitleUrl))
val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, subtitleVariantPlaylistSegments)
_castServer.addHandler(
HttpConstantHandler("GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(),
"application/vnd.apple.mpegurl")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castLocalHls")
mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "en", "english", true, true, true))
}
val masterPlaylist = HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true)
_castServer.addHandler(
HttpConstantHandler("GET", hlsPath, masterPlaylist.buildM3U8(),
"application/vnd.apple.mpegurl")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castLocalHls")
Logger.i(TAG, "added new castLocalHls handlers (hlsPath: $hlsPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath).")
ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble())
return listOf(hlsUrl, videoUrl, audioUrl, subtitleUrl)
}
private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double) : List<String> {
val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress}:${_castServer.port}";
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
val id = UUID.randomUUID();
val dashPath = "/dash-${id}"
@@ -486,7 +613,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 +632,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,13 +674,277 @@ class StateCasting {
return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "");
}
private fun castProxiedHls(video: IPlatformVideoDetails, sourceUrl: String, resumePosition: Double): List<String> {
_castServer.removeAllHandlers("castProxiedHlsMaster")
val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
val id = UUID.randomUUID();
val hlsPath = "/hls-${id}"
val hlsUrl = url + hlsPath
Logger.i(TAG, "HLS url: $hlsUrl");
_castServer.addHandler(HttpFuntionHandler("GET", hlsPath) { masterContext ->
_castServer.removeAllHandlers("castProxiedHlsVariant")
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, video.isLive)
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant")
newVariantPlaylistRefs.add(HLS.VariantPlaylistReference(
newPlaylistUrl,
variantPlaylistRef.streamInfo
))
}
for (mediaRendition in masterPlaylist.mediaRenditions) {
val playlistId = UUID.randomUUID()
var newPlaylistUrl: String? = null
if (mediaRendition.uri != null) {
val newPlaylistPath = "/hls-playlist-${playlistId}"
newPlaylistUrl = url + newPlaylistPath
_castServer.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, video.isLive)
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant")
}
newMediaRenditions.add(HLS.MediaRendition(
mediaRendition.type,
newPlaylistUrl,
mediaRendition.groupID,
mediaRendition.language,
mediaRendition.name,
mediaRendition.isDefault,
mediaRendition.isAutoSelect,
mediaRendition.isForced
))
}
masterContext.respondCode(200, headers, newMasterPlaylist.buildM3U8());
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsMaster")
Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath).");
//ChromeCast is sometimes funky with resume position 0
val hackfixResumePosition = if (ad is ChromecastCastingDevice && !video.isLive && resumePosition == 0.0) 1.0 else resumePosition;
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, hackfixResumePosition, video.duration.toDouble());
return listOf(hlsUrl);
}
private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, isLive: Boolean, proxySegments: Boolean = true): HLS.VariantPlaylist {
val newSegments = arrayListOf<HLS.Segment>()
if (proxySegments) {
variantPlaylist.segments.forEachIndexed { index, segment ->
val sequenceNumber = variantPlaylist.mediaSequence + index.toLong()
newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber))
}
} else {
newSegments.addAll(variantPlaylist.segments)
}
return HLS.VariantPlaylist(
variantPlaylist.version,
variantPlaylist.targetDuration,
variantPlaylist.mediaSequence,
variantPlaylist.discontinuitySequence,
variantPlaylist.programDateTime,
variantPlaylist.playlistType,
newSegments
)
}
private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long): HLS.Segment {
if (segment is HLS.MediaSegment) {
val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}"
val newSegmentUrl = url + newSegmentPath;
if (_castServer.getHandler("GET", newSegmentPath) == null) {
_castServer.addHandler(
HttpProxyHandler("GET", newSegmentPath, segment.uri, true)
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castProxiedHlsVariant")
}
return HLS.MediaSegment(
segment.duration,
newSegmentUrl
)
} else {
return segment
}
}
private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> {
val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
val id = UUID.randomUUID();
val hlsPath = "/hls-${id}"
val hlsUrl = url + hlsPath;
Logger.i(TAG, "HLS url: $hlsUrl");
val mediaRenditions = arrayListOf<HLS.MediaRendition>()
val variantPlaylistReferences = arrayListOf<HLS.VariantPlaylistReference>()
if (audioSource != null) {
val audioPath = "/audio-${id}"
val audioUrl = url + audioPath
val duration = audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown")
val audioVariantPlaylistPath = "/audio-playlist-${id}"
val audioVariantPlaylistUrl = url + audioVariantPlaylistPath
val audioVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), audioUrl))
val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, audioVariantPlaylistSegments)
_castServer.addHandler(
HttpConstantHandler("GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(),
"application/vnd.apple.mpegurl")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
_castServer.addHandler(HttpOptionsAllowHandler(audioVariantPlaylistPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant");
mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "en", "english", true, true, true))
_castServer.addHandler(
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
_castServer.addHandler(HttpOptionsAllowHandler(audioPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant");
}
val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) {
return@withContext subtitleSource.getSubtitlesURI();
} else null;
var subtitlesUrl: String? = null;
if (subtitlesUri != null) {
val subtitlePath = "/subtitles-${id}"
if(subtitlesUri.scheme == "file") {
var content: String? = null;
val inputStream = contentResolver.openInputStream(subtitlesUri);
inputStream?.use { stream ->
val reader = stream.bufferedReader();
content = reader.use { it.readText() };
}
if (content != null) {
_castServer.addHandler(
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
_castServer.addHandler(HttpOptionsAllowHandler(subtitlePath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant");
}
subtitlesUrl = url + subtitlePath;
} else {
subtitlesUrl = subtitlesUri.toString();
}
}
if (subtitlesUrl != null) {
val duration = videoSource?.duration ?: audioSource?.duration ?: throw Exception("Duration unknown")
val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}"
val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath
val subtitleVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), subtitlesUrl))
val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, subtitleVariantPlaylistSegments)
_castServer.addHandler(
HttpConstantHandler("GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(),
"application/vnd.apple.mpegurl")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
_castServer.addHandler(HttpOptionsAllowHandler(subtitleVariantPlaylistPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant");
mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "en", "english", true, true, true))
}
if (videoSource != null) {
val videoPath = "/video-${id}"
val videoUrl = url + videoPath
val duration = videoSource.duration
val videoVariantPlaylistPath = "/video-playlist-${id}"
val videoVariantPlaylistUrl = url + videoVariantPlaylistPath
val videoVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), videoUrl))
val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, videoVariantPlaylistSegments)
_castServer.addHandler(
HttpConstantHandler("GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(),
"application/vnd.apple.mpegurl")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
_castServer.addHandler(HttpOptionsAllowHandler(videoVariantPlaylistPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant");
variantPlaylistReferences.add(HLS.VariantPlaylistReference(videoVariantPlaylistUrl, HLS.StreamInfo(
videoSource.bitrate ?: 0,
"${videoSource.width}x${videoSource.height}",
videoSource.codec,
null,
null,
if (audioSource != null) "audio" else null,
if (subtitleSource != null) "subtitles" else null,
null, null)))
_castServer.addHandler(
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
_castServer.addHandler(HttpOptionsAllowHandler(videoPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant");
}
val masterPlaylist = HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true)
_castServer.addHandler(
HttpConstantHandler("GET", hlsPath, masterPlaylist.buildM3U8(),
"application/vnd.apple.mpegurl")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectMaster")
_castServer.addHandler(HttpOptionsAllowHandler(hlsPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant");
Logger.i(TAG, "added new castHls handlers (hlsPath: $hlsPath).");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble());
return listOf(hlsUrl, videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
}
private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> {
val ad = activeDevice ?: return listOf();
val proxyStreams = ad !is FastCastCastingDevice;
val url = "http://${ad.localAddress}:${_castServer.port}";
Logger.i(TAG, "DASH url: $url");
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
val id = UUID.randomUUID();
val dashPath = "/dash-${id}"
@@ -562,6 +953,8 @@ class StateCasting {
val subtitlePath = "/subtitle-${id}"
val dashUrl = url + dashPath;
Logger.i(TAG, "DASH url: $dashUrl");
val videoUrl = if(proxyStreams) url + videoPath else videoSource?.getVideoUrl();
val audioUrl = if(proxyStreams) url + audioPath else audioSource?.getAudioUrl();
@@ -587,6 +980,7 @@ class StateCasting {
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
_castServer.addHandler(HttpOptionsAllowHandler(subtitlePath).withHeader("Access-Control-Allow-Origin", "*")).withTag("cast");
}
subtitlesUrl = url + subtitlePath;
@@ -600,33 +994,37 @@ class StateCasting {
"application/dash+xml")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
_castServer.addHandler(
HttpOptionsAllowHandler(dashPath)
.withHeader("Access-Control-Allow-Origin", "*")
).withTag("cast");
if (videoSource != null) {
_castServer.addHandler(
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl())
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
_castServer.addHandler(
HttpOptionsAllowHandler(videoPath)
.withHeader("Access-Control-Allow-Origin", "*")
.withHeader("Connection", "keep-alive"))
.withTag("cast");
).withTag("cast");
}
if (audioSource != null) {
_castServer.addHandler(
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl())
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
_castServer.addHandler(
HttpOptionsAllowHandler(audioPath)
.withHeader("Access-Control-Allow-Origin", "*")
.withHeader("Connection", "keep-alivcontexte"))
)
.withTag("cast");
}
Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath).");
ad.loadVideo("BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble());
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble());
return listOf(dashUrl, videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
}
@@ -24,6 +24,7 @@ import com.google.gson.JsonArray
import com.google.gson.JsonParser
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.lang.reflect.InvocationTargetException
import java.util.UUID
import kotlin.reflect.jvm.jvmErasure
@@ -185,7 +186,11 @@ class DeveloperEndpoints(private val context: Context) {
val config = context.readContentJson<SourcePluginConfig>()
try {
_testPluginVariables.clear();
_testPlugin = V8Plugin(StateApp.instance.context, config);
val client = JSHttpClient(null, null, null, config);
val clientAuth = JSHttpClient(null, null, null, config);
_testPlugin = V8Plugin(StateApp.instance.context, config, null, client, clientAuth);
context.respondJson(200, testPluginOrThrow.getPackageVariables());
}
catch(ex: Throwable) {
@@ -235,7 +240,7 @@ class DeveloperEndpoints(private val context: Context) {
}
LoginActivity.showLogin(StateApp.instance.context, config) {
_testPluginVariables.clear();
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null), JSHttpClient(null, it));
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
};
context.respondCode(200, "Login started");
@@ -287,7 +292,6 @@ class DeveloperEndpoints(private val context: Context) {
@HttpPOST("/plugin/remoteCall")
fun pluginRemoteCall(context: HttpContext) {
try {
val parameters = context.readContentString();
val objId = context.query.get("id")
val method = context.query.get("method")
@@ -299,16 +303,24 @@ class DeveloperEndpoints(private val context: Context) {
context.respondCode(400, "Missing method");
return;
}
if(method != "isLoggedIn")
Logger.i(TAG, "Remote Call [${objId}].${method}(...)");
val parameters = context.readContentString();
val remoteObj = getRemoteObject(objId);
val paras = JsonParser.parseString(parameters);
if(!paras.isJsonArray)
throw IllegalArgumentException("Expected json array as body");
if(method != "isLoggedIn")
Logger.i(TAG, "Remote Call [${objId}].${method}(...)");
val callResult = remoteObj.call(method, paras as JsonArray);
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);
}
}
@@ -1,13 +1,18 @@
package com.futo.platformplayer.downloads
import android.content.Context
import android.net.Uri
import android.os.Environment
import androidx.documentfile.provider.DocumentFile
import com.arthenica.ffmpegkit.*
import com.futo.platformplayer.api.media.models.streams.sources.*
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.toHumanBitrate
import kotlinx.coroutines.*
import java.io.*
import java.util.UUID
import java.util.concurrent.CancellationException
import java.util.concurrent.Executors
import kotlin.coroutines.resumeWithException
@@ -43,7 +48,7 @@ class VideoExport {
this.subtitleSource = subtitleSource;
}
suspend fun export(onProgress: ((Double) -> Unit)? = null): File = coroutineScope {
suspend fun export(context: Context, onProgress: ((Double) -> Unit)? = null): DocumentFile = coroutineScope {
if(isCancelled) throw CancellationException("Export got cancelled");
val v = videoSource;
@@ -55,34 +60,47 @@ class VideoExport {
if (a != null) sourceCount++;
if (s != null) sourceCount++;
var outputFile: File? = null;
val moviesRoot = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES);
val musicRoot = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC);
val moviesGrayjay = File(moviesRoot, "Grayjay");
val musicGrayjay = File(musicRoot, "Grayjay");
if(!moviesGrayjay.exists())
moviesGrayjay.mkdirs();
if(!musicGrayjay.exists())
musicGrayjay.mkdirs();
val outputFile: DocumentFile?;
val downloadRoot = StateApp.instance.getExternalDownloadDirectory(context) ?: throw Exception("External download directory is not set");
if (sourceCount > 1) {
val outputFileName = toSafeFileName(videoLocal.name) + ".mp4"// + VideoDownload.videoContainerToExtension(v.container);
val f = File(moviesGrayjay, outputFileName);
val f = downloadRoot.createFile("video/mp4", outputFileName)
?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Combining video and audio through FFMPEG.");
combine(a?.filePath, v?.filePath, s?.filePath, f.absolutePath, videoLocal.duration.toDouble()) { progress -> onProgress?.invoke(progress) };
val tempFile = File(context.cacheDir, "${UUID.randomUUID()}.mp4");
try {
combine(a?.filePath, v?.filePath, s?.filePath, tempFile.absolutePath, videoLocal.duration.toDouble()) { progress -> onProgress?.invoke(progress) };
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
copy(tempFile.absolutePath, outputStream) { progress -> onProgress?.invoke(progress) };
}
} finally {
tempFile.delete();
}
outputFile = f;
} else if (v != null) {
val outputFileName = toSafeFileName(videoLocal.name) + "." + VideoDownload.videoContainerToExtension(v.container);
val f = File(moviesGrayjay, outputFileName);
val f = downloadRoot.createFile(v.container, outputFileName)
?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying video.");
copy(v.filePath, f.absolutePath) { progress -> onProgress?.invoke(progress) };
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
copy(v.filePath, outputStream) { progress -> onProgress?.invoke(progress) };
}
outputFile = f;
} else if (a != null) {
val outputFileName = toSafeFileName(videoLocal.name) + "." + VideoDownload.audioContainerToExtension(a.container);
val f = File(musicGrayjay, outputFileName);
val f = downloadRoot.createFile(a.container, outputFileName)
?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying audio.");
copy(a.filePath, f.absolutePath) { progress -> onProgress?.invoke(progress) };
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
copy(a.filePath, outputStream) { progress -> onProgress?.invoke(progress) };
}
outputFile = f;
} else {
throw Exception("Cannot export when no audio or video source is set.");
@@ -179,10 +197,9 @@ class VideoExport {
}
}
private suspend fun copy(fromPath: String, toPath: String, bufferSize: Int = 8192, onProgress: ((Double) -> Unit)? = null) {
private suspend fun copy(fromPath: String, outputStream: OutputStream, bufferSize: Int = 8192, onProgress: ((Double) -> Unit)? = null) {
withContext(Dispatchers.IO) {
var inputStream: FileInputStream? = null
var outputStream: FileOutputStream? = null
try {
val srcFile = File(fromPath)
@@ -190,17 +207,7 @@ class VideoExport {
throw IOException("Source file not found.")
}
val dstFile = File(toPath)
val parentDir = dstFile.parentFile ?: throw IOException("Non existent parent dir.")
if (!parentDir.exists()) {
if (!parentDir.mkdirs()) {
throw IOException("Failed to create destination directory.")
}
}
inputStream = FileInputStream(srcFile)
outputStream = FileOutputStream(dstFile)
val buffer = ByteArray(bufferSize)
val totalBytes = srcFile.length()
@@ -221,7 +228,6 @@ class VideoExport {
throw IOException("Error occurred while copying file: ${e.message}", e)
} finally {
inputStream?.close()
outputStream?.close()
}
}
}
@@ -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,9 +55,11 @@ 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>();
val onLongPress = Event1<IPlatformContent>();
private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> {
Logger.i(TAG, "getContentPager");
@@ -75,15 +76,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,12 +153,14 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
_recyclerResults = view.findViewById(R.id.recycler_videos);
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results).apply {
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar).apply {
this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit);
this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit);
this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit);
this.onChannelClicked.subscribe(this@ChannelContentsFragment.onChannelClicked::emit);
this.onAddToClicked.subscribe(this@ChannelContentsFragment.onAddToClicked::emit);
this.onAddToQueueClicked.subscribe(this@ChannelContentsFragment.onAddToQueueClicked::emit);
this.onLongPress.subscribe(this@ChannelContentsFragment.onLongPress::emit);
}
_llmVideo = LinearLayoutManager(view.context);
@@ -1,22 +1,20 @@
package com.futo.platformplayer.fragment.channel.tab
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.Fragment
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.views.SupportView
import com.futo.platformplayer.views.buttons.BigButton
class ChannelMonetizationFragment : Fragment, IChannelTabFragment {
private var _supportView: SupportView? = null
private var _textMonetization: TextView? = null
private var _lastChannel: IPlatformChannel? = null;
private var _lastPolycentricProfile: PolycentricProfile? = null;
@@ -26,21 +24,22 @@ class ChannelMonetizationFragment : Fragment, IChannelTabFragment {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_channel_monetization, container, false);
_supportView = view.findViewById(R.id.support);
_textMonetization = view.findViewById(R.id.text_monetization);
_lastChannel?.also {
setChannel(it);
};
_lastPolycentricProfile?.also {
setPolycentricProfile(it, animate = false);
}
_supportView?.visibility = View.GONE;
_textMonetization?.visibility = View.GONE;
setPolycentricProfile(_lastPolycentricProfile, animate = false);
return view;
}
override fun onDestroyView() {
super.onDestroyView();
_supportView = null;
_textMonetization = null;
}
override fun setChannel(channel: IPlatformChannel) {
@@ -49,7 +48,15 @@ class ChannelMonetizationFragment : Fragment, IChannelTabFragment {
fun setPolycentricProfile(polycentricProfile: PolycentricProfile?, animate: Boolean) {
_lastPolycentricProfile = polycentricProfile
_supportView?.setPolycentricProfile(polycentricProfile, animate)
if (polycentricProfile != null) {
_supportView?.setPolycentricProfile(polycentricProfile, animate)
_supportView?.visibility = View.VISIBLE
_textMonetization?.visibility = View.GONE
} else {
_supportView?.setPolycentricProfile(null, animate)
_supportView?.visibility = View.GONE
_textMonetization?.visibility = View.VISIBLE
}
}
companion object {
@@ -91,7 +91,7 @@ class BuyFragment : MainFragment() {
val price = prices[currency.id]!!;
val priceDecimal = (price.toDouble() / 100);
withContext(Dispatchers.Main) {
_buttonBuyText.text = currency.symbol + String.format("%.2f", priceDecimal);
_buttonBuyText.text = currency.symbol + String.format("%.2f", priceDecimal) + context.getString(R.string.plus_tax);
}
}
}
@@ -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 -> {
@@ -220,6 +223,12 @@ class ChannelFragment : MainFragment() {
else -> {};
}
}
adapter.onLongPress.subscribe { content ->
_overlayContainer.let {
if(content is IPlatformVideo)
_slideUpOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it);
}
}
viewPager.adapter = adapter;
val tabLayoutMediator = TabLayoutMediator(tabs, viewPager) { tab, position ->
@@ -1,6 +1,5 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import android.view.LayoutInflater
@@ -17,16 +16,16 @@ import com.futo.platformplayer.api.media.structures.*
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.video.PlayerManager
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.PreviewContentListAdapter
import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.InsertedViewHolder
import com.futo.platformplayer.views.adapters.PreviewNestedVideoViewHolder
import com.futo.platformplayer.views.adapters.PreviewVideoViewHolder
import com.futo.platformplayer.views.adapters.feedtypes.PreviewNestedVideoViewHolder
import com.futo.platformplayer.views.adapters.feedtypes.PreviewVideoViewHolder
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import kotlin.math.floor
abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent, IPlatformContent, IPager<IPlatformContent>, ContentPreviewViewHolder> where TFragment : MainFragment {
@@ -37,6 +36,8 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
private var _previewsEnabled: Boolean = true;
override val visibleThreshold: Int get() = if (feedStyle == FeedStyle.PREVIEW) { 5 } else { 10 };
protected lateinit var headerView: LinearLayout;
private var _videoOptionsOverlay: SlideUpMenuOverlay? = null;
protected open val shouldShowTimeBar: Boolean get() = true
constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
@@ -57,39 +58,22 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
};
headerView = v;
return PreviewContentListAdapter(context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(v)).apply {
return PreviewContentListAdapter(context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(v), arrayListOf(), shouldShowTimeBar).apply {
attachAdapterEvents(this);
}
}
private fun attachAdapterEvents(adapter: PreviewContentListAdapter) {
adapter.onContentUrlClicked.subscribe(this, this@ContentFeedView::onContentUrlClicked);
adapter.onUrlClicked.subscribe(this, this@ContentFeedView::onUrlClicked);
adapter.onContentClicked.subscribe(this) { content, time ->
this@ContentFeedView.onContentClicked(content, time);
};
adapter.onChannelClicked.subscribe(this) { fragment.navigate<ChannelFragment>(it) };
adapter.onAddToClicked.subscribe(this) { content ->
//TODO: Reconstruct search video from detail if search is null
_overlayContainer.let {
if(content is IPlatformVideo)
UISlideOverlays.showVideoOptionsOverlay(content, it, SlideUpMenuItem(context, R.drawable.ic_visibility_off, context.getString(R.string.hide), context.getString(R.string.hide_from_home), "hide",
{ StateMeta.instance.addHiddenVideo(content.url);
if (fragment is HomeFragment) {
val removeIndex = recyclerData.results.indexOf(content);
if (removeIndex >= 0) {
recyclerData.results.removeAt(removeIndex);
recyclerData.adapter.notifyItemRemoved(recyclerData.adapter.childToParentPosition(removeIndex));
}
}
}),
SlideUpMenuItem(context, R.drawable.ic_playlist, context.getString(R.string.play_feed_as_queue), context.getString(R.string.play_entire_feed), "playFeed",
{
val newQueue = listOf(content) + recyclerData.results
.filterIsInstance<IPlatformVideo>()
.filter { it != content };
StatePlayer.instance.setQueue(newQueue, StatePlayer.TYPE_QUEUE, "Feed Queue", true, false);
})
);
if(content is IPlatformVideo) {
showVideoOptionsOverlay(content)
}
};
adapter.onAddToQueueClicked.subscribe(this) {
@@ -99,15 +83,61 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
UIDialogs.toast(context, context.getString(R.string.queued) + " [$name]", false);
}
};
adapter.onLongPress.subscribe(this) {
if (it is IPlatformVideo) {
showVideoOptionsOverlay(it)
}
};
}
fun onBackPressed(): Boolean {
val videoOptionsOverlay = _videoOptionsOverlay
if (videoOptionsOverlay != null) {
if (videoOptionsOverlay.isVisible) {
videoOptionsOverlay.hide();
_videoOptionsOverlay = null
return true;
}
_videoOptionsOverlay = null
return false
}
return false
}
private fun showVideoOptionsOverlay(content: IPlatformVideo) {
_overlayContainer.let {
_videoOptionsOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it, SlideUpMenuItem(context, R.drawable.ic_visibility_off, context.getString(R.string.hide), context.getString(R.string.hide_from_home), "hide",
{ StateMeta.instance.addHiddenVideo(content.url);
if (fragment is HomeFragment) {
val removeIndex = recyclerData.results.indexOf(content);
if (removeIndex >= 0) {
recyclerData.results.removeAt(removeIndex);
recyclerData.adapter.notifyItemRemoved(recyclerData.adapter.childToParentPosition(removeIndex));
}
}
}),
SlideUpMenuItem(context, R.drawable.ic_playlist, context.getString(R.string.play_feed_as_queue), context.getString(R.string.play_entire_feed), "playFeed",
{
val newQueue = listOf(content) + recyclerData.results
.filterIsInstance<IPlatformVideo>()
.filter { it != content };
StatePlayer.instance.setQueue(newQueue, StatePlayer.TYPE_QUEUE, "Feed Queue", true, false);
})
);
}
}
private fun detachAdapterEvents() {
val adapter = recyclerData.adapter as PreviewContentListAdapter? ?: return;
adapter.onContentUrlClicked.remove(this);
adapter.onUrlClicked.remove(this);
adapter.onContentClicked.remove(this);
adapter.onChannelClicked.remove(this);
adapter.onAddToClicked.remove(this);
adapter.onAddToQueueClicked.remove(this);
adapter.onLongPress.remove(this);
}
override fun onRestoreCachedData(cachedData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>) {
@@ -137,11 +167,14 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
protected open fun onContentClicked(content: IPlatformContent, time: Long) {
if(content is IPlatformVideo) {
StatePlayer.instance.clearQueue();
if (Settings.instance.playback.shouldResumePreview(time))
fragment.navigate<VideoDetailFragment>(content.withTimestamp(time)).maximizeVideoDetail();
else
fragment.navigate<VideoDetailFragment>(content).maximizeVideoDetail();
if (StatePlayer.instance.hasQueue) {
StatePlayer.instance.addToQueue(content)
} else {
if (Settings.instance.playback.shouldResumePreview(time))
fragment.navigate<VideoDetailFragment>(content.withTimestamp(time)).maximizeVideoDetail();
else
fragment.navigate<VideoDetailFragment>(content).maximizeVideoDetail();
}
} else if (content is IPlatformPlaylist) {
fragment.navigate<PlaylistFragment>(content);
} else if (content is IPlatformPost) {
@@ -159,6 +192,9 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
else -> {};
}
}
protected open fun onUrlClicked(url: String) {
fragment.navigate<BrowserFragment>(url);
}
private fun playPreview() {
if(feedStyle == FeedStyle.THUMBNAIL)
@@ -62,6 +62,13 @@ class ContentSearchResultsFragment : MainFragment() {
_view = null;
}
override fun onBackPressed(): Boolean {
if (_view?.onBackPressed() == true)
return true
return super.onBackPressed()
}
fun setPreviewsEnabled(previewsEnabled: Boolean) {
_view?.setPreviewsEnabled(previewsEnabled && Settings.instance.search.previewFeedItems);
}
@@ -77,6 +84,7 @@ class ContentSearchResultsFragment : MainFragment() {
private var _channelUrl: String? = null;
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
override val shouldShowTimeBar: Boolean get() = Settings.instance.search.progressBar
constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) {
_taskSearch = TaskHandler<String, IPager<IPlatformContent>>({fragment.lifecycleScope}, { query ->
@@ -66,6 +66,13 @@ class HomeFragment : MainFragment() {
return view;
}
override fun onBackPressed(): Boolean {
if (_view?.onBackPressed() == true)
return true
return super.onBackPressed()
}
override fun onDestroyMainView() {
super.onDestroyMainView();
@@ -88,6 +95,7 @@ class HomeFragment : MainFragment() {
private var _announcementsView: AnnouncementView;
private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>;
override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
_announcementsView = AnnouncementView(context, null).apply {
@@ -152,7 +160,7 @@ class HomeFragment : MainFragment() {
}
override fun filterResults(contents: List<IPlatformContent>): List<IPlatformContent> {
return contents.filter { it !is IPlatformVideo || !StateMeta.instance.isVideoHidden(it.url) };
return contents.filter { !StateMeta.instance.isVideoHidden(it.url) && !StateMeta.instance.isCreatorHidden(it.author.url) };
}
private fun loadResults() {
@@ -44,6 +44,13 @@ class PlaylistSearchResultsFragment : MainFragment() {
return view;
}
override fun onBackPressed(): Boolean {
if (_view?.onBackPressed() == true)
return true
return super.onBackPressed()
}
override fun onDestroyMainView() {
super.onDestroyMainView();
_view?.cleanup();
@@ -17,6 +17,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.R
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.assume
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
@@ -54,6 +55,14 @@ class PlaylistsFragment : MainFragment() {
_view?.onShown(parameter, isBack);
}
override fun onBackPressed(): Boolean {
if (_view?.onBackPressed() == true) {
return true;
}
return super.onBackPressed()
}
@SuppressLint("ViewConstructor")
class PlaylistsView : LinearLayout {
private val _fragment: PlaylistsFragment;
@@ -64,6 +73,7 @@ class PlaylistsFragment : MainFragment() {
private var _adapterWatchLater: VideoListHorizontalAdapter;
private var _adapterPlaylist: PlaylistsAdapter;
private var _layoutWatchlist: ConstraintLayout;
private var _slideUpOverlay: SlideUpMenuOverlay? = null;
constructor(fragment: PlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) {
_fragment = fragment;
@@ -92,41 +102,24 @@ class PlaylistsFragment : MainFragment() {
recyclerPlaylists.adapter = _adapterPlaylist;
recyclerPlaylists.layoutManager = LinearLayoutManager(context);
val nameInput = SlideUpMenuTextInput(context, context.getString(R.string.name));
val addPlaylistOverlay = SlideUpMenuOverlay(context, findViewById<FrameLayout>(R.id.overlay_create_playlist), context.getString(R.string.create_new_playlist), context.getString(R.string.ok), false, nameInput);
val buttonCreatePlaylist = findViewById<ImageButton>(R.id.button_create_playlist);
buttonCreatePlaylist.setOnClickListener {
_slideUpOverlay = UISlideOverlays.showCreatePlaylistOverlay(findViewById<FrameLayout>(R.id.overlay_create_playlist)) {
val playlist = Playlist(it, arrayListOf());
playlists.add(0, playlist);
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
_adapterPlaylist.notifyItemInserted(0);
};
};
_adapterPlaylist.onClick.subscribe { p -> _fragment.navigate<PlaylistFragment>(p); };
_adapterPlaylist.onPlay.subscribe { p ->
StatePlayer.instance.setPlaylist(p, 0, true);
};
addPlaylistOverlay.onOK.subscribe {
val text = nameInput.text;
if (text.isBlank()) {
return@subscribe;
}
val playlist = Playlist(text, arrayListOf());
playlists.add(0, playlist);
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
_adapterPlaylist.notifyItemInserted(0);
addPlaylistOverlay.hide();
nameInput.deactivate();
nameInput.clear();
};
addPlaylistOverlay.onCancel.subscribe {
nameInput.deactivate();
nameInput.clear();
};
val buttonCreatePlaylist = findViewById<ImageButton>(R.id.button_create_playlist);
buttonCreatePlaylist.setOnClickListener {
addPlaylistOverlay.show();
nameInput.activate();
};
_appBar = findViewById(R.id.app_bar);
_layoutWatchlist = findViewById(R.id.layout_watchlist);
@@ -142,12 +135,28 @@ class PlaylistsFragment : MainFragment() {
fun onShown(parameter: Any?, isBack: Boolean) {
playlists.clear()
playlists.addAll(StatePlaylists.instance.getPlaylists().sortedByDescending { maxOf(it.datePlayed, it.dateUpdate, it.dateCreation) });
playlists.addAll(
StatePlaylists.instance.getPlaylists()
.sortedByDescending { maxOf(it.datePlayed, it.dateUpdate, it.dateCreation) });
_adapterPlaylist.notifyDataSetChanged();
updateWatchLater();
}
fun onBackPressed(): Boolean {
val slideUpOverlay = _slideUpOverlay;
if (slideUpOverlay != null) {
if (slideUpOverlay.isVisible) {
slideUpOverlay.hide();
return true;
}
return false;
}
return false;
}
private fun updateWatchLater() {
val watchList = StatePlaylists.instance.getWatchLater();
if (watchList.isNotEmpty()) {
@@ -20,7 +20,6 @@ import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
@@ -32,6 +31,7 @@ import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp
import com.futo.platformplayer.fixHtmlWhitespace
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
@@ -46,7 +46,7 @@ import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.platformplayer.views.others.Toggle
import com.futo.platformplayer.views.adapters.PreviewPostView
import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView
import com.futo.platformplayer.views.overlays.RepliesOverlay
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
import com.futo.polycentric.core.ApiMethods
@@ -364,7 +364,7 @@ class PostDetailFragment : MainFragment {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
Logger.i(TAG, "Started backfill");
args.processHandle.fullyBackfillServers();
args.processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e)
@@ -80,12 +80,21 @@ class SubscriptionsFeedFragment : MainFragment() {
}
}
override fun onBackPressed(): Boolean {
if (_view?.onBackPressed() == true)
return true
return super.onBackPressed()
}
fun setPreviewsEnabled(previewsEnabled: Boolean) {
_view?.setPreviewsEnabled(previewsEnabled && Settings.instance.subscriptions.previewFeedItems);
}
@SuppressLint("ViewConstructor")
class SubscriptionsFeedView : ContentFeedView<SubscriptionsFeedFragment> {
override val shouldShowTimeBar: Boolean get() = Settings.instance.subscriptions.progressBar
constructor(fragment: SubscriptionsFeedFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
Logger.i(TAG, "SubscriptionsFeedFragment constructor()");
StateSubscriptions.instance.onGlobalSubscriptionsUpdateProgress.subscribe(this) { progress, total ->
@@ -121,7 +130,7 @@ class SubscriptionsFeedFragment : MainFragment() {
recyclerData.lastLoad.getNowDiffSeconds() > 60 ) {
recyclerData.lastLoad = OffsetDateTime.now();
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5)
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5 && Settings.instance.subscriptions.fetchOnTabOpen)
loadResults(false);
else if(recyclerData.results.size == 0)
loadCache();
@@ -193,7 +202,15 @@ class SubscriptionsFeedFragment : MainFragment() {
return@TaskHandler resp;
})
.success { loadedResult(it); }
.success {
if(!Settings.instance.subscriptions.alwaysReloadFromCache)
loadedResult(it);
else {
finishRefreshLayoutLoader();
setLoading(false);
loadCache();
}
} //TODO: Remove
.exception<RateLimitException> {
fragment.lifecycleScope.launch(Dispatchers.IO) {
val subs = StateSubscriptions.instance.getSubscriptions();
@@ -256,13 +273,16 @@ class SubscriptionsFeedFragment : MainFragment() {
else null;
_filterSettings.save();
};
loadResults(false)
if(Settings.instance.subscriptions.fetchOnTabOpen) //TODO: Do this different, temporary workaround
loadResults(false);
else
loadCache();
}
override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
val nowSoon = OffsetDateTime.now().plusMinutes(5);
return results.filter {
val allowedContentType = _filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO) ContentType.MEDIA else it.contentType);
val allowedContentType = _filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType);
if(it.datetime?.isAfter(nowSoon) == true) {
if(!_filterSettings.allowPlanned)
@@ -68,8 +68,6 @@ class VideoDetailFragment : MainFragment {
super.onShownWithView(parameter, isBack);
Logger.i(TAG, "onShownWithView parameter=$parameter")
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
if(parameter is IPlatformVideoDetails)
_viewDetail?.setVideoDetails(parameter, true);
else if (parameter is IPlatformVideo)
@@ -105,6 +103,8 @@ class VideoDetailFragment : MainFragment {
return;
}
if(Settings.instance.other.bypassRotationPrevention && orientation == OrientationManager.Orientation.PORTRAIT)
changeOrientation(OrientationManager.Orientation.PORTRAIT);
if(lastOrientation == newOrientation)
return;
@@ -174,7 +174,6 @@ class VideoDetailFragment : MainFragment {
_viewDetail?.onStop();
close();
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
StatePlayer.instance.clearQueue();
StatePlayer.instance.setPlayerClosed();
}
@@ -22,6 +22,7 @@ import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.view.WindowManager
import android.widget.*
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.lifecycleScope
@@ -59,6 +60,7 @@ import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.downloads.VideoLocal
import com.futo.platformplayer.engine.exceptions.ScriptAgeException
import com.futo.platformplayer.engine.exceptions.ScriptException
@@ -122,6 +124,7 @@ class VideoDetailView : ConstraintLayout {
private var _searchVideo: IPlatformVideo? = null;
var video: IPlatformVideoDetails? = null
private set;
var videoLocal: VideoLocal? = null;
private var _playbackTracker: IPlaybackTracker? = null;
val currentUrl get() = video?.url ?: _searchVideo?.url ?: _url;
@@ -216,6 +219,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 +425,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 +606,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 +623,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());
}
}
}
@@ -1037,10 +1045,32 @@ class VideoDetailView : ConstraintLayout {
_player.setPlaybackRate(Settings.instance.playback.getDefaultPlaybackSpeed());
}
val video = if(videoDetail is VideoLocal)
videoDetail;
else //TODO: Update cached video if it exists with video
StateDownloads.instance.getCachedVideo(videoDetail.id) ?: videoDetail;
var videoLocal: VideoLocal? = null;
var video: IPlatformVideoDetails? = null;
if(videoDetail is VideoLocal) {
videoLocal = videoDetail;
video = videoDetail;
val videoTask = StatePlatform.instance.getContentDetails(videoDetail.url);
videoTask.invokeOnCompletion { ex ->
if(ex != null) {
Logger.e(TAG, "Failed to fetch live video for offline video", ex);
return@invokeOnCompletion;
}
val result = videoTask.getCompleted();
if(this.video == videoDetail && result != null && result is IPlatformVideoDetails) {
this.video = result;
fragment.lifecycleScope.launch(Dispatchers.Main) {
updateQualitySourcesOverlay(result, videoLocal);
}
}
};
}
else { //TODO: Update cached video if it exists with video
videoLocal = StateDownloads.instance.getCachedVideo(videoDetail.id);
video = videoDetail;
}
this.videoLocal = videoLocal;
this.video = video;
this._playbackTracker = null;
@@ -1075,9 +1105,13 @@ class VideoDetailView : ConstraintLayout {
me._playbackTracker = tracker;
}
catch(ex: Throwable) {
withContext(Dispatchers.Main) {
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_get_playback_tracker), ex);
Logger.e(TAG, "Playback tracker failed", ex);
if(me.video?.isLive == true) withContext(Dispatchers.Main) {
UIDialogs.toast(context, context.getString(R.string.failed_to_get_playback_tracker));
};
else withContext(Dispatchers.Main) {
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_get_playback_tracker), ex);
}
}
};
}
@@ -1087,7 +1121,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
@@ -1101,9 +1135,12 @@ class VideoDetailView : ConstraintLayout {
(_channelName.layoutParams as MarginLayoutParams).setMargins(0, (DP_2).toInt(), 0, 0);
}
video.author.let {
if(it is PlatformAuthorMembershipLink && !it.membershipUrl.isNullOrEmpty())
_monetization.setPlatformMembership(video.id.pluginId, it.membershipUrl);
else
_monetization.setPlatformMembership(null, null);
}
_minimize_title.text = video.name;
@@ -1171,7 +1208,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)
@@ -1225,7 +1262,7 @@ class VideoDetailView : ConstraintLayout {
//Overlay
updateQualitySourcesOverlay(video);
updateQualitySourcesOverlay(video, videoLocal);
setLoading(false);
@@ -1476,6 +1513,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() {
@@ -1503,9 +1541,9 @@ class VideoDetailView : ConstraintLayout {
//Quality Selector data
private fun updateQualityFormatsOverlay(liveStreamVideoFormats : List<Format>?, liveStreamAudioFormats : List<Format>?) {
val v = video ?: return;
updateQualitySourcesOverlay(v, liveStreamVideoFormats, liveStreamAudioFormats);
updateQualitySourcesOverlay(v, videoLocal, liveStreamVideoFormats, liveStreamAudioFormats);
}
private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, liveStreamVideoFormats: List<Format>? = null, liveStreamAudioFormats: List<Format>? = null) {
private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, videoLocal: VideoLocal? = null, liveStreamVideoFormats: List<Format>? = null, liveStreamAudioFormats: List<Format>? = null) {
Logger.i(TAG, "updateQualitySourcesOverlay");
val video: IPlatformVideoDetails?;
@@ -1513,24 +1551,35 @@ class VideoDetailView : ConstraintLayout {
val localAudioSource: List<LocalAudioSource>?;
val localSubtitleSources: List<LocalSubtitleSource>?;
val videoSources: List<IVideoSource>?;
val audioSources: List<IAudioSource>?;
if(videoDetails is VideoLocal) {
video = videoDetails.videoSerialized;
video = videoLocal?.videoSerialized;
localVideoSources = videoDetails.videoSource.toList();
localAudioSource = videoDetails.audioSource.toList();
localSubtitleSources = videoDetails.subtitlesSources.toList();
videoSources = null
audioSources = null;
}
else {
video = videoDetails;
localVideoSources = null;
localAudioSource = null;
localSubtitleSources = null;
videoSources = video?.video?.videoSources?.toList();
audioSources = if(video?.video?.isUnMuxed == true)
(video.video as VideoUnMuxedSourceDescriptor).audioSources.toList()
else null
if(videoLocal != null) {
localVideoSources = videoLocal.videoSource.toList();
localAudioSource = videoLocal.audioSource.toList();
localSubtitleSources = videoLocal.subtitlesSources.toList();
}
else {
localVideoSources = null;
localAudioSource = null;
localSubtitleSources = null;
}
}
val videoSources = video?.video?.videoSources?.toList();
val audioSources = if(video?.video?.isUnMuxed == true)
(video.video as VideoUnMuxedSourceDescriptor).audioSources.toList()
else null
val bestVideoSources = videoSources?.map { it.height * it.width }
?.distinct()
?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
@@ -1558,7 +1607,7 @@ class VideoDetailView : ConstraintLayout {
if(localVideoSources?.isNotEmpty() == true)
SlideUpMenuGroup(this.context, context.getString(R.string.offline_video), "video",
*localVideoSources.stream()
*localVideoSources
.map {
SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, "${it.width}x${it.height}", it,
{ handleSelectVideoTrack(it) });
@@ -1566,7 +1615,7 @@ class VideoDetailView : ConstraintLayout {
else null,
if(localAudioSource?.isNotEmpty() == true)
SlideUpMenuGroup(this.context, context.getString(R.string.offline_audio), "audio",
*localAudioSource.stream()
*localAudioSource
.map {
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it,
{ handleSelectAudioTrack(it) });
@@ -1582,7 +1631,7 @@ class VideoDetailView : ConstraintLayout {
else null,
if(liveStreamVideoFormats?.isEmpty() == false)
SlideUpMenuGroup(this.context, context.getString(R.string.stream_video), "video",
*liveStreamVideoFormats.stream()
*liveStreamVideoFormats
.map {
SlideUpMenuItem(this.context, R.drawable.ic_movie, it?.label ?: it.containerMimeType ?: it.bitrate.toString(), "${it.width}x${it.height}", it,
{ _player.selectVideoTrack(it.height) });
@@ -1590,7 +1639,7 @@ class VideoDetailView : ConstraintLayout {
else null,
if(liveStreamAudioFormats?.isEmpty() == false)
SlideUpMenuGroup(this.context, context.getString(R.string.stream_audio), "audio",
*liveStreamAudioFormats.stream()
*liveStreamAudioFormats
.map {
SlideUpMenuItem(this.context, R.drawable.ic_music, "${it?.label ?: it.containerMimeType} ${it.bitrate}", "", it,
{ _player.selectAudioTrack(it.bitrate) });
@@ -1599,7 +1648,7 @@ class VideoDetailView : ConstraintLayout {
if(bestVideoSources.isNotEmpty())
SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
*bestVideoSources.stream()
*bestVideoSources
.map {
SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", it,
{ handleSelectVideoTrack(it) });
@@ -1607,7 +1656,7 @@ class VideoDetailView : ConstraintLayout {
else null,
if(bestAudioSources.isNotEmpty())
SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
*bestAudioSources.stream()
*bestAudioSources
.map {
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it,
{ handleSelectAudioTrack(it) });
@@ -1677,14 +1726,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) {
@@ -1816,7 +1879,7 @@ class VideoDetailView : ConstraintLayout {
private fun setCastEnabled(isCasting: Boolean) {
Logger.i(TAG, "setCastEnabled(isCasting=$isCasting)")
video?.let { updateQualitySourcesOverlay(it); };
video?.let { updateQualitySourcesOverlay(it, videoLocal); };
_isCasting = isCasting;
@@ -2028,7 +2091,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) {
@@ -125,6 +125,14 @@ class Subscription {
lastVideo = OffsetDateTime.MIN;
lastVideoUpdate = OffsetDateTime.now();
}
ResultCapabilities.TYPE_SUBSCRIPTIONS -> {
uploadInterval = interval;
if(mostRecent != null)
lastVideo = mostRecent;
else if(lastVideo.year > 3000)
lastVideo = OffsetDateTime.MIN;
lastVideoUpdate = OffsetDateTime.now();
}
ResultCapabilities.TYPE_STREAMS -> {
uploadStreamInterval = interval;
if(mostRecent != null)
@@ -0,0 +1,342 @@
package com.futo.platformplayer.parsers
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.toYesNo
import com.futo.platformplayer.yesNoToBoolean
import java.net.URI
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
class HLS {
companion object {
fun 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 = URI(sourceUrl).resolve("./").toString()
val variantPlaylists = mutableListOf<VariantPlaylistReference>()
val mediaRenditions = mutableListOf<MediaRendition>()
val sessionDataList = mutableListOf<SessionData>()
var independentSegments = false
masterPlaylistContent.lines().forEachIndexed { index, line ->
when {
line.startsWith("#EXT-X-STREAM-INF") -> {
val nextLine = masterPlaylistContent.lines().getOrNull(index + 1)
?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none")
val url = resolveUrl(baseUrl, nextLine)
variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line)))
}
line.startsWith("#EXT-X-MEDIA") -> {
mediaRenditions.add(parseMediaRendition(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 playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":")
val segments = mutableListOf<Segment>()
var currentSegment: MediaSegment? = null
lines.forEach { line ->
when {
line.startsWith("#EXTINF:") -> {
val duration = line.substringAfter(":").substringBefore(",").toDoubleOrNull()
?: throw Exception("Invalid segment duration format")
currentSegment = MediaSegment(duration = duration)
}
line == "#EXT-X-DISCONTINUITY" -> {
segments.add(DiscontinuitySegment())
}
line =="#EXT-X-ENDLIST" -> {
segments.add(EndListSegment())
}
else -> {
currentSegment?.let {
it.uri = resolveUrl(sourceUrl, line)
segments.add(it)
}
currentSegment = null
}
}
}
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, segments)
}
private fun resolveUrl(baseUrl: String, url: String): String {
val baseUri = URI(baseUrl)
val urlUri = URI(url)
return if (urlUri.isAbsolute) {
url
} else {
val resolvedUri = baseUri.resolve(urlUri)
resolvedUri.toString()
}
}
private fun parseStreamInfo(content: String): StreamInfo {
val attributes = parseAttributes(content)
return StreamInfo(
bandwidth = attributes["BANDWIDTH"]?.toIntOrNull(),
resolution = attributes["RESOLUTION"],
codecs = attributes["CODECS"],
frameRate = attributes["FRAME-RATE"],
videoRange = attributes["VIDEO-RANGE"],
audio = attributes["AUDIO"],
video = attributes["VIDEO"],
subtitles = attributes["SUBTITLES"],
closedCaptions = attributes["CLOSED-CAPTIONS"]
)
}
private fun parseMediaRendition(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", "VIDEO")
private fun shouldQuote(key: String, value: String?): Boolean {
if (value == null)
return false;
if (value.contains(','))
return true;
return _quoteList.contains(key)
}
private fun appendAttributes(stringBuilder: StringBuilder, vararg attributes: Pair<String, String?>) {
attributes.filter { it.second != null }
.joinToString(",") {
val value = it.second
"${it.first}=${if (shouldQuote(it.first, it.second)) "\"$value\"" else value}"
}
.let { if (it.isNotEmpty()) stringBuilder.append(it) }
}
}
data class SessionData(
val dataId: String,
val value: String
) {
fun toM3U8Line(): String = buildString {
append("#EXT-X-SESSION-DATA:")
appendAttributes(this,
"DATA-ID" to dataId,
"VALUE" to value
)
append("\n")
}
}
data class StreamInfo(
val bandwidth: Int?,
val resolution: String?,
val codecs: String?,
val frameRate: String?,
val videoRange: String?,
val audio: String?,
val video: String?,
val subtitles: String?,
val closedCaptions: String?
)
data class MediaRendition(
val type: String?,
val uri: String?,
val groupID: String?,
val language: String?,
val name: String?,
val isDefault: Boolean?,
val isAutoSelect: Boolean?,
val isForced: Boolean?
) {
fun toM3U8Line(): String = buildString {
append("#EXT-X-MEDIA:")
appendAttributes(this,
"TYPE" to type,
"URI" to uri,
"GROUP-ID" to groupID,
"LANGUAGE" to language,
"NAME" to name,
"DEFAULT" to isDefault.toYesNo(),
"AUTOSELECT" to isAutoSelect.toYesNo(),
"FORCED" to isForced.toYesNo()
)
append("\n")
}
}
data class MasterPlaylist(
val variantPlaylistsRefs: List<VariantPlaylistReference>,
val mediaRenditions: List<MediaRendition>,
val sessionDataList: List<SessionData>,
val independentSegments: Boolean
) {
fun buildM3U8(): String {
val builder = StringBuilder()
builder.append("#EXTM3U\n")
if (independentSegments) {
builder.append("#EXT-X-INDEPENDENT-SEGMENTS\n")
}
mediaRenditions.forEach { rendition ->
builder.append(rendition.toM3U8Line())
}
variantPlaylistsRefs.forEach { variant ->
builder.append(variant.toM3U8Line())
}
sessionDataList.forEach { data ->
builder.append(data.toM3U8Line())
}
return builder.toString()
}
}
data class VariantPlaylistReference(val url: String, val streamInfo: StreamInfo) {
fun toM3U8Line(): String = buildString {
append("#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,
"VIDEO" to streamInfo.video,
"SUBTITLES" to streamInfo.subtitles,
"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 playlistType: String?,
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")
playlistType?.let {
append("#EXT-X-PLAYLIST-TYPE:$it\n")
}
programDateTime?.let {
append("#EXT-X-PROGRAM-DATE-TIME:${it.format(DateTimeFormatter.ISO_DATE_TIME)}\n")
}
segments.forEach { segment ->
append(segment.toM3U8Line())
}
}
}
abstract class Segment {
abstract fun toM3U8Line(): String
}
data class MediaSegment (
val duration: Double,
var uri: String = ""
) : Segment() {
override fun toM3U8Line(): String = buildString {
append("#EXTINF:${duration},\n")
append(uri + "\n")
}
}
class DiscontinuitySegment : Segment() {
override fun toM3U8Line(): String = buildString {
append("#EXT-X-DISCONTINUITY\n")
}
}
class EndListSegment : Segment() {
override fun toM3U8Line(): String = buildString {
append("#EXT-X-ENDLIST\n")
}
}
}
@@ -0,0 +1,66 @@
package com.futo.platformplayer.parsers
import com.futo.platformplayer.api.http.server.HttpHeaders
import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException
import com.futo.platformplayer.readHttpHeaderBytes
import java.io.ByteArrayInputStream
import java.io.InputStream
class HttpResponseParser : AutoCloseable {
private val _inputStream: InputStream;
var head: String = "";
var headers: HttpHeaders = HttpHeaders();
var contentType: String? = null;
var transferEncoding: String? = null;
var location: String? = null;
var contentLength: Long = -1L;
var statusCode: Int = -1;
constructor(inputStream: InputStream) {
_inputStream = inputStream;
val headerBytes = inputStream.readHttpHeaderBytes()
ByteArrayInputStream(headerBytes).use {
val reader = it.bufferedReader(Charsets.UTF_8)
head = reader.readLine() ?: throw EmptyRequestException("No head found");
val statusLineParts = head.split(" ")
if (statusLineParts.size < 3) {
throw IllegalStateException("Invalid status line")
}
statusCode = statusLineParts[1].toInt()
while (true) {
val line = reader.readLine();
val headerEndIndex = line.indexOf(":");
if (headerEndIndex == -1)
break;
val headerKey = line.substring(0, headerEndIndex).lowercase()
val headerValue = line.substring(headerEndIndex + 1).trim();
headers[headerKey] = headerValue;
when(headerKey) {
"content-length" -> contentLength = headerValue.toLong();
"content-type" -> contentType = headerValue;
"transfer-encoding" -> transferEncoding = headerValue;
"location" -> location = headerValue;
}
if(line.isNullOrEmpty())
break;
}
}
}
override fun close() {
_inputStream.close();
}
companion object {
private val TAG = "HttpResponse";
}
}
@@ -0,0 +1,48 @@
package com.futo.platformplayer.receivers
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.futo.platformplayer.Settings
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateNotifications
class PlannedNotificationReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
try {
Logger.i(TAG, "Planned Notification received");
if(!Settings.instance.notifications.plannedContentNotification)
return;
if(StateApp.instance.contextOrNull == null)
StateApp.instance.initializeFiles();
val notifs = StateNotifications.instance.getScheduledNotifications(60 * 15, true);
if(!notifs.isEmpty() && context != null) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
val channel = StateNotifications.instance.contentNotifChannel;
notificationManager.createNotificationChannel(channel);
var i = 0;
for (notif in notifs) {
StateNotifications.instance.notifyNewContentWithThumbnail(context, notificationManager, channel, 110 + i, notif);
i++;
}
}
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed PlannedNotificationReceiver.onReceive", ex);
}
}
companion object {
private val TAG = "PlannedNotificationReceiver"
fun getIntent(context: Context): PendingIntent {
return PendingIntent.getBroadcast(context, 110, Intent(context, PlannedNotificationReceiver::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE);
}
}
}
@@ -107,7 +107,7 @@ class ExportingService : Service() {
{
try{
notifyExport(currentExport);
doExport(currentExport);
doExport(applicationContext, currentExport);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed export [${currentExport.videoLocal.name}]: ${ex.message}", ex);
@@ -125,13 +125,13 @@ class ExportingService : Service() {
stopService(this);
}
private suspend fun doExport(export: VideoExport) {
private suspend fun doExport(context: Context, export: VideoExport) {
Logger.i(TAG, "Exporting [${export.videoLocal.name}] started");
export.changeState(VideoExport.State.EXPORTING);
var lastNotifyTime: Long = 0L;
val file = export.export { progress ->
val file = export.export(context) { progress ->
export.progress = progress;
val currentTime = System.currentTimeMillis();
@@ -146,7 +146,7 @@ class ExportingService : Service() {
notifyExport(export);
withContext(Dispatchers.Main) {
StateAnnouncement.instance.registerAnnouncement(UUID.randomUUID().toString(), "File exported", "Exported [${file.path}]", AnnouncementType.SESSION, time = null, category = "download", actionButton = "Open") {
StateAnnouncement.instance.registerAnnouncement(UUID.randomUUID().toString(), "File exported", "Exported [${file.uri}]", AnnouncementType.SESSION, time = null, category = "download", actionButton = "Open") {
file.share(this@ExportingService);
};
}
@@ -111,17 +111,13 @@ class StateApp {
return null;
}
fun changeExternalDownloadDirectory(context: IWithResultLauncher, onChanged: ((DocumentFile?)->Unit)? = null) {
scopeOrNull?.launch(Dispatchers.Main) {
UIDialogs.toast("External download directory not yet used by export (WIP)");
};
if(context is Context)
requestDirectoryAccess(context, "Download Exports", "This directory is used to export downloads to for external usage.", null) {
if(it != null)
context.contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_WRITE_URI_PERMISSION.or(Intent.FLAG_GRANT_READ_URI_PERMISSION));
if(it != null && isValidStorageUri(context, it)) {
Logger.i(TAG, "Changed external download directory: ${it}");
Settings.instance.storage.storage_general = it.toString();
Settings.instance.storage.storage_download = it.toString();
Settings.instance.save();
onChanged?.invoke(getExternalDownloadDirectory(context));
@@ -400,10 +400,7 @@ class StateDownloads {
_exporting.save(videoExport);
if(notify) {
if(videoSource == null)
UIDialogs.toast("Exporting [${shortName}]\nIn your music directory under Grayjay");
else
UIDialogs.toast("Exporting [${shortName}]\nIn your movies directory under Grayjay");
UIDialogs.toast("Exporting [${shortName}]");
StateApp.withContext { ExportingService.getOrCreateService(it) };
onExportsChanged.emit();
}
@@ -5,15 +5,39 @@ import com.futo.platformplayer.stores.StringHashSetStorage
class StateMeta {
val hiddenVideos = FragmentedStorage.get<StringHashSetStorage>("hiddenVideos");
val hiddenCreators = FragmentedStorage.get<StringHashSetStorage>("hiddenCreators");
fun isVideoHidden(videoUrl: String) : Boolean {
return hiddenVideos.contains(videoUrl);
}
fun addHiddenVideo(videoUrl: String) {
hiddenVideos.addDistinct(videoUrl);
hiddenVideos.save();
}
fun removeHiddenVideo(videoUrl: String) {
hiddenVideos.remove(videoUrl);
hiddenVideos.save();
}
fun removeAllHiddenVideos() {
hiddenVideos.removeAll();
hiddenVideos.save();
}
fun isCreatorHidden(creatorUrl: String): Boolean {
return hiddenCreators.contains(creatorUrl);
}
fun addHiddenCreator(creatorUrl: String) {
hiddenCreators.addDistinct(creatorUrl);
hiddenCreators.save();
}
fun removeHiddenCreator(creatorUrl: String) {
hiddenCreators.remove(creatorUrl);
hiddenCreators.save();
}
fun removeAllHiddenCreators() {
hiddenCreators.removeAll();
hiddenCreators.save();
}
companion object {
@@ -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
@@ -246,13 +246,12 @@ class StateSubscriptions {
fun getSubscriptionsFeedWithExceptions(allowFailure: Boolean = false, withCacheFallback: Boolean = false, cacheScope: CoroutineScope, onProgress: ((Int, Int)->Unit)? = null, onNewCacheHit: ((Subscription, IPlatformContent)->Unit)? = null): Pair<IPager<IPlatformContent>, List<Throwable>> {
val algo = SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, cacheScope, allowFailure, withCacheFallback, _subscriptionsPool);
if(onNewCacheHit != null)
algo.onNewCacheHit.subscribe(onNewCacheHit)
algo.onProgress.subscribe { progress, total ->
onProgress?.invoke(progress, total);
}
algo.onNewCacheHit.subscribe { sub, content ->
}
val usePolycentric = true;
val subUrls = getSubscriptions().parallelStream().map {
@@ -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> {
@@ -35,6 +35,11 @@ class StringHashSetStorage : FragmentedStorageFileJson() {
values.remove(obj);
}
}
fun removeAll() {
synchronized(values) {
values.clear();
}
}
fun set(vararg objs: String) {
synchronized(values) {
values.clear();
@@ -0,0 +1,5 @@
package com.futo.platformplayer.stores.db
class ManagedDBIndex {
}
@@ -0,0 +1,5 @@
package com.futo.platformplayer.stores.db
class ManagedDBStore {
}
@@ -35,6 +35,8 @@ class SmartSubscriptionAlgorithm(
if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
return@flatMap listOf(SubscriptionTask(client, sub, it.key, ResultCapabilities.TYPE_MIXED));
else if(capabilities.hasType(ResultCapabilities.TYPE_SUBSCRIPTIONS))
return@flatMap listOf(SubscriptionTask(client, sub, it.key, ResultCapabilities.TYPE_SUBSCRIPTIONS))
else {
val types = listOf(
if(sub.shouldFetchVideos()) ResultCapabilities.TYPE_VIDEOS else null,
@@ -67,12 +69,17 @@ class SmartSubscriptionAlgorithm(
if(limit == null || limit <= 0)
finalTasks.addAll(clientTasks.second);
else {
val fetchTasks = clientTasks.second.take(limit);
val cacheTasks = clientTasks.second.drop(limit);
for(cacheTask in cacheTasks)
cacheTask.fromCache = true;
val fetchTasks = mutableListOf<SubscriptionTask>();
val cacheTasks = mutableListOf<SubscriptionTask>();
for(task in clientTasks.second) {
if(!task.fromCache && fetchTasks.size < limit)
fetchTasks.add(task);
else {
task.fromCache = true;
cacheTasks.add(task);
}
}
Logger.i(TAG, "Subscription Client Budget [${clientTasks.first.name}]: ${fetchTasks.size}/${limit}")
finalTasks.addAll(fetchTasks + cacheTasks);
@@ -57,7 +57,8 @@ abstract class SubscriptionsTaskFetchAlgorithm(
for(clientTasks in tasksGrouped) {
val clientTaskCount = clientTasks.value.filter { !it.fromCache }.size;
val clientCacheCount = clientTasks.value.size - clientTaskCount;
if(clientCacheCount > 0 && clientTaskCount > 0 && StateApp.instance.contextOrNull?.let { it is MainActivity && it.isFragmentActive<SubscriptionsFeedFragment>() } == true) {
val limit = clientTasks.key.getSubscriptionRateLimit();
if(clientCacheCount > 0 && clientTaskCount > 0 && limit != null && clientTaskCount >= limit && StateApp.instance.contextOrNull?.let { it is MainActivity && it.isFragmentActive<SubscriptionsFeedFragment>() } == true) {
UIDialogs.toast("[${clientTasks.key.name}] only updating ${clientTaskCount} most urgent channels (rqs). (${clientCacheCount} cached)");
}
}
@@ -15,10 +15,12 @@ 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>();
val onAddToQueueClicked = Event1<IPlatformContent>();
val onLongPress = Event1<IPlatformContent>();
override fun getItemCount(): Int {
return _cache.size;
@@ -50,9 +52,11 @@ 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);
onLongPress.subscribe(this@ChannelViewPagerAdapter.onLongPress::emit);
};
1 -> ChannelListFragment.newInstance().apply { onClickChannel.subscribe(onChannelClicked::emit) };
//2 -> ChannelStoreFragment.newInstance();
@@ -75,7 +75,7 @@ class CommentViewHolder : ViewHolder {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
Logger.i(TAG, "Started backfill");
args.processHandle.fullyBackfillServers();
args.processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers.", e)
@@ -1,4 +1,4 @@
package com.futo.platformplayer.views.adapters
package com.futo.platformplayer.views.adapters.feedtypes
import android.content.Context
import android.util.Log
@@ -18,6 +18,9 @@ import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.video.PlayerManager
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.EmptyPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewViewHolder> {
private var _initialPlay = true;
@@ -26,12 +29,15 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
private val _exoPlayer: PlayerManager?;
private val _feedStyle : FeedStyle;
private var _paused: Boolean = false;
private val _shouldShowTimeBar: Boolean
val onUrlClicked = Event1<String>();
val onContentUrlClicked = Event2<String, ContentType>();
val onContentClicked = Event2<IPlatformContent, Long>();
val onChannelClicked = Event1<PlatformAuthorLink>();
val onAddToClicked = Event1<IPlatformContent>();
val onAddToQueueClicked = Event1<IPlatformContent>();
val onLongPress = Event1<IPlatformContent>();
private var _taskLoadContent = TaskHandler<Pair<ContentPreviewViewHolder, IPlatformContent>, Pair<ContentPreviewViewHolder, IPlatformContentDetails>>(
StateApp.instance.scopeGetter, { (viewHolder, video) ->
@@ -43,12 +49,13 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
constructor(context: Context, feedStyle : FeedStyle, dataSet: ArrayList<IPlatformContent>, exoPlayer: PlayerManager? = null,
initialPlay: Boolean = false, viewsToPrepend: ArrayList<View> = arrayListOf(),
viewsToAppend: ArrayList<View> = arrayListOf()) : super(context, viewsToPrepend, viewsToAppend) {
viewsToAppend: ArrayList<View> = arrayListOf(), shouldShowTimeBar: Boolean = true) : super(context, viewsToPrepend, viewsToAppend) {
this._feedStyle = feedStyle;
this._dataSet = dataSet;
this._initialPlay = initialPlay;
this._exoPlayer = exoPlayer;
this._shouldShowTimeBar = shouldShowTimeBar
}
override fun getChildCount(): Int = _dataSet.size;
@@ -71,6 +78,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
ContentType.POST -> createPostViewHolder(viewGroup);
ContentType.PLAYLIST -> createPlaylistViewHolder(viewGroup);
ContentType.NESTED_VIDEO -> createNestedViewHolder(viewGroup);
ContentType.LOCKED -> createLockedViewHolder(viewGroup);
else -> EmptyPreviewViewHolder(viewGroup)
}
}
@@ -86,13 +94,17 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
this.onAddToClicked.subscribe(this@PreviewContentListAdapter.onAddToClicked::emit);
this.onAddToQueueClicked.subscribe(this@PreviewContentListAdapter.onAddToQueueClicked::emit);
};
private fun createLockedViewHolder(viewGroup: ViewGroup): PreviewLockedViewHolder = PreviewLockedViewHolder(viewGroup, _feedStyle).apply {
this.onLockedUrlClicked.subscribe(this@PreviewContentListAdapter.onUrlClicked::emit);
};
private fun createPlaceholderViewHolder(viewGroup: ViewGroup): PreviewPlaceholderViewHolder
= PreviewPlaceholderViewHolder(viewGroup, _feedStyle);
private fun createVideoPreviewViewHolder(viewGroup: ViewGroup): PreviewVideoViewHolder = PreviewVideoViewHolder(viewGroup, _feedStyle, _exoPlayer).apply {
private fun createVideoPreviewViewHolder(viewGroup: ViewGroup): PreviewVideoViewHolder = PreviewVideoViewHolder(viewGroup, _feedStyle, _exoPlayer, _shouldShowTimeBar).apply {
this.onVideoClicked.subscribe(this@PreviewContentListAdapter.onContentClicked::emit);
this.onChannelClicked.subscribe(this@PreviewContentListAdapter.onChannelClicked::emit);
this.onAddToClicked.subscribe(this@PreviewContentListAdapter.onAddToClicked::emit);
this.onAddToQueueClicked.subscribe(this@PreviewContentListAdapter.onAddToQueueClicked::emit);
this.onLongPress.subscribe(this@PreviewContentListAdapter.onLongPress::emit);
};
private fun createPlaylistViewHolder(viewGroup: ViewGroup): PreviewPlaylistViewHolder = PreviewPlaylistViewHolder(viewGroup, _feedStyle).apply {
this.onPlaylistClicked.subscribe { this@PreviewContentListAdapter.onContentClicked.emit(it, 0L) };
@@ -141,6 +153,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
fun release() {
_taskLoadContent.dispose();
onContentUrlClicked.clear();
onUrlClicked.clear();
onContentClicked.clear();
onChannelClicked.clear();
onAddToClicked.clear();
@@ -0,0 +1,134 @@
package com.futo.platformplayer.views.adapters.feedtypes
import android.animation.ObjectAnimator
import android.content.Context
import android.content.res.Resources
import android.util.Log
import android.util.TypedValue
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.bumptech.glide.Glide
import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.images.GlideHelper.Companion.loadThumbnails
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.video.PlayerManager
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.video.FutoThumbnailPlayer
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.google.android.material.imageview.ShapeableImageView
class PreviewLockedView : LinearLayout {
protected val _feedStyle : FeedStyle;
private val _textChannelName: TextView;
private val _textVideoName: TextView;
//private val _imageNeoPassChannel: ImageView;
private val _imageChannelThumbnail: ImageView;
private val _imageVideoThumbnail: ImageView;
private val _platformIndicator: PlatformIndicator;
private val _textLockedDescription: TextView;
//private val _textBrowserOpen: TextView;
private val _textLockedUrl: TextView;
private val _textVideoMetadata: TextView;
val onLockedUrlClicked = Event1<String>();
constructor(context: Context, feedStyle : FeedStyle) : super(context) {
inflate(feedStyle);
_feedStyle = feedStyle;
_textVideoName = findViewById(R.id.text_video_name);
_textChannelName = findViewById(R.id.text_channel_name);
//_imageNeoPassChannel = findViewById(R.id.image_neopass_channel);
_imageChannelThumbnail = findViewById(R.id.image_channel_thumbnail);
_imageVideoThumbnail = findViewById(R.id.image_video_thumbnail);
_platformIndicator = findViewById(R.id.thumbnail_platform)
_textLockedDescription = findViewById(R.id.text_locked_description);
//_textBrowserOpen = findViewById(R.id.text_browser_open);
_textLockedUrl = findViewById(R.id.text_locked_url);
_textVideoMetadata = findViewById(R.id.text_video_metadata);
setOnClickListener {
if(!_textLockedUrl.text.isNullOrEmpty())
onLockedUrlClicked.emit(_textLockedUrl.text.toString());
}
}
protected open fun inflate(feedStyle: FeedStyle) {
inflate(context, when(feedStyle) {
FeedStyle.PREVIEW -> R.layout.list_locked_preview
else -> R.layout.list_locked_thumbnail
}, this)
}
open fun bind(content: IPlatformContent) {
_textVideoName.text = content.name;
_textChannelName.text = content.author.name;
_platformIndicator.setPlatformFromClientID(content.id.pluginId);
if(content is IPlatformLockedContent) {
_imageVideoThumbnail.loadThumbnails(content.contentThumbnails, false) {
it.placeholder(R.drawable.placeholder_video_thumbnail)
.into(_imageVideoThumbnail);
};
Glide.with(_imageChannelThumbnail)
.load(content.author.thumbnail)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.into(_imageChannelThumbnail);
_textLockedDescription.text = content.lockDescription ?: "";
_textLockedUrl.text = content.unlockUrl ?: "";
}
else {
_imageChannelThumbnail.setImageResource(0);
_imageVideoThumbnail.setImageResource(0);
_textLockedDescription.text = "";
_textLockedUrl.text = "";
}
if(_textLockedUrl.text.isNullOrEmpty()) {
_textLockedUrl.visibility = GONE;
_textVideoMetadata.text = "";
}
else {
_textLockedUrl.visibility = VISIBLE;
_textVideoMetadata.text = "Tap to open in browser";
}
}
open fun preview(video: IPlatformContentDetails?, paused: Boolean) {
}
companion object {
private val TAG = "PreviewLockedView"
}
}
@@ -0,0 +1,46 @@
package com.futo.platformplayer.views.adapters.feedtypes
import android.content.Context
import android.graphics.drawable.Animatable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.contents.PlatformContentPlaceholder
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.platform.PlatformIndicator
class PreviewLockedViewHolder : ContentPreviewViewHolder {
override var content: IPlatformContent? = null;
private val view: PreviewLockedView get() = itemView as PreviewLockedView;
val onLockedUrlClicked = Event1<String>();
val context: Context;
constructor(viewGroup: ViewGroup, feedStyle: FeedStyle) : super(PreviewLockedView(viewGroup.context, feedStyle)) {
context = itemView.context;
view.onLockedUrlClicked.subscribe(onLockedUrlClicked::emit);
}
override fun bind(content: IPlatformContent) = view.bind(content);
override fun preview(video: IPlatformContentDetails?, paused: Boolean) { }
override fun stopPreview() { }
override fun pausePreview() { }
override fun resumePreview() { }
companion object {
private val TAG = "PlaceholderPreviewViewHolder"
}
}
@@ -1,4 +1,4 @@
package com.futo.platformplayer.views.adapters
package com.futo.platformplayer.views.adapters.feedtypes
import android.content.Context
import android.view.View
@@ -1,9 +1,6 @@
package com.futo.platformplayer.views.adapters
package com.futo.platformplayer.views.adapters.feedtypes
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.FrameLayout
import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
@@ -13,6 +10,7 @@ import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.video.PlayerManager
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
class PreviewNestedVideoViewHolder : ContentPreviewViewHolder {
@@ -25,7 +23,9 @@ class PreviewNestedVideoViewHolder : ContentPreviewViewHolder {
override val content: IPlatformContent? get() = view.content;
private val view: PreviewNestedVideoView get() = itemView as PreviewNestedVideoView;
constructor(viewGroup: ViewGroup, feedStyle : FeedStyle, exoPlayer: PlayerManager? = null): super(PreviewNestedVideoView(viewGroup.context, feedStyle, exoPlayer)) {
constructor(viewGroup: ViewGroup, feedStyle : FeedStyle, exoPlayer: PlayerManager? = null): super(
PreviewNestedVideoView(viewGroup.context, feedStyle, exoPlayer)
) {
view.onContentUrlClicked.subscribe(onContentUrlClicked::emit);
view.onVideoClicked.subscribe(onVideoClicked::emit);
view.onChannelClicked.subscribe(onChannelClicked::emit);
@@ -1,4 +1,4 @@
package com.futo.platformplayer.views.adapters
package com.futo.platformplayer.views.adapters.feedtypes
import android.content.Context
import android.graphics.drawable.Animatable
@@ -12,6 +12,7 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.contents.PlatformContentPlaceholder
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.platform.PlatformIndicator
@@ -1,4 +1,4 @@
package com.futo.platformplayer.views.adapters
package com.futo.platformplayer.views.adapters.feedtypes
import android.view.ViewGroup
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
@@ -7,6 +7,8 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.PlaylistView
class PreviewPlaylistViewHolder : ContentPreviewViewHolder {
@@ -1,4 +1,4 @@
package com.futo.platformplayer.views.adapters
package com.futo.platformplayer.views.adapters.feedtypes
import android.animation.ObjectAnimator
import android.content.Context
@@ -1,4 +1,4 @@
package com.futo.platformplayer.views.adapters
package com.futo.platformplayer.views.adapters.feedtypes
import android.view.ViewGroup
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
@@ -7,6 +7,7 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.video.PlayerManager
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
class PreviewPostViewHolder : ContentPreviewViewHolder {
@@ -18,7 +19,9 @@ class PreviewPostViewHolder : ContentPreviewViewHolder {
private val view: PreviewPostView get() = itemView as PreviewPostView;
constructor(viewGroup: ViewGroup, feedStyle : FeedStyle, exoPlayer: PlayerManager? = null): super(PreviewPostView(viewGroup.context, feedStyle)) {
constructor(viewGroup: ViewGroup, feedStyle : FeedStyle, exoPlayer: PlayerManager? = null): super(
PreviewPostView(viewGroup.context, feedStyle)
) {
view.onContentClicked.subscribe(onContentClicked::emit);
view.onChannelClicked.subscribe(onChannelClicked::emit);
}
@@ -1,4 +1,4 @@
package com.futo.platformplayer.views.adapters
package com.futo.platformplayer.views.adapters.feedtypes
import android.animation.ObjectAnimator
import android.content.Context
@@ -27,9 +27,11 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.video.PlayerManager
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.others.ProgressBar
import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.video.FutoThumbnailPlayer
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
@@ -67,7 +69,10 @@ open class PreviewVideoView : LinearLayout {
Logger.w(TAG, "Failed to load profile.", it);
};
private val _timeBar: ProgressBar?;
val onVideoClicked = Event2<IPlatformVideo, Long>();
val onLongPress = Event1<IPlatformVideo>();
val onChannelClicked = Event1<PlatformAuthorLink>();
val onAddToClicked = Event1<IPlatformVideo>();
val onAddToQueueClicked = Event1<IPlatformVideo>();
@@ -76,10 +81,12 @@ open class PreviewVideoView : LinearLayout {
private set
val content: IPlatformContent? get() = currentVideo;
val shouldShowTimeBar: Boolean
constructor(context: Context, feedStyle : FeedStyle, exoPlayer: PlayerManager? = null) : super(context) {
constructor(context: Context, feedStyle : FeedStyle, exoPlayer: PlayerManager? = null, shouldShowTimeBar: Boolean = true) : super(context) {
inflate(feedStyle);
_feedStyle = feedStyle;
this.shouldShowTimeBar = shouldShowTimeBar
val playerContainer = findViewById<FrameLayout>(R.id.player_container);
val displayMetrics = Resources.getSystem().displayMetrics;
@@ -116,10 +123,17 @@ open class PreviewVideoView : LinearLayout {
_button_add_to = findViewById(R.id.button_add_to);
_imageNeopassChannel = findViewById(R.id.image_neopass_channel);
_layoutDownloaded = findViewById(R.id.layout_downloaded);
_timeBar = findViewById(R.id.time_bar)
this._exoPlayer = exoPlayer
setOnClickListener { onOpenClicked() };
setOnLongClickListener {
onLongPress()
true
};
setOnClickListener {
onOpenClicked()
};
_imageChannel.setOnClickListener { currentVideo?.let { onChannelClicked.emit(it.author) } };
_textChannelName.setOnClickListener { currentVideo?.let { onChannelClicked.emit(it.author) } };
_textVideoMetadata.setOnClickListener { currentVideo?.let { onChannelClicked.emit(it.author) } };
@@ -145,6 +159,12 @@ open class PreviewVideoView : LinearLayout {
}
}
protected open fun onLongPress() {
currentVideo?.let {
onLongPress.emit(it);
}
}
open fun bind(content: IPlatformContent) {
_taskLoadProfile.cancel();
@@ -222,13 +242,26 @@ open class PreviewVideoView : LinearLayout {
_containerLive.visibility = GONE;
_containerDuration.visibility = VISIBLE;
}
val timeBar = _timeBar
if (timeBar != null) {
if (shouldShowTimeBar) {
val historyPosition = StatePlaylists.instance.getHistoryPosition(video.url)
timeBar.visibility = if (historyPosition > 0) VISIBLE else GONE
timeBar.progress = historyPosition.toFloat() / video.duration.toFloat()
} else {
timeBar.visibility = GONE
}
}
}
else {
currentVideo = null;
_imageVideo.setImageResource(0);
_containerDuration.visibility = GONE;
_containerLive.visibility = GONE;
_timeBar?.visibility = GONE;
}
_textVideoMetadata.text = metadata + timeMeta;
}
@@ -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);
}
@@ -1,5 +1,6 @@
package com.futo.platformplayer.views.adapters.viewholders
import android.app.Activity
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageButton
@@ -7,9 +8,11 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.futo.platformplayer.*
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.downloads.VideoLocal
import com.futo.platformplayer.images.GlideHelper.Companion.loadThumbnails
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.views.adapters.AnyAdapter
@@ -47,7 +50,18 @@ class VideoDownloadViewHolder(_viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder<
}
_videoExport.setOnClickListener {
val v = _video ?: return@setOnClickListener;
StateDownloads.instance.export(v, v.videoSource.firstOrNull(), v.audioSource.firstOrNull(), v.subtitlesSources.firstOrNull());
if (StateApp.instance.getExternalDownloadDirectory(_view.context) == null) {
StateApp.instance.changeExternalDownloadDirectory(_view.context as MainActivity) {
if (it == null) {
UIDialogs.toast(_view.context, "Download directory must be set to export.");
return@changeExternalDownloadDirectory;
}
StateDownloads.instance.export(v, v.videoSource.firstOrNull(), v.audioSource.firstOrNull(), v.subtitlesSources.firstOrNull());
};
} else {
StateDownloads.instance.export(v, v.videoSource.firstOrNull(), v.audioSource.firstOrNull(), v.subtitlesSources.firstOrNull());
}
}
}
@@ -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;
@@ -26,6 +26,8 @@ class ButtonField : BigButton, IField {
override var reference: Any? = null;
override val value: Any? = null;
override val obj : Any? get() {
if(this._obj == null)
throw java.lang.IllegalStateException("Can only be called if fromField is used");

Some files were not shown because too many files have changed in this diff Show More