diff --git a/app/build.gradle b/app/build.gradle index 278e8b0f..25d458d4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -156,6 +156,7 @@ android { dependencies { implementation 'com.google.dagger:dagger:2.48' implementation 'androidx.test:monitor:1.7.2' + implementation 'com.google.android.material:material:1.12.0' annotationProcessor 'com.google.dagger:dagger-compiler:2.48' //Core diff --git a/app/src/main/assets/scripts/source.js b/app/src/main/assets/scripts/source.js index c2e7abb8..85195f47 100644 --- a/app/src/main/assets/scripts/source.js +++ b/app/src/main/assets/scripts/source.js @@ -251,6 +251,9 @@ class PlatformVideo extends PlatformContent { this.duration = obj.duration ?? -1; //Long this.viewCount = obj.viewCount ?? -1; //Long + this.playbackTime = obj.playbackTime ?? -1; + this.playbackDate = obj.playbackDate ?? undefined; + this.isLive = obj.isLive ?? false; //Boolean this.isShort = !!obj.isShort ?? false; } @@ -785,6 +788,7 @@ let plugin = { //To override by plugin const source = { getHome() { return new ContentPager([], false, {}); }, + getShorts() { return new VideoPager([], false, {}); }, enable(config){ }, disable() {}, diff --git a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt index 1d7e3ef7..36755512 100644 --- a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt +++ b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt @@ -424,7 +424,7 @@ class UIDialogs { } - fun showCastingDialog(context: Context) { + fun showCastingDialog(context: Context, ownerActivity: Activity? = null) { val d = StateCasting.instance.activeDevice; if (d != null) { val dialog = ConnectedCastingDialog(context); @@ -432,6 +432,7 @@ class UIDialogs { dialog.setOwnerActivity(context) } registerDialogOpened(dialog); + ownerActivity?.let { dialog.setOwnerActivity(it) } dialog.setOnDismissListener { registerDialogClosed(dialog) }; dialog.show(); } else { @@ -444,21 +445,24 @@ class UIDialogs { if (c is Activity) { dialog.setOwnerActivity(c); } + ownerActivity?.let { dialog.setOwnerActivity(it) } dialog.setOnDismissListener { registerDialogClosed(dialog) }; dialog.show(); } } - fun showCastingTutorialDialog(context: Context) { + fun showCastingTutorialDialog(context: Context, ownerActivity: Activity? = null) { val dialog = CastingHelpDialog(context); registerDialogOpened(dialog); + ownerActivity?.let { dialog.setOwnerActivity(it) } dialog.setOnDismissListener { registerDialogClosed(dialog) }; dialog.show(); } - fun showCastingAddDialog(context: Context) { + fun showCastingAddDialog(context: Context, ownerActivity: Activity? = null) { val dialog = CastingAddDialog(context); registerDialogOpened(dialog); + ownerActivity?.let { dialog.setOwnerActivity(it) } dialog.setOnDismissListener { registerDialogClosed(dialog) }; dialog.show(); } diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index 83387081..409adbf5 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -129,115 +129,163 @@ class UISlideOverlays { val originalVideo = subscription.doFetchVideos; val originalPosts = subscription.doFetchPosts; - val menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, listOf()); + val menu = SlideUpMenuOverlay( + container.context, + container, + "Subscription Settings", + null, + true, + listOf() + ); - StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){ - val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url); - val capabilities = plugin.getChannelCapabilities(); + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + try { + val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url); + val capabilities = plugin.getChannelCapabilities(); - withContext(Dispatchers.Main) { - items.addAll(listOf( - SlideUpMenuItem( - container.context, - R.drawable.ic_notifications, - "Notifications", - "", - tag = "notifications", - call = { - subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications; - }, - invokeParent = false - ), - if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty()) - SlideUpMenuGroup(container.context, "Subscription Groups", - "You can select which groups this subscription is part of.", - -1, listOf()) else null, - if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty()) - SlideUpMenuRecycler(container.context, "as") { - val groups = ArrayList(StateSubscriptionGroups.instance.getSubscriptionGroups() - .map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) } - .sortedBy { !it.selected }); - var adapter: AnyAdapterView? = null; - adapter = it.asAny(groups, RecyclerView.HORIZONTAL) { - it.onClick.subscribe { - if(it is SubscriptionGroup.Selectable) { - val actualGroup = StateSubscriptionGroups.instance.getSubscriptionGroup(it.id) - ?: return@subscribe; - groups.clear(); - if(it.selected) - actualGroup.urls.remove(subscription.channel.url); - else - actualGroup.urls.add(subscription.channel.url); + withContext(Dispatchers.Main) { + items.addAll( + listOf( + SlideUpMenuItem( + container.context, + R.drawable.ic_notifications, + "Notifications", + "", + tag = "notifications", + call = { + subscription.doNotifications = + menu?.selectOption(null, "notifications", true, true) + ?: subscription.doNotifications; + }, + invokeParent = false + ), + if (StateSubscriptionGroups.instance.getSubscriptionGroups() + .isNotEmpty() + ) + SlideUpMenuGroup( + container.context, "Subscription Groups", + "You can select which groups this subscription is part of.", + -1, listOf() + ) else null, + if (StateSubscriptionGroups.instance.getSubscriptionGroups() + .isNotEmpty() + ) + SlideUpMenuRecycler(container.context, "as") { + val groups = + ArrayList( + StateSubscriptionGroups.instance.getSubscriptionGroups() + .map { + SubscriptionGroup.Selectable( + it, + it.urls.contains(subscription.channel.url) + ) + } + .sortedBy { !it.selected }); + var adapter: AnyAdapterView? = + null; + adapter = it.asAny(groups, RecyclerView.HORIZONTAL) { + it.onClick.subscribe { + if (it is SubscriptionGroup.Selectable) { + val actualGroup = + StateSubscriptionGroups.instance.getSubscriptionGroup( + it.id + ) + ?: return@subscribe; + groups.clear(); + if (it.selected) + actualGroup.urls.remove(subscription.channel.url); + else + actualGroup.urls.add(subscription.channel.url); - StateSubscriptionGroups.instance.updateSubscriptionGroup(actualGroup); - groups.addAll(StateSubscriptionGroups.instance.getSubscriptionGroups() - .map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) } - .sortedBy { !it.selected }); - adapter?.notifyContentChanged(); - } - } - }; - return@SlideUpMenuRecycler adapter; - } else null, - 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", - tag = "fetchLive", - call = { - subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive; - }, - invokeParent = false - ) else null, - if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem( - container.context, - R.drawable.ic_play, - "Streams", - "Check for streams", - tag = "fetchStreams", - call = { - subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams; - }, - invokeParent = false - ) else null, - if(capabilities.hasType(ResultCapabilities.TYPE_VIDEOS)) - SlideUpMenuItem( - container.context, - R.drawable.ic_play, - "Videos", - "Check for videos", - tag = "fetchVideos", - call = { - subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos; - }, - invokeParent = false - ) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty()) - SlideUpMenuItem( - container.context, - R.drawable.ic_play, - "Content", - "Check for content", - tag = "fetchVideos", - call = { - subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos; - }, - invokeParent = false - ) else null, - if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem( - container.context, - R.drawable.ic_chat, - "Posts", - "Check for posts", - tag = "fetchPosts", - call = { - subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts; - }, - invokeParent = false - ) else null/*,, + StateSubscriptionGroups.instance.updateSubscriptionGroup( + actualGroup + ); + groups.addAll( + StateSubscriptionGroups.instance.getSubscriptionGroups() + .map { + SubscriptionGroup.Selectable( + it, + it.urls.contains(subscription.channel.url) + ) + } + .sortedBy { !it.selected }); + adapter?.notifyContentChanged(); + } + } + }; + return@SlideUpMenuRecycler adapter; + } else null, + 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", + tag = "fetchLive", + call = { + subscription.doFetchLive = + menu?.selectOption(null, "fetchLive", true, true) + ?: subscription.doFetchLive; + }, + invokeParent = false + ) else null, + if (capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem( + container.context, + R.drawable.ic_play, + "Streams", + "Check for streams", + tag = "fetchStreams", + call = { + subscription.doFetchStreams = + menu?.selectOption(null, "fetchStreams", true, true) + ?: subscription.doFetchStreams; + }, + invokeParent = false + ) else null, + if (capabilities.hasType(ResultCapabilities.TYPE_VIDEOS)) + SlideUpMenuItem( + container.context, + R.drawable.ic_play, + "Videos", + "Check for videos", + tag = "fetchVideos", + call = { + subscription.doFetchVideos = + menu?.selectOption(null, "fetchVideos", true, true) + ?: subscription.doFetchVideos; + }, + invokeParent = false + ) else if (capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty()) + SlideUpMenuItem( + container.context, + R.drawable.ic_play, + "Content", + "Check for content", + tag = "fetchVideos", + call = { + subscription.doFetchVideos = + menu?.selectOption(null, "fetchVideos", true, true) + ?: subscription.doFetchVideos; + }, + invokeParent = false + ) else null, + if (capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem( + container.context, + R.drawable.ic_chat, + "Posts", + "Check for posts", + tag = "fetchPosts", + call = { + subscription.doFetchPosts = + menu?.selectOption(null, "fetchPosts", true, true) + ?: subscription.doFetchPosts; + }, + invokeParent = false + ) else null/*,, SlideUpMenuGroup(container.context, "Actions", "Various things you can do with this subscription", @@ -245,61 +293,82 @@ class UISlideOverlays { SlideUpMenuItem(container.context, R.drawable.ic_list, "Add to Group", "", "btnAddToGroup", { showCreateSubscriptionGroup(container, subscription.channel); }, false)*/ - ).filterNotNull()); + ).filterNotNull() + ); - menu.setItems(items); + menu.setItems(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); + 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.onOK.subscribe { + subscription.save(); + menu.hide(true); - if(subscription.doNotifications && !originalNotif) { - val mainContext = StateApp.instance.contextOrNull; - if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) { - UIDialogs.toast(container.context, "Enable 'Background Update' in settings for notifications to work"); + if (subscription.doNotifications && !originalNotif) { + val mainContext = StateApp.instance.contextOrNull; + if (Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) { + UIDialogs.toast( + container.context, + "Enable 'Background Update' in settings for notifications to work" + ); - if(mainContext is MainActivity) { - UIDialogs.showDialog(mainContext, R.drawable.ic_settings, "Background Updating Required", - "You need to set a Background Updating interval for notifications", null, 0, - UIDialogs.Action("Cancel", {}), - UIDialogs.Action("Configure", { - val intent = Intent(mainContext, SettingsActivity::class.java); - intent.putExtra("query", mainContext.getString(R.string.background_update)); - mainContext.startActivity(intent); - }, UIDialogs.ActionStyle.PRIMARY)); - } - return@subscribe; - } - else if(!(mainContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()) { - UIDialogs.toast(container.context, "Android notifications are disabled"); - if(mainContext is MainActivity) { - mainContext.requestNotificationPermissions("Notifications are required for subscription updating and notifications to work"); + if (mainContext is MainActivity) { + UIDialogs.showDialog( + mainContext, + R.drawable.ic_settings, + "Background Updating Required", + "You need to set a Background Updating interval for notifications", + null, + 0, + UIDialogs.Action("Cancel", {}), + UIDialogs.Action("Configure", { + val intent = Intent( + mainContext, + SettingsActivity::class.java + ); + intent.putExtra( + "query", + mainContext.getString(R.string.background_update) + ); + mainContext.startActivity(intent); + }, UIDialogs.ActionStyle.PRIMARY) + ); + } + return@subscribe; + } else if (!(mainContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()) { + UIDialogs.toast( + container.context, + "Android notifications are disabled" + ); + if (mainContext is MainActivity) { + mainContext.requestNotificationPermissions("Notifications are required for subscription updating and notifications to work"); + } } } - } - }; - menu.onCancel.subscribe { - subscription.doNotifications = originalNotif; - subscription.doFetchLive = originalLive; - subscription.doFetchStreams = originalStream; - subscription.doFetchVideos = originalVideo; - subscription.doFetchPosts = originalPosts; - }; + }; + menu.onCancel.subscribe { + subscription.doNotifications = originalNotif; + subscription.doFetchLive = originalLive; + subscription.doFetchStreams = originalStream; + subscription.doFetchVideos = originalVideo; + subscription.doFetchPosts = originalPosts; + }; - menu.setOk("Save"); + menu.setOk("Save"); - menu.show(); + menu.show(); + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to show subscription overlay.", e) } } diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index 073033da..0d5bf8d9 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -62,6 +62,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsF import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment import com.futo.platformplayer.fragment.mainactivity.main.PostDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.RemotePlaylistFragment +import com.futo.platformplayer.fragment.mainactivity.main.ShortsFragment import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.SourcesFragment import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment @@ -169,6 +170,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { lateinit var _fragMainRemotePlaylist: RemotePlaylistFragment; lateinit var _fragWatchlist: WatchLaterFragment; lateinit var _fragHistory: HistoryFragment; + lateinit var _fragShorts: ShortsFragment; lateinit var _fragSourceDetail: SourceDetailFragment; lateinit var _fragDownloads: DownloadsFragment; lateinit var _fragImportSubscriptions: ImportSubscriptionsFragment; @@ -338,6 +340,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _fragWebDetail = WebDetailFragment.newInstance(); _fragWatchlist = WatchLaterFragment.newInstance(); _fragHistory = HistoryFragment.newInstance(); + _fragShorts = ShortsFragment.newInstance(); _fragSourceDetail = SourceDetailFragment.newInstance(); _fragDownloads = DownloadsFragment(); _fragImportSubscriptions = ImportSubscriptionsFragment.newInstance(); @@ -1253,6 +1256,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { WebDetailFragment::class -> _fragWebDetail as T; WatchLaterFragment::class -> _fragWatchlist as T; HistoryFragment::class -> _fragHistory as T; + ShortsFragment::class -> _fragShorts as T; SourceDetailFragment::class -> _fragSourceDetail as T; DownloadsFragment::class -> _fragDownloads as T; ImportSubscriptionsFragment::class -> _fragImportSubscriptions as T; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt index 010fd3c1..894dfca4 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt @@ -13,6 +13,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails +import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.models.ImageVariable @@ -36,6 +37,11 @@ interface IPlatformClient { */ fun getHome(): IPager + /** + * Gets the shorts feed + */ + fun getShorts(): IPager + //Search /** * Gets search suggestion for the provided query string @@ -176,6 +182,10 @@ interface IPlatformClient { * Retrieves the subscriptions of the currently logged in user */ fun getUserSubscriptions(): Array; + /** + * Retrieves the history of the currently logged in user + */ + fun getUserHistory(): IPager; fun isClaimTypeSupported(claimType: Int): Boolean; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/LiveChatManager.kt b/app/src/main/java/com/futo/platformplayer/api/media/LiveChatManager.kt index ab903057..da7302fb 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/LiveChatManager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/LiveChatManager.kt @@ -11,6 +11,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent import com.futo.platformplayer.api.media.models.live.LiveEventComment import com.futo.platformplayer.api.media.models.live.LiveEventEmojis import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager +import com.futo.platformplayer.api.media.platforms.js.models.JSVODEventPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.BatchedTaskHandler import com.futo.platformplayer.logging.Logger @@ -26,12 +27,17 @@ class LiveChatManager { private val _emojiCache: EmojiCache = EmojiCache(); private val _pager: IPager?; + private var _position: Long = 0; + private var _eventsPosition: Long = 0; + private val _history: ArrayList = arrayListOf(); private var _startCounter = 0; private val _followers: HashMap) -> Unit> = hashMapOf(); + val isVOD get() = _pager is JSVODEventPager; + var viewCount: Long = 0 private set; @@ -39,8 +45,24 @@ class LiveChatManager { _scope = scope; _pager = pager; viewCount = initialViewCount; - handleEvents(listOf(LiveEventComment("SYSTEM", null, "Live chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n"))); - handleEvents(pager.getResults()); + if(pager is JSVODEventPager) + handleEvents(listOf(LiveEventComment("SYSTEM", null, "VOD chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n"))); + else + handleEvents(listOf(LiveEventComment("SYSTEM", null, "Live chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n"))); + + if(pager is JSVODEventPager) { + var replayResults = pager.getResults().filter { it.time > _eventsPosition || it is LiveEventEmojis }; + //TODO: Remove this once dripfeed is done properly + replayResults = replayResults.filter{ it.time < _eventsPosition + 1500 || it is LiveEventEmojis }; + if(replayResults.size > 0) { + _eventsPosition = replayResults.maxOf { it.time }; + Logger.i(TAG, "VOD Events last event: " + _eventsPosition); + } + else + _eventsPosition = _eventsPosition + 1500; + } + else + handleEvents(pager.getResults()); } fun start() { @@ -52,6 +74,10 @@ class LiveChatManager { _startCounter++; } + fun setVideoPosition(ms: Long) { + _position = ms; + } + fun getHistory(): List { synchronized(_history) { return _history.toList(); @@ -85,13 +111,36 @@ class LiveChatManager { try { while(_startCounter == counter) { var nextInterval = 1000L; + if(_pager is JSVODEventPager && _eventsPosition > _position) { + delay(500); + continue; + } + try { if(_pager == null || !_pager.hasMorePages()) return@launch; - _pager.nextPage(); - val newEvents = _pager.getResults(); + val newEvents = if(_pager is JSVODEventPager) { + val requestPosition = _position; + _pager.nextPage(requestPosition.toInt()); + var replayResults = _pager.getResults().filter { it.time > requestPosition || it is LiveEventEmojis }; + //TODO: Remove this once dripfeed is done properly + replayResults = replayResults.filter{ it.time < requestPosition + 1500 || it is LiveEventEmojis }; + if(replayResults.size > 0) { + _eventsPosition = replayResults.maxOf { it.time }; + Logger.i(TAG, "VOD Events last event: " + _eventsPosition); + } + else + _eventsPosition = requestPosition + _pager.nextRequest.coerceAtLeast(800).toLong(); + replayResults; + } + else { + _pager.nextPage(); + _pager.getResults(); + } if(_pager is JSLiveEventPager) nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong(); + else if(_pager is JSVODEventPager) + nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong(); if(newEvents.size > 0) Logger.i(TAG, "New Live Events (${newEvents.size}) [${newEvents.map { it.type.name }.joinToString(", ")}]"); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientCapabilities.kt b/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientCapabilities.kt index cb62b66c..aa65a18b 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientCapabilities.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientCapabilities.kt @@ -20,7 +20,8 @@ data class PlatformClientCapabilities( val hasGetContentChapters: Boolean = false, val hasPeekChannelContents: Boolean = false, val hasGetChannelPlaylists: Boolean = false, - val hasGetContentRecommendations: Boolean = false + val hasGetContentRecommendations: Boolean = false, + val hasGetUserHistory: Boolean = false ) { } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/live/IPlatformLiveEvent.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/live/IPlatformLiveEvent.kt index 19b4bbb9..8c7ca14c 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/live/IPlatformLiveEvent.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/live/IPlatformLiveEvent.kt @@ -7,6 +7,7 @@ import com.futo.platformplayer.getOrThrow interface IPlatformLiveEvent { val type : LiveEventType; + var time: Long; companion object { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventComment.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventComment.kt index 8b9883ef..33818eb0 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventComment.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventComment.kt @@ -18,12 +18,15 @@ class LiveEventComment: IPlatformLiveEvent, ILiveEventChatMessage { val colorName: String?; val badges: List; - constructor(name: String, thumbnail: String?, message: String, colorName: String? = null, badges: List? = null) { + override var time: Long = -1; + + constructor(name: String, thumbnail: String?, message: String, colorName: String? = null, badges: List? = null, time: Long = -1) { this.name = name; this.message = message; this.thumbnail = thumbnail; this.colorName = colorName; this.badges = badges ?: listOf(); + this.time = time; } companion object { @@ -39,7 +42,8 @@ class LiveEventComment: IPlatformLiveEvent, ILiveEventChatMessage { obj.getOrThrow(config, "name", contextName), obj.getOrThrow(config, "thumbnail", contextName, true), obj.getOrThrow(config, "message", contextName), - colorName, badges); + colorName, badges, + obj.getOrDefault(config, "time", contextName, -1) ?: -1); } } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventDonation.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventDonation.kt index f8cbafe6..49044906 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventDonation.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventDonation.kt @@ -21,6 +21,8 @@ class LiveEventDonation: IPlatformLiveEvent, ILiveEventChatMessage { var expire: Int = 6000; + override var time: Long = -1; + constructor(name: String, thumbnail: String?, message: String, amount: String, expire: Int = 6000, colorDonation: String? = null) { this.name = name; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventEmojis.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventEmojis.kt index ebd75b44..f4141174 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventEmojis.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventEmojis.kt @@ -10,6 +10,8 @@ class LiveEventEmojis: IPlatformLiveEvent { val emojis: HashMap; + override var time: Long = -1; + constructor(emojis: HashMap) { this.emojis = emojis; } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventRaid.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventRaid.kt index 6663852d..6f7740a0 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventRaid.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventRaid.kt @@ -14,6 +14,8 @@ class LiveEventRaid: IPlatformLiveEvent { val targetUrl: String; val isOutgoing: Boolean; + override var time: Long = -1; + constructor(name: String, url: String, thumbnail: String, isOutgoing: Boolean) { this.targetName = name; this.targetUrl = url; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventViewCount.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventViewCount.kt index 5e48e984..d69a6ccb 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventViewCount.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventViewCount.kt @@ -10,6 +10,8 @@ class LiveEventViewCount: IPlatformLiveEvent { val viewCount: Int; + override var time: Long = -1; + constructor(viewCount: Int) { this.viewCount = viewCount; } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/IPlatformVideo.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/IPlatformVideo.kt index bd4a80ec..de635396 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/video/IPlatformVideo.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/IPlatformVideo.kt @@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.video import com.futo.platformplayer.api.media.models.Thumbnails import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import java.time.OffsetDateTime /** * A search result representing a video (overview data) @@ -12,6 +13,9 @@ interface IPlatformVideo : IPlatformContent { val duration: Long; val viewCount: Long; + val playbackTime: Long; + val playbackDate: OffsetDateTime?; + val isLive : Boolean; val isShort: Boolean; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt index c9e02d92..6144e3e6 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt @@ -6,8 +6,6 @@ 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.serializers.OffsetDateTimeNullableSerializer -import com.futo.polycentric.core.combineHashCodes -import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNames @@ -18,7 +16,7 @@ open class SerializedPlatformVideo( override val contentType: ContentType = ContentType.MEDIA, override val id: PlatformID, override val name: String, - override val thumbnails: Thumbnails, + override val thumbnails: Thumbnails = Thumbnails(), override val author: PlatformAuthorLink, @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) @JsonNames("datetime", "dateTime") @@ -33,6 +31,10 @@ open class SerializedPlatformVideo( override val isLive: Boolean = false; + override var playbackTime: Long = -1; + @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) + override var playbackDate: OffsetDateTime? = null; + override fun toJson() : String { return Json.encodeToString(this); } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideoDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideoDetails.kt index 5354352f..b7e343ca 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideoDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideoDetails.kt @@ -13,7 +13,6 @@ import com.futo.platformplayer.api.media.models.ratings.IRating import com.futo.platformplayer.api.media.models.streams.sources.* import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer -import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.time.OffsetDateTime @@ -43,6 +42,10 @@ open class SerializedPlatformVideoDetails( ) : IPlatformVideo, IPlatformVideoDetails { final override val contentType: ContentType get() = ContentType.MEDIA; + override var playbackTime: Long = -1; + @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) + override var playbackDate: OffsetDateTime? = null; + override val isLive: Boolean get() = false; override val dash: IDashManifestSource? get() = null; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt index 8c4097ae..2233048f 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt @@ -23,6 +23,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails +import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.platforms.js.internal.JSCallDocs import com.futo.platformplayer.api.media.platforms.js.internal.JSDocs import com.futo.platformplayer.api.media.platforms.js.internal.JSDocsParameter @@ -43,6 +44,7 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager import com.futo.platformplayer.api.media.platforms.js.models.JSPlaybackTracker import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistDetails import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistPager +import com.futo.platformplayer.api.media.platforms.js.models.JSVideoPager import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.Event1 @@ -124,6 +126,7 @@ open class JSClient : IPlatformClient { val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true + val enableInShorts get() = descriptor.appSettings.tabEnabled.enableShorts ?: true fun getSubscriptionRateLimit(): Int? { val pluginRateLimit = config.subscriptionRateLimit; @@ -269,7 +272,8 @@ open class JSClient : IPlatformClient { hasGetContentChapters = plugin.executeBoolean("!!source.getContentChapters") ?: false, hasPeekChannelContents = plugin.executeBoolean("!!source.peekChannelContents") ?: false, hasGetChannelPlaylists = plugin.executeBoolean("!!source.getChannelPlaylists") ?: false, - hasGetContentRecommendations = plugin.executeBoolean("!!source.getContentRecommendations") ?: false + hasGetContentRecommendations = plugin.executeBoolean("!!source.getContentRecommendations") ?: false, + hasGetUserHistory = plugin.executeBoolean("!!source.getUserHistory") ?: false ); try { @@ -328,6 +332,13 @@ open class JSClient : IPlatformClient { plugin.executeTyped("source.getHome()")); } + @JSDocs(2, "source.getShorts()", "Gets the Shorts feed of the platform") + override fun getShorts(): IPager = isBusyWith("getShorts") { + ensureEnabled() + return@isBusyWith JSVideoPager(config, this, + plugin.executeTyped("source.getShorts()")) + } + @JSDocs(3, "source.searchSuggestions(query)", "Gets search suggestions for a given query") @JSDocsParameter("query", "Query to complete suggestions for") override fun searchSuggestions(query: String): Array = isBusyWith("searchSuggestions") { @@ -702,6 +713,13 @@ open class JSClient : IPlatformClient { .toTypedArray(); } + @JSOptional + @JSDocs(23, "source.getUserHistory()", "Gets the history of the current user") + override fun getUserHistory(): IPager { + ensureEnabled(); + return JSContentPager(config, this, plugin.executeTyped("source.getUserHistory()")); + } + fun validate() { try { plugin.start(); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt index e318b5c2..8d5675b6 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt @@ -48,6 +48,7 @@ class SourcePluginConfig( var subscriptionRateLimit: Int? = null, var enableInSearch: Boolean = true, var enableInHome: Boolean = true, + var enableInShorts: Boolean = true, var supportedClaimTypes: List = listOf(), var primaryClaimFieldType: Int? = null, var developerSubmitUrl: String? = null, diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt index add53131..55921618 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt @@ -5,10 +5,16 @@ import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.AnnouncementType import com.futo.platformplayer.states.StateAnnouncement +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateHistory +import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.views.fields.DropdownFieldOptions import com.futo.platformplayer.views.fields.FieldForm import com.futo.platformplayer.views.fields.FormField +import com.futo.platformplayer.views.fields.FormFieldButton import com.futo.platformplayer.views.fields.FormFieldWarning +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.serialization.Serializable @Serializable @@ -103,12 +109,35 @@ class SourcePluginDescriptor { @FormField(R.string.home, FieldForm.TOGGLE, R.string.show_content_in_home_tab, 1) var enableHome: Boolean? = null; - @FormField(R.string.search, FieldForm.TOGGLE, R.string.show_content_in_search_results, 2) var enableSearch: Boolean? = null; + + @FormField(R.string.shorts, FieldForm.TOGGLE, R.string.show_content_in_shorts_tab, 3) + var enableShorts: Boolean? = null; } - @FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 3) + @FormField(R.string.sync, "group", R.string.sync_desc, 3) + var sync = Sync(); + @Serializable + class Sync { + @FormField(R.string.sync_history, FieldForm.TOGGLE, R.string.sync_history_desc, 1) + var enableHistorySync: Boolean? = null; + + @FormField(R.string.sync_history, FieldForm.BUTTON, R.string.sync_history_desc, 2) + @FormFieldButton() + fun syncHistoryNow() { + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + val clients = StatePlatform.instance.getEnabledClients(); + for (client in clients) { + if (client is JSClient) {//) && client.descriptor.appSettings.sync.enableHistorySync == true) { + StateHistory.instance.syncRemoteHistory(client); + } + } + }; + } + } + + @FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 4) var rateLimit = RateLimit(); @Serializable class RateLimit { @@ -143,6 +172,8 @@ class SourcePluginDescriptor { tabEnabled.enableHome = config.enableInHome if(tabEnabled.enableSearch == null) tabEnabled.enableSearch = config.enableInSearch + if(tabEnabled.enableShorts == null) + tabEnabled.enableShorts = config.enableInShorts } } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPager.kt index 731d0e51..d4aecc16 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPager.kt @@ -19,8 +19,8 @@ abstract class JSPager : IPager { protected var pager: V8ValueObject; private var _lastResults: List? = null; - private var _resultChanged: Boolean = true; - private var _hasMorePages: Boolean = false; + protected var _resultChanged: Boolean = true; + protected var _hasMorePages: Boolean = false; //private var _morePagesWasFalse: Boolean = false; val isAvailable get() = plugin.getUnderlyingPlugin()._runtime?.let { !it.isClosed && !it.isDead } ?: false; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVODEventPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVODEventPager.kt new file mode 100644 index 00000000..5361a2a4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVODEventPager.kt @@ -0,0 +1,44 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.V8Value +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.structures.IPlatformLiveEventPager +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.invokeV8 +import com.futo.platformplayer.warnIfMainThread +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +class JSVODEventPager : JSPager, IPlatformLiveEventPager { + override var nextRequest: Int; + + constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) { + nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager"); + } + + fun nextPage(ms: Int) = plugin.isBusyWith("JSLiveEventPager.nextPage") { + warnIfMainThread("VODEventPager.nextPage"); + + val pluginV8 = plugin.getUnderlyingPlugin(); + pluginV8.busy { + val newPager: V8Value = pluginV8.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage(...)") { + pager.invokeV8("nextPage", ms); + }; + if(newPager is V8ValueObject) + pager = newPager; + _hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false; + _resultChanged = true; + } + nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager"); + } + + override fun nextPage() = nextPage(0); + + override fun convertResult(obj: V8ValueObject): IPlatformLiveEvent { + return IPlatformLiveEvent.fromV8(config, obj, "LiveEventPager"); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideo.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideo.kt index 13c0aead..0b033af7 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideo.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideo.kt @@ -8,6 +8,10 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset open class JSVideo : JSContent, IPlatformVideo, IPluginSourced { final override val contentType: ContentType get() = ContentType.MEDIA; @@ -17,6 +21,10 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced { final override val duration: Long; final override val viewCount: Long; + override var playbackTime: Long = -1; + @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) + override var playbackDate: OffsetDateTime? = null; + final override val isLive: Boolean; final override val isShort: Boolean; @@ -29,5 +37,11 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced { viewCount = _content.getOrThrow(config, "viewCount", contextName); isLive = _content.getOrThrow(config, "isLive", contextName); isShort = _content.getOrDefault(config, "isShort", contextName, false) ?: false; + playbackTime = _content.getOrDefault(config, "playbackTime", contextName, -1)?.toLong() ?: -1; + val playbackDateInt = _content.getOrDefault(config, "playbackDate", contextName, null)?.toLong(); + if(playbackDateInt == null || playbackDateInt == 0.toLong()) + playbackDate = null; + else + playbackDate = OffsetDateTime.of(LocalDateTime.ofEpochSecond(playbackDateInt, 0, ZoneOffset.UTC), ZoneOffset.UTC); } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideoDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideoDetails.kt index abea9550..4aca63aa 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideoDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideoDetails.kt @@ -7,6 +7,7 @@ import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.ratings.IRating import com.futo.platformplayer.api.media.models.ratings.RatingLikes @@ -26,12 +27,15 @@ import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrowNullable import com.futo.platformplayer.invokeV8 import com.futo.platformplayer.states.StateDeveloper +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json class JSVideoDetails : JSVideo, IPlatformVideoDetails { private val _plugin: JSClient; private val _hasGetComments: Boolean; private val _hasGetContentRecommendations: Boolean; private val _hasGetPlaybackTracker: Boolean; + private val _hasGetVODEvents: Boolean; //Details override val description : String; @@ -47,7 +51,6 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails { override val subtitles: List; - constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin.config, obj) { val contextName = "VideoDetails"; _plugin = plugin; @@ -72,6 +75,7 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails { _hasGetComments = _content.has("getComments"); _hasGetPlaybackTracker = _content.has("getPlaybackTracker"); _hasGetContentRecommendations = _content.has("getContentRecommendations"); + _hasGetVODEvents = _content.has("getVODEvents"); } override fun getPlaybackTracker(): IPlaybackTracker? { @@ -138,4 +142,15 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails { return@busy JSCommentPager(_pluginConfig, client, commentPager); } } + + fun hasVODEvents(): Boolean{ + return _hasGetVODEvents; + } + fun getVODEvents(url: String): IPager? = _plugin.busy { + if(!_hasGetVODEvents) + return@busy null; + + return@busy JSVODEventPager(_plugin.config, _plugin, + _content.invokeV8("getVODEvents", arrayOf())); + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoDetails.kt index 1c169e64..7dff9cc5 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoDetails.kt @@ -11,7 +11,6 @@ import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.ratings.IRating import com.futo.platformplayer.api.media.models.ratings.RatingLikes import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor -import com.futo.platformplayer.api.media.models.streams.DownloadedVideoMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource @@ -19,7 +18,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource import com.futo.platformplayer.api.media.structures.IPager -import com.futo.platformplayer.downloads.VideoLocal +import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import java.io.File import java.time.Instant import java.time.OffsetDateTime @@ -53,6 +52,10 @@ class LocalVideoDetails: IPlatformVideoDetails { override val isLive: Boolean = false; override val isShort: Boolean = false; + override var playbackTime: Long = -1; + @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) + override var playbackDate: OffsetDateTime? = null; + constructor(file: File) { id = PlatformID("Local", file.path, "LOCAL") name = file.name; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt index e86c9ba6..66554572 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt @@ -7,12 +7,12 @@ import java.util.stream.IntStream * A Content MultiPager that returns results based on a specified distribution * TODO: Merge all basic distribution pagers */ -class MultiDistributionContentPager : MultiPager { +class MultiDistributionContentPager : MultiPager { - private val dist : HashMap, Float>; - private val distConsumed : HashMap, Float>; + private val dist : HashMap, Float>; + private val distConsumed : HashMap, Float>; - constructor(pagers : Map, Float>) : super(pagers.keys.toMutableList()) { + constructor(pagers : Map, Float>) : super(pagers.keys.toMutableList()) { val distTotal = pagers.values.sum(); dist = HashMap(); @@ -25,7 +25,7 @@ class MultiDistributionContentPager : MultiPager { } @Synchronized - override fun selectItemIndex(options: Array>): Int { + override fun selectItemIndex(options: Array>): Int { if(options.size == 0) return -1; var bestIndex = 0; @@ -42,6 +42,4 @@ class MultiDistributionContentPager : MultiPager { distConsumed[options[bestIndex].pager.getPager()] = bestConsumed; return bestIndex; } - - } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt index 226a0a66..f6582055 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt @@ -62,6 +62,7 @@ class ChromecastCastingDevice : CastingDevice { private val MAX_LAUNCH_RETRIES = 3 private var _lastLaunchTime_ms = 0L private var _retryJob: Job? = null + private var _autoLaunchEnabled = true constructor(name: String, addresses: Array, port: Int) : super() { this.name = name; @@ -305,6 +306,7 @@ class ChromecastCastingDevice : CastingDevice { return; } + _autoLaunchEnabled = true _started = true; _sessionId = null; _launchRetries = 0 @@ -546,6 +548,7 @@ class ChromecastCastingDevice : CastingDevice { if (appId == "CC1AD845") { sessionIsRunning = true; + _autoLaunchEnabled = false if (_sessionId == null) { connectionState = CastConnectionState.CONNECTED; @@ -558,7 +561,6 @@ class ChromecastCastingDevice : CastingDevice { _transportId = transportId; requestMediaStatus(); - playVideo(); } } } @@ -568,21 +570,22 @@ class ChromecastCastingDevice : CastingDevice { if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) { _sessionId = null _mediaSessionId = null - setTime(0.0) _transportId = null - if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) { - Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}") - _launchRetries++ - launchPlayer() - } else if (!_launching && _launchRetries < MAX_LAUNCH_RETRIES) { - // Maybe the first GET_STATUS came back empty; still try launching - Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}") - _launching = true - _launchRetries++ - launchPlayer() + if (_autoLaunchEnabled) { + if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) { + Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}") + _launchRetries++ + launchPlayer() + } else { + // Maybe the first GET_STATUS came back empty; still try launching + Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}") + _launching = true + _launchRetries++ + launchPlayer() + } } else { - Logger.e(TAG, "Player not found after $_launchRetries attempts; giving up.") + Logger.e(TAG, "Player not found ($_launchRetries, _autoLaunchEnabled = $_autoLaunchEnabled); giving up.") Logger.i(TAG, "Unable to start media receiver on device") stop() } @@ -599,6 +602,7 @@ class ChromecastCastingDevice : CastingDevice { } else { _launching = false _launchRetries = 0 + _autoLaunchEnabled = false } val volume = status.getJSONObject("volume"); @@ -636,10 +640,16 @@ class ChromecastCastingDevice : CastingDevice { stopVideo(); } } + + val needsLoad = statuses.length() == 0 || (statuses.getJSONObject(0).getString("playerState") == "IDLE") + if (needsLoad && _contentId != null && _mediaSessionId == null) { + Logger.i(TAG, "Receiver idle, sending initial LOAD") + playVideo() + } } else if (type == "CLOSE") { if (message.sourceId == "receiver-0") { Logger.i(TAG, "Close received."); - stop(); + stopCasting(); } else if (_transportId == message.sourceId) { throw Exception("Transport id closed.") } @@ -676,6 +686,10 @@ class ChromecastCastingDevice : CastingDevice { localAddress = null; _started = false; + _contentId = null + _contentType = null + _streamType = null + _retryJob?.cancel() _retryJob = null diff --git a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt index dcfaf63d..be62f726 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt @@ -348,7 +348,7 @@ class FCastCastingDevice : CastingDevice { headerBytesRead += read } - val size = ((buffer[3].toLong() shl 24) or (buffer[2].toLong() shl 16) or (buffer[1].toLong() shl 8) or buffer[0].toLong()).toInt(); + val size = ((buffer[3].toUByte().toLong() shl 24) or (buffer[2].toUByte().toLong() shl 16) or (buffer[1].toUByte().toLong() shl 8) or buffer[0].toUByte().toLong()).toInt(); if (size > buffer.size) { Logger.w(TAG, "Packets larger than $size bytes are not supported.") break diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index bb980d84..f7802e86 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -39,6 +39,7 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestMergingRawSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.builders.DashBuilder import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 @@ -486,7 +487,7 @@ class StateCasting { } } } else { - val proxyStreams = Settings.instance.casting.alwaysProxyRequests; + val proxyStreams = shouldProxyStreams(ad, videoSource, audioSource) val url = getLocalUrl(ad); val id = UUID.randomUUID(); @@ -751,7 +752,7 @@ class StateCasting { private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); - val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice; + val proxyStreams = shouldProxyStreams(ad, videoSource, audioSource) val url = getLocalUrl(ad); val id = UUID.randomUUID(); @@ -1114,9 +1115,14 @@ class StateCasting { return listOf(hlsUrl, videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); } + private fun shouldProxyStreams(castingDevice: CastingDevice, videoSource: IVideoSource?, audioSource: IAudioSource?): Boolean { + val hasRequestModifier = (videoSource as? JSSource)?.hasRequestModifier == true || (audioSource as? JSSource)?.hasRequestModifier == true + return Settings.instance.casting.alwaysProxyRequests || castingDevice !is FCastCastingDevice || hasRequestModifier + } + private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); - val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice; + val proxyStreams = shouldProxyStreams(ad, videoSource, audioSource) val url = getLocalUrl(ad); val id = UUID.randomUUID(); diff --git a/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt b/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt index acb57b78..4a7b8dd7 100644 --- a/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt +++ b/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt @@ -47,10 +47,10 @@ class DeveloperEndpoints(private val context: Context) { private val testPluginOrThrow: V8Plugin get() = _testPlugin ?: throw IllegalStateException("Attempted to use test plugin without plugin"); private val _testPluginVariables: HashMap = hashMapOf(); - private inline fun createRemoteObjectArray(objs: Iterable): List { - val remotes = mutableListOf(); + private inline fun createRemoteObjectArray(objs: Iterable): List { + val remotes = mutableListOf(); for(obj in objs) - remotes.add(createRemoteObject(obj)!!); + remotes.add(createRemoteObject(obj)); return remotes; } private inline fun createRemoteObject(obj: T): V8RemoteObject? { diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt index 295e191a..9eb71145 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt @@ -106,7 +106,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) { }; _buttonTutorial.setOnClickListener { - UIDialogs.showCastingTutorialDialog(context) + UIDialogs.showCastingTutorialDialog(context, ownerActivity) dismiss() } } @@ -130,7 +130,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) { private fun performDismiss(shouldShowCastingDialog: Boolean = true) { if (shouldShowCastingDialog) { - UIDialogs.showCastingDialog(context); + UIDialogs.showCastingDialog(context, ownerActivity); } dismiss(); diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/CastingHelpDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/CastingHelpDialog.kt index 9f305b18..510b6965 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/CastingHelpDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/CastingHelpDialog.kt @@ -53,7 +53,7 @@ class CastingHelpDialog(context: Context?) : AlertDialog(context) { findViewById(R.id.button_close).onClick.subscribe { dismiss() - UIDialogs.showCastingAddDialog(context) + UIDialogs.showCastingAddDialog(context, ownerActivity) } } diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt index 87375779..2bb87111 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt @@ -83,7 +83,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { _buttonClose.setOnClickListener { dismiss(); }; _buttonAdd.setOnClickListener { - UIDialogs.showCastingAddDialog(context); + UIDialogs.showCastingAddDialog(context, ownerActivity); dismiss(); }; @@ -139,9 +139,6 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { } } } - - _textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE; - _recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE; } override fun dismiss() { diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt index 5e64c3e3..23ace157 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -303,9 +303,10 @@ class VideoDownload { try { val playlistResponse = client.get(source.url) if (playlistResponse.isOk) { + val resolvedPlaylistUrl = playlistResponse.url val playlistContent = playlistResponse.body?.string() if (playlistContent != null) { - videoSources.addAll(HLS.parseAndGetVideoSources(source, playlistContent, source.url)) + videoSources.addAll(HLS.parseAndGetVideoSources(source, playlistContent, resolvedPlaylistUrl)) } } } catch (e: Throwable) { @@ -351,9 +352,10 @@ class VideoDownload { try { val playlistResponse = client.get(source.url) if (playlistResponse.isOk) { + val resolvedPlaylistUrl = playlistResponse.url val playlistContent = playlistResponse.body?.string() if (playlistContent != null) { - audioSources.addAll(HLS.parseAndGetAudioSources(source, playlistContent, source.url)) + audioSources.addAll(HLS.parseAndGetAudioSources(source, playlistContent, resolvedPlaylistUrl)) } } } catch (e: Throwable) { diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt index 06095058..b3b877ab 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt @@ -73,6 +73,10 @@ class VideoLocal: IPlatformVideoDetails, IStoreItem { override val isShort: Boolean get() = videoSerialized.isShort; + override var playbackTime: Long = -1; + @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) + override var playbackDate: OffsetDateTime? = null; + //TODO: Offline subtitles override val subtitles: List = listOf(); diff --git a/app/src/main/java/com/futo/platformplayer/engine/dev/V8RemoteObject.kt b/app/src/main/java/com/futo/platformplayer/engine/dev/V8RemoteObject.kt index 9aa0c6cc..030c3646 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/dev/V8RemoteObject.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/dev/V8RemoteObject.kt @@ -136,7 +136,7 @@ class V8RemoteObject { } - fun List.serialize() : String { + fun List.serialize() : String { return _gson.toJson(this); } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt index 0939fbde..8bfc80e0 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt @@ -25,6 +25,7 @@ import com.futo.platformplayer.api.media.structures.IReplacerPager import com.futo.platformplayer.api.media.structures.MultiPager import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.constructs.Event3 import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.dp import com.futo.platformplayer.engine.exceptions.PluginException @@ -61,7 +62,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), private var _query: String? = null private var _searchView: SearchView? = null - val onContentClicked = Event2(); + val onContentClicked = Event3, ArrayList>?>(); val onContentUrlClicked = Event2(); val onUrlClicked = Event1(); val onChannelClicked = Event1(); @@ -208,10 +209,13 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), _searchView = searchView updateSearchViewVisibility() - _adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar, viewsToPrepend = arrayListOf(searchView)).apply { + _adapterResults = PreviewContentListAdapter(lifecycleScope, view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar, viewsToPrepend = arrayListOf(searchView)).apply { this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit); this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit); - this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit); + this.onContentClicked.subscribe { content, num -> + val results = ArrayList(_results) + this@ChannelContentsFragment.onContentClicked.emit(content, num, Pair(_pager!!, results)) + } this.onChannelClicked.subscribe(this@ChannelContentsFragment.onChannelClicked::emit); this.onAddToClicked.subscribe(this@ChannelContentsFragment.onAddToClicked::emit); this.onAddToQueueClicked.subscribe(this@ChannelContentsFragment.onAddToQueueClicked::emit); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelPlaylistsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelPlaylistsFragment.kt index cd88e610..c9509144 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelPlaylistsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelPlaylistsFragment.kt @@ -148,7 +148,7 @@ class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment { _recyclerResults = view.findViewById(R.id.recycler_videos) _adapterResults = PreviewContentListAdapter( - view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar + lifecycleScope, view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar ).apply { this.onContentUrlClicked.subscribe(this@ChannelPlaylistsFragment.onContentUrlClicked::emit) this.onUrlClicked.subscribe(this@ChannelPlaylistsFragment.onUrlClicked::emit) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt index c8f0d62e..f6e57c26 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt @@ -15,6 +15,7 @@ import android.view.ViewGroup import android.widget.* import androidx.core.animation.doOnEnd import androidx.lifecycle.lifecycleScope +import androidx.media3.common.util.UnstableApi import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs @@ -375,6 +376,7 @@ class MenuBottomBarFragment : MainActivityFragment() { fun newInstance() = MenuBottomBarFragment().apply { } + @UnstableApi //Add configurable buttons here var buttonDefinitions = listOf( ButtonDefinition(0, R.drawable.ic_home, R.drawable.ic_home_filled, R.string.home, canToggle = true, { it.currentMain is HomeFragment }, { @@ -390,13 +392,14 @@ class MenuBottomBarFragment : MainActivityFragment() { ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate(withHistory = false) }), ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate(withHistory = false) }), ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate(withHistory = false) }), + ButtonDefinition(11, R.drawable.ic_smart_display, R.drawable.ic_smart_display_filled, R.string.shorts, canToggle = true, { it.currentMain is ShortsFragment && !(it.currentMain as ShortsFragment).isChannelShortsMode }, { it.navigate(withHistory = false) }), ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate(withHistory = false) }), ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate(withHistory = false) }), ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate(withHistory = false) }), ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate(withHistory = false) }), ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate(withHistory = false) }), ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, { - val c = it.context ?: return@ButtonDefinition; + val c = it.context ?: return@ButtonDefinition; Logger.i(TAG, "settings preventPictureInPicture()"); it.requireFragment().preventPictureInPicture(); val intent = Intent(c, SettingsActivity::class.java); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt index 4cd8455c..91e6aaa3 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt @@ -211,6 +211,14 @@ class ChannelFragment : MainFragment() { } } } + adapter.onShortClicked.subscribe { v, _, pagerPair -> + when (v) { + is IPlatformVideo -> { + StatePlayer.instance.clearQueue() + fragment.navigate(Triple(v, pagerPair!!.first, pagerPair.second)) + } + } + } adapter.onAddToClicked.subscribe { content -> _overlayContainer.let { if (content is IPlatformVideo) _slideUpOverlay = diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt index fbb85dac..cb7261e5 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt @@ -4,6 +4,7 @@ import android.content.Context import android.util.Log import android.view.LayoutInflater import android.widget.LinearLayout +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.R @@ -19,6 +20,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.platforms.js.models.JSWeb import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.fragment.mainactivity.main.ShortView.Companion import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StatePlayer @@ -34,6 +36,9 @@ 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 com.futo.platformplayer.withTimestamp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlin.math.floor import kotlin.math.max @@ -59,7 +64,7 @@ abstract class ContentFeedView : FeedView state.muted = true }; _exoPlayer = player; - return PreviewContentListAdapter(context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(), arrayListOf(), shouldShowTimeBar).apply { + return PreviewContentListAdapter(fragment.lifecycleScope, context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(), arrayListOf(), shouldShowTimeBar).apply { attachAdapterEvents(this); } } @@ -246,8 +251,15 @@ abstract class ContentFeedView : FeedView? = null + private var loadLikesTask: TaskHandler>? = + null + + val onResetTriggered = Event0() + private val onPlayingToggled = Event1() + private val onLikesLoaded = Event3() + private val onLikeDislikeUpdated = Event1() + private val onVideoUpdated = Event1() + + private val bottomSheet: CommentsModalBottomSheet = CommentsModalBottomSheet() + + var likes: Long = 0 + set(value) { + field = value + likeCount.text = value.toString() + } + + var dislikes: Long = 0 + set(value) { + field = value + dislikeCount.text = value.toString() + } + + constructor(inflater: LayoutInflater, fragment: MainFragment, overlayQualityContainer: FrameLayout) : this(inflater.context) { + this.overlayQualityContainer = overlayQualityContainer + + layoutParams = LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT + ) + + this.mainFragment = fragment + bottomSheet.mainFragment = fragment + } + + // Required constructor for XML inflation + constructor(context: Context) : this(context, null, null) + + // Required constructor for XML inflation with attributes + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, null) + + // Required constructor for XML inflation with attributes and style + constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int? = null) : super( + context, attrs, defStyleAttr ?: 0 + ) { + // Inflate the layout once here + inflate(context, R.layout.view_short, this) + + // Initialize all val properties using findViewById + player = findViewById(R.id.short_player) + channelInfo = findViewById(R.id.channel_info) + creatorThumbnail = findViewById(R.id.creator_thumbnail) + channelName = findViewById(R.id.channel_name) + videoTitle = findViewById(R.id.video_title) + platformIndicator = findViewById(R.id.short_platform_indicator) + backButton = findViewById(R.id.back_button) + backButtonContainer = findViewById(R.id.back_button_container) + likeContainer = findViewById(R.id.like_container) + dislikeContainer = findViewById(R.id.dislike_container) + likeButton = findViewById(R.id.like_button) + likeCount = findViewById(R.id.like_count) + dislikeButton = findViewById(R.id.dislike_button) + dislikeCount = findViewById(R.id.dislike_count) + commentsButton = findViewById(R.id.comments_button) + shareButton = findViewById(R.id.share_button) + refreshButton = findViewById(R.id.refresh_button) + refreshButtonContainer = findViewById(R.id.refresh_button_container) + qualityButton = findViewById(R.id.quality_button) + playPauseOverlay = findViewById(R.id.play_pause_overlay) + playPauseIcon = findViewById(R.id.play_pause_icon) + overlayLoading = findViewById(R.id.short_view_loading_overlay) + overlayLoadingSpinner = findViewById(R.id.short_view_loader) + + player.setOnClickListener { + if (player.activelyPlaying) { + player.pause() + onPlayingToggled.emit(false) + } else { + player.play() + onPlayingToggled.emit(true) + } + } + + onPlayingToggled.subscribe { playing -> + if (playing) { + playPauseIcon.setImageResource(R.drawable.ic_play) + playPauseIcon.contentDescription = context.getString(R.string.play) + } else { + playPauseIcon.setImageResource(R.drawable.ic_pause) + playPauseIcon.contentDescription = context.getString(R.string.pause) + } + showPlayPauseIcon() + } + + onVideoUpdated.subscribe { + videoTitle.text = it?.name + platformIndicator.setPlatformFromClientID(it?.id?.pluginId) + creatorThumbnail.setThumbnail(it?.author?.thumbnail, true) + channelName.text = it?.author?.name + } + + backButton.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + mainFragment.closeSegment() + } + + channelInfo.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + mainFragment.navigate(video?.author) + } + + videoTitle.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + if (!bottomSheet.isAdded) { + bottomSheet.show(mainFragment.childFragmentManager, CommentsModalBottomSheet.TAG) + } + } + + commentsButton.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + if (!bottomSheet.isAdded) { + bottomSheet.show(mainFragment.childFragmentManager, CommentsModalBottomSheet.TAG) + } + } + + shareButton.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + val url = video?.shareUrl ?: video?.url + mainFragment.startActivity(Intent.createChooser(Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, url) + type = "text/plain" + }, null)) + } + + refreshButton.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + onResetTriggered.emit() + } + + refreshButton.setOnLongClickListener { + UIDialogs.toast(context, "Reload all platform shorts pagers") + false + } + + qualityButton.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + showVideoSettings() + } + + likeButton.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + val checked = !likeButton.isChecked + StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) { + if (checked) { + likes++ + } else { + likes-- + } + + likeButton.isChecked = checked + + if (dislikeButton.isChecked && checked) { + dislikeButton.isChecked = false + dislikes-- + } + + onLikeDislikeUpdated.emit( + OnLikeDislikeUpdatedArgs( + it, likes, likeButton.isChecked, dislikes, dislikeButton.isChecked + ) + ) + } + } + + dislikeButton.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + val checked = !dislikeButton.isChecked + StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) { + if (checked) { + dislikes++ + } else { + dislikes-- + } + + dislikeButton.isChecked = checked + + if (likeButton.isChecked && checked) { + likeButton.isChecked = false + likes-- + } + + onLikeDislikeUpdated.emit( + OnLikeDislikeUpdatedArgs( + it, likes, likeButton.isChecked, dislikes, dislikeButton.isChecked + ) + ) + } + } + + onLikesLoaded.subscribe(tag) { rating, liked, disliked -> + likes = rating.likes + dislikes = rating.dislikes + likeButton.isChecked = liked + dislikeButton.isChecked = disliked + + dislikeContainer.visibility = VISIBLE + likeContainer.visibility = VISIBLE + } + + player.onPlaybackStateChanged.subscribe { + val videoSource = _lastVideoSource + + if (videoSource is IDashManifestSource || videoSource is IHLSManifestSource) { + val videoTracks = + player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_VIDEO } + val audioTracks = + player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_AUDIO } + + val videoTrackFormats = mutableListOf() + val audioTrackFormats = mutableListOf() + + if (videoTracks != null) { + for (i in 0 until videoTracks.mediaTrackGroup.length) videoTrackFormats.add(videoTracks.mediaTrackGroup.getFormat(i)) + } + if (audioTracks != null) { + for (i in 0 until audioTracks.mediaTrackGroup.length) audioTrackFormats.add(audioTracks.mediaTrackGroup.getFormat(i)) + } + + updateQualitySourcesOverlay(videoDetails, null, videoTrackFormats.distinctBy { it.height } + .sortedBy { it.height }, audioTrackFormats.distinctBy { it.bitrate } + .sortedBy { it.bitrate }) + } else { + updateQualitySourcesOverlay(videoDetails, null) + } + } + } + + private fun showPlayPauseIcon() { + val overlay = playPauseOverlay + + overlay.alpha = 0f + overlay.scaleX = 0f + overlay.scaleY = 0f + overlay.visibility = VISIBLE + + overlay.animate().alpha(1f).scaleX(1f).scaleY(1f).setDuration(400) + .setInterpolator(OvershootInterpolator(1.2f)).start() + + overlay.postDelayed({ + hidePlayPauseIcon() + }, 1500) + } + + private fun hidePlayPauseIcon() { + val overlay = playPauseOverlay + + overlay.animate().alpha(0f).scaleX(0.8f).scaleY(0.8f).setDuration(300) + .setInterpolator(AccelerateInterpolator()).withEndAction { + overlay.visibility = GONE + }.start() + } + + // TODO merge this with the updateQualitySourcesOverlay for the normal video player + @androidx.annotation.OptIn(UnstableApi::class) + private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, videoLocal: VideoLocal? = null, liveStreamVideoFormats: List? = null, liveStreamAudioFormats: List? = null) { + Logger.i(TAG, "updateQualitySourcesOverlay") + + val video: IPlatformVideoDetails? + val localVideoSources: List? + val localAudioSource: List? + val localSubtitleSources: List? + + val videoSources: List? + val audioSources: List? + + if (videoDetails is VideoLocal) { + video = videoLocal?.videoSerialized + localVideoSources = videoDetails.videoSource.toList() + localAudioSource = videoDetails.audioSource.toList() + localSubtitleSources = videoDetails.subtitlesSources.toList() + videoSources = null + audioSources = null + } else { + video = videoDetails + 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 doDedup = Settings.instance.playback.simplifySources + + val bestVideoSources = if (doDedup) (videoSources?.map { it.height * it.width }?.distinct() + ?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) } + ?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource }))?.distinct() + ?.filterNotNull()?.toList() ?: listOf() else videoSources?.toList() ?: listOf() + val bestAudioContainer = + audioSources?.let { VideoHelper.selectBestAudioSource(it, FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS)?.container } + val bestAudioSources = + if (doDedup) audioSources?.filter { it.container == bestAudioContainer } + ?.plus(audioSources.filter { it is IHLSManifestAudioSource || it is IDashManifestSource }) + ?.distinct()?.toList() ?: listOf() else audioSources?.toList() ?: listOf() + + val canSetSpeed = true + val currentPlaybackRate = player.getPlaybackRate() + overlayQualitySelector = + SlideUpMenuOverlay( + this.context, overlayQualityContainer, context.getString( + R.string.quality + ), null, true, if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate)) } else null, if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply { + setButtons(listOf("0.25", "0.5", "0.75", "1.0", "1.25", "1.5", "1.75", "2.0", "2.25"), currentPlaybackRate.toString()) + onClick.subscribe { v -> + + player.setPlaybackRate(v.toFloat()) + setSelected(v) + + } + } else null, if (localVideoSources?.isNotEmpty() == true) SlideUpMenuGroup( + this.context, context.getString(R.string.offline_video), "video", *localVideoSources.map { + SlideUpMenuItem(this.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", tag = it, call = { handleSelectVideoTrack(it) }) + }.toList().toTypedArray() + ) + else null, if (localAudioSource?.isNotEmpty() == true) SlideUpMenuGroup( + this.context, context.getString(R.string.offline_audio), "audio", *localAudioSource.map { + SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), tag = it, call = { handleSelectAudioTrack(it) }) + }.toList().toTypedArray() + ) + else null, if (localSubtitleSources?.isNotEmpty() == true) SlideUpMenuGroup( + this.context, context.getString(R.string.offline_subtitles), "subtitles", *localSubtitleSources.map { + SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it, call = { handleSelectSubtitleTrack(it) }) + }.toList().toTypedArray() + ) + else null, if (liveStreamVideoFormats?.isEmpty() == false) SlideUpMenuGroup( + this.context, context.getString(R.string.stream_video), "video", (listOf( + SlideUpMenuItem(this.context, R.drawable.ic_movie, "Auto", tag = "auto", call = { player.selectVideoTrack(-1) }) + ) + (liveStreamVideoFormats.map { + SlideUpMenuItem( + this.context, R.drawable.ic_movie, it.label ?: it.containerMimeType + ?: it.bitrate.toString(), "${it.width}x${it.height}", tag = it, call = { player.selectVideoTrack(it.height) }) + })) + ) + else null, if (liveStreamAudioFormats?.isEmpty() == false) SlideUpMenuGroup( + this.context, context.getString(R.string.stream_audio), "audio", *liveStreamAudioFormats.map { + SlideUpMenuItem(this.context, R.drawable.ic_music, "${it.label ?: it.containerMimeType} ${it.bitrate}", "", tag = it, call = { player.selectAudioTrack(it.bitrate) }) + }.toList().toTypedArray() + ) + else null, if (bestVideoSources.isNotEmpty()) SlideUpMenuGroup( + this.context, context.getString(R.string.video), "video", *bestVideoSources.map { + val estSize = VideoHelper.estimateSourceSize(it) + val prefix = if (estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "" + SlideUpMenuItem(this.context, R.drawable.ic_movie, it.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", (prefix + it.codec.trim()).trim(), tag = it, call = { handleSelectVideoTrack(it) }) + }.toList().toTypedArray() + ) + else null, if (bestAudioSources.isNotEmpty()) SlideUpMenuGroup( + this.context, context.getString(R.string.audio), "audio", *bestAudioSources.map { + val estSize = VideoHelper.estimateSourceSize(it) + val prefix = if (estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "" + SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), (prefix + it.codec.trim()).trim(), tag = it, call = { handleSelectAudioTrack(it) }) + }.toList().toTypedArray() + ) + else null, if (video?.subtitles?.isNotEmpty() == true) SlideUpMenuGroup( + this.context, context.getString(R.string.subtitles), "subtitles", *video.subtitles.map { + SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it, call = { handleSelectSubtitleTrack(it) }) + }.toList().toTypedArray() + ) + else null + ) + } + + private fun handleSelectVideoTrack(videoSource: IVideoSource) { + Logger.i(TAG, "handleSelectAudioTrack(videoSource=$videoSource)") + if (_lastVideoSource == videoSource) return + + _lastVideoSource = videoSource + + playVideo(player.position) + } + + private fun handleSelectAudioTrack(audioSource: IAudioSource) { + Logger.i(TAG, "handleSelectAudioTrack(audioSource=$audioSource)") + if (_lastAudioSource == audioSource) return + + _lastAudioSource = audioSource + + playVideo(player.position) + } + + private fun handleSelectSubtitleTrack(subtitleSource: ISubtitleSource) { + Logger.i(TAG, "handleSelectSubtitleTrack(subtitleSource=$subtitleSource)") + var toSet: ISubtitleSource? = subtitleSource + if (_lastSubtitleSource == subtitleSource) toSet = null + + mainFragment.lifecycleScope.launch(Dispatchers.Main) { + try { + player.swapSubtitles(toSet) + } catch (e: Throwable) { + Logger.e(TAG, "handleSelectSubtitleTrack failed", e) + } + } + + _lastSubtitleSource = toSet + } + + private fun showVideoSettings() { + Logger.i(TAG, "showVideoSettings") + + overlayQualitySelector?.selectOption("video", _lastVideoSource) + overlayQualitySelector?.selectOption("audio", _lastAudioSource) + overlayQualitySelector?.selectOption("subtitles", _lastSubtitleSource) + + if (_lastVideoSource is IDashManifestSource || _lastVideoSource is IHLSManifestSource) { + val videoTracks = + player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_VIDEO } + + var selectedQuality: Format? = null + + if (videoTracks != null) { + for (i in 0 until videoTracks.mediaTrackGroup.length) { + if (videoTracks.mediaTrackGroup.getFormat(i).height == player.targetTrackVideoHeight) { + selectedQuality = videoTracks.mediaTrackGroup.getFormat(i) + } + } + } + + var videoMenuGroup: SlideUpMenuGroup? = null + for (view in overlayQualitySelector!!.groupItems) { + if (view is SlideUpMenuGroup && view.groupTag == "video") { + videoMenuGroup = view + } + } + + if (selectedQuality != null) { + videoMenuGroup?.getItem("auto")?.setSubText("") + overlayQualitySelector?.selectOption("video", selectedQuality) + } else { + videoMenuGroup?.getItem("auto") + ?.setSubText("${player.exoPlayer?.player?.videoFormat?.width}x${player.exoPlayer?.player?.videoFormat?.height}") + overlayQualitySelector?.selectOption("video", "auto") + } + } + + val currentPlaybackRate = player.getPlaybackRate() + overlayQualitySelector?.groupItems?.firstOrNull { it is SlideUpMenuButtonList && it.id == "playback_rate" } + ?.let { + (it as SlideUpMenuButtonList).setSelected(currentPlaybackRate.toString()) + } + + overlayQualitySelector?.show() + } + + @Suppress("unused") + fun setMainFragment(fragment: MainFragment, overlayQualityContainer: FrameLayout) { + this.mainFragment = fragment + this.bottomSheet.mainFragment = fragment + this.overlayQualityContainer = overlayQualityContainer + } + + fun changeVideo(video: IPlatformVideo, isChannelShortsMode: Boolean) { + if (this.video?.url == video.url) { + return + } + this.video = video + + refreshButtonContainer.visibility = if (isChannelShortsMode) { + GONE + } else { + VISIBLE + } + backButtonContainer.visibility = if (isChannelShortsMode) { + VISIBLE + } else { + GONE + } + + loadVideo(video.url) + } + + @Suppress("unused") + fun changeVideo(videoDetails: IPlatformVideoDetails) { + if (video?.url == videoDetails.url) { + return + } + + this.video = videoDetails + this.videoDetails = videoDetails + } + + fun play() { + loadLikes(this.video!!) + player.clear() + player.attach() + player.clear() + playVideo() + } + + fun pause() { + player.pause() + } + + fun stop() { + playWhenReady = false + + player.clear() + player.detach() + } + + fun cancel() { + loadVideoTask?.cancel() + loadLikesTask?.cancel() + } + + private fun setLoading(isLoading: Boolean) { + if (isLoading) { + (overlayLoadingSpinner.drawable as Animatable?)?.start() + overlayLoading.visibility = VISIBLE + } else { + overlayLoading.visibility = GONE + (overlayLoadingSpinner.drawable as Animatable?)?.stop() + } + } + + private fun loadLikes(video: IPlatformVideo) { + likeContainer.visibility = GONE + dislikeContainer.visibility = GONE + + loadLikesTask?.cancel() + loadLikesTask = + TaskHandler>( + StateApp.instance.scopeGetter, { + val ref = Models.referenceFromBuffer(video.url.toByteArray()) + val extraBytesRef = + video.id.value?.let { if (it.isNotEmpty()) it.toByteArray() else null } + + val queryReferencesResponse = ApiMethods.getQueryReferences( + ApiMethods.SERVER, ref, null, null, arrayListOf( + Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() + .setFromType(ContentType.OPINION.value).setValue( + ByteString.copyFrom(Opinion.like.data) + ) + .build(), Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() + .setFromType(ContentType.OPINION.value).setValue( + ByteString.copyFrom(Opinion.dislike.data) + ).build() + ), extraByteReferences = listOfNotNull(extraBytesRef) + ) + + Pair(ref, queryReferencesResponse) + }).success { (ref, queryReferencesResponse) -> + val likes = queryReferencesResponse.countsList[0] + val dislikes = queryReferencesResponse.countsList[1] + val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray()) + val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray()) + onLikesLoaded.emit(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked) + onLikeDislikeUpdated.subscribe(this) { args -> + if (args.hasLiked) { + args.processHandle.opinion(ref, Opinion.like) + } else if (args.hasDisliked) { + args.processHandle.opinion(ref, Opinion.dislike) + } else { + args.processHandle.opinion(ref, Opinion.neutral) + } + + mainFragment.lifecycleScope.launch(Dispatchers.IO) { + try { + Logger.i(CommentsModalBottomSheet.TAG, "Started backfill") + args.processHandle.fullyBackfillServersAnnounceExceptions() + Logger.i(CommentsModalBottomSheet.TAG, "Finished backfill") + } catch (e: Throwable) { + Logger.e(CommentsModalBottomSheet.TAG, "Failed to backfill servers", e) + } + } + + StatePolycentric.instance.updateLikeMap( + ref, args.hasLiked, args.hasDisliked + ) + } + } + + loadLikesTask?.run(video) + } + + private fun loadVideo(url: String) { + loadVideoTask?.cancel() + videoDetails = null + _lastVideoSource = null + _lastAudioSource = null + _lastSubtitleSource = null + + setLoading(true) + + loadVideoTask = TaskHandler( + StateApp.instance.scopeGetter, { + val result = StatePlatform.instance.getContentDetails(it).await() + if (result !is IPlatformVideoDetails) throw IllegalStateException("Expected media content, found ${result.contentType}") + return@TaskHandler result + }).success { result -> + videoDetails = result + video = result + + bottomSheet.video = result + + setLoading(false) + + if (playWhenReady) playVideo() + }.exception { + Logger.w(TAG, "exception", it) + UIDialogs.showDialog( + context, R.drawable.ic_sources, "No source enabled to support this video\n(${url})", null, null, 0, UIDialogs.Action("Close", { }, UIDialogs.ActionStyle.PRIMARY) + ) + }.exception { e -> + Logger.w(TAG, "exception", e) + UIDialogs.showDialog( + context, R.drawable.ic_security, "Authentication", e.message, null, 0, UIDialogs.Action("Cancel", {}), UIDialogs.Action("Login", { + val id = e.config.let { if (it is SourcePluginConfig) it.id else null } + val didLogin = + if (id == null) false else StatePlugins.instance.loginPlugin(context, id) { + loadVideo(url) + } + if (!didLogin) UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Failed to login") + }, UIDialogs.ActionStyle.PRIMARY) + ) + }.exception { + Logger.w(TAG, "exception", it) + UIDialogs.showSingleButtonDialog(context, R.drawable.ic_schedule, "Video is available in ${it.availableWhen}.", "Close") { } + }.exception { + Logger.w(TAG, "exception", it) + UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptimplementationexception), it, { loadVideo(url) }, null, mainFragment) + }.exception { + Logger.w(TAG, "exception", it) + UIDialogs.showDialog( + context, R.drawable.ic_lock, "Age restricted video", it.message, null, 0, UIDialogs.Action("Close", { }, UIDialogs.ActionStyle.PRIMARY) + ) + }.exception { + Logger.w(TAG, "exception", it) + UIDialogs.showDialog( + context, R.drawable.ic_lock, context.getString(R.string.unavailable_video), context.getString(R.string.this_video_is_unavailable), null, 0, UIDialogs.Action(context.getString(R.string.close), { }, UIDialogs.ActionStyle.PRIMARY) + ) + }.exception { + Logger.w(TAG, "exception", it) + UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptexception), it, { loadVideo(url) }, null, mainFragment) + }.exception { + Logger.w(ChannelFragment.TAG, "Failed to load video.", it) + UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video), it, { loadVideo(url) }, null, mainFragment) + } + + loadVideoTask?.run(url) + } + + private fun playVideo(resumePositionMs: Long = 0) { + val videoDetails = this@ShortView.videoDetails + + if (videoDetails === null) { + playWhenReady = true + return + } + + updateQualitySourcesOverlay(videoDetails, null) + + try { + val videoSource = _lastVideoSource + ?: player.getPreferredVideoSource(videoDetails, Settings.instance.playback.getCurrentPreferredQualityPixelCount()) + val audioSource = _lastAudioSource + ?: player.getPreferredAudioSource(videoDetails, Settings.instance.playback.getPrimaryLanguage(context)) + val subtitleSource = _lastSubtitleSource + ?: (if (videoDetails is VideoLocal) videoDetails.subtitlesSources.firstOrNull() else null) + Logger.i(TAG, "loadCurrentVideo(videoSource=$videoSource, audioSource=$audioSource, subtitleSource=$subtitleSource, resumePositionMs=$resumePositionMs)") + + if (videoSource == null && audioSource == null) { + UIDialogs.showDialog( + context, R.drawable.ic_lock, context.getString(R.string.unavailable_video), context.getString(R.string.this_video_is_unavailable), null, 0, UIDialogs.Action(context.getString(R.string.close), { }, UIDialogs.ActionStyle.PRIMARY) + ) + StatePlatform.instance.clearContentDetailCache(videoDetails.url) + return + } + + val thumbnail = videoDetails.thumbnails.getHQThumbnail() + if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap() + .load(thumbnail).into(object : CustomTarget() { + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + player.setArtwork(resource.toDrawable(resources)) + } + + override fun onLoadCleared(placeholder: Drawable?) { + player.setArtwork(null) + } + }) + else player.setArtwork(null) + + mainFragment.lifecycleScope.launch(Dispatchers.Main) { + try { + player.setSource(videoSource, audioSource, play = true, keepSubtitles = false, resume = resumePositionMs > 0) + if (subtitleSource != null) player.swapSubtitles(subtitleSource) + player.seekTo(resumePositionMs) + } catch (e: Throwable) { + Logger.e(TAG, "playVideo failed", e) + } + } + + _lastVideoSource = videoSource + _lastAudioSource = audioSource + _lastSubtitleSource = subtitleSource + } catch (ex: UnsupportedCastException) { + Logger.e(TAG, "Failed to load cast media", ex) + UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.unsupported_cast_format), ex) + } catch (ex: Throwable) { + Logger.e(TAG, "Failed to load media", ex) + UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_load_media), ex) + } + } + + companion object { + const val TAG = "VideoDetailView" + } + + class CommentsModalBottomSheet : BottomSheetDialogFragment() { + var mainFragment: MainFragment? = null + + private lateinit var containerContent: FrameLayout + private lateinit var containerContentMain: LinearLayout + private lateinit var containerContentReplies: RepliesOverlay + private lateinit var containerContentDescription: DescriptionOverlay + private lateinit var containerContentSupport: SupportOverlay + + private lateinit var title: TextView + private lateinit var subTitle: TextView + private lateinit var channelName: TextView + private lateinit var channelMeta: TextView + private lateinit var creatorThumbnail: CreatorThumbnail + private lateinit var channelButton: LinearLayout + private lateinit var monetization: MonetizationView + private lateinit var platform: PlatformIndicator + private lateinit var textLikes: TextView + private lateinit var textDislikes: TextView + private lateinit var layoutRating: LinearLayout + private lateinit var imageDislikeIcon: ImageView + private lateinit var imageLikeIcon: ImageView + + private lateinit var description: TextView + private lateinit var descriptionContainer: LinearLayout + private lateinit var descriptionViewMore: TextView + + private lateinit var commentsList: CommentsList + private lateinit var addCommentView: AddCommentView + + private var polycentricProfile: PolycentricProfile? = null + + private lateinit var buttonPolycentric: Button + private lateinit var buttonPlatform: Button + + private var tabIndex: Int? = null + + private var contentOverlayView: View? = null + + lateinit var video: IPlatformVideoDetails + + private lateinit var behavior: BottomSheetBehavior + + private val _taskLoadPolycentricProfile = + TaskHandler(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) }).success { setPolycentricProfile(it, animate = true) } + .exception { + Logger.w(TAG, "Failed to load claims.", it) + } + + override fun onCreateDialog( + savedInstanceState: Bundle?, + ): Dialog { + val bottomSheetDialog = + BottomSheetDialog(requireContext(), R.style.Custom_BottomSheetDialog_Theme) + bottomSheetDialog.setContentView(R.layout.modal_comments) + + behavior = bottomSheetDialog.behavior + + // TODO figure out how to not need all of these non null assertions + containerContent = bottomSheetDialog.findViewById(R.id.content_container)!! + containerContentMain = bottomSheetDialog.findViewById(R.id.videodetail_container_main)!! + containerContentReplies = + bottomSheetDialog.findViewById(R.id.videodetail_container_replies)!! + containerContentDescription = + bottomSheetDialog.findViewById(R.id.videodetail_container_description)!! + containerContentSupport = + bottomSheetDialog.findViewById(R.id.videodetail_container_support)!! + + title = bottomSheetDialog.findViewById(R.id.videodetail_title)!! + subTitle = bottomSheetDialog.findViewById(R.id.videodetail_meta)!! + channelName = bottomSheetDialog.findViewById(R.id.videodetail_channel_name)!! + channelMeta = bottomSheetDialog.findViewById(R.id.videodetail_channel_meta)!! + creatorThumbnail = bottomSheetDialog.findViewById(R.id.creator_thumbnail)!! + channelButton = bottomSheetDialog.findViewById(R.id.videodetail_channel_button)!! + monetization = bottomSheetDialog.findViewById(R.id.monetization)!! + platform = bottomSheetDialog.findViewById(R.id.videodetail_platform)!! + layoutRating = bottomSheetDialog.findViewById(R.id.layout_rating)!! + textDislikes = bottomSheetDialog.findViewById(R.id.text_dislikes)!! + textLikes = bottomSheetDialog.findViewById(R.id.text_likes)!! + imageLikeIcon = bottomSheetDialog.findViewById(R.id.image_like_icon)!! + imageDislikeIcon = bottomSheetDialog.findViewById(R.id.image_dislike_icon)!! + + description = bottomSheetDialog.findViewById(R.id.videodetail_description)!! + descriptionContainer = + bottomSheetDialog.findViewById(R.id.videodetail_description_container)!! + descriptionViewMore = + bottomSheetDialog.findViewById(R.id.videodetail_description_view_more)!! + + addCommentView = bottomSheetDialog.findViewById(R.id.add_comment_view)!! + commentsList = bottomSheetDialog.findViewById(R.id.comments_list)!! + buttonPolycentric = bottomSheetDialog.findViewById(R.id.button_polycentric)!! + buttonPlatform = bottomSheetDialog.findViewById(R.id.button_platform)!! + + commentsList.onAuthorClick.subscribe { c -> + if (c !is PolycentricPlatformComment) { + return@subscribe + } + val id = c.author.id.value + + Logger.i(TAG, "onAuthorClick: $id") + if (id != null && id.startsWith("polycentric://")) { + val navUrl = "https://harbor.social/" + id.substring("polycentric://".length) + mainFragment!!.startActivity(Intent(Intent.ACTION_VIEW, navUrl.toUri())) + } + } + commentsList.onRepliesClick.subscribe { c -> + val replyCount = c.replyCount ?: 0 + var metadata = "" + if (replyCount > 0) { + metadata += "$replyCount " + requireContext().getString(R.string.replies) + } + + if (c is PolycentricPlatformComment) { + var parentComment: PolycentricPlatformComment = c + containerContentReplies.load(tabIndex!! != 0, metadata, c.contextUrl, c.reference, c, { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) }, { + val newComment = parentComment.cloneWithUpdatedReplyCount( + (parentComment.replyCount ?: 0) + 1 + ) + commentsList.replaceComment(parentComment, newComment) + parentComment = newComment + }) + } else { + containerContentReplies.load(tabIndex!! != 0, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) }) + } + animateOpenOverlayView(containerContentReplies) + } + + if (StatePolycentric.instance.enabled) { + buttonPolycentric.setOnClickListener { + setTabIndex(0) + StateMeta.instance.setLastCommentSection(0) + } + } else { + buttonPolycentric.visibility = GONE + } + + buttonPlatform.setOnClickListener { + setTabIndex(1) + StateMeta.instance.setLastCommentSection(1) + } + + val ref = Models.referenceFromBuffer(video.url.toByteArray()) + addCommentView.setContext(video.url, ref) + + if (Settings.instance.comments.recommendationsDefault && !Settings.instance.comments.hideRecommendations) { + setTabIndex(2, true) + } else { + when (Settings.instance.comments.defaultCommentSection) { + 0 -> if (Settings.instance.other.polycentricEnabled) setTabIndex(0, true) else setTabIndex(1, true) + 1 -> setTabIndex(1, true) + 2 -> setTabIndex(StateMeta.instance.getLastCommentSection(), true) + } + } + + containerContentDescription.onClose.subscribe { animateCloseOverlayView() } + containerContentReplies.onClose.subscribe { animateCloseOverlayView() } + + descriptionViewMore.setOnClickListener { + animateOpenOverlayView(containerContentDescription) + } + + updateDescriptionUI(video.description.fixHtmlLinks()) + + val dp5 = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics) + val dp2 = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, resources.displayMetrics) + + //UI + title.text = video.name + channelName.text = video.author.name + if (video.author.subscribers != null) { + channelMeta.text = if ((video.author.subscribers + ?: 0) > 0 + ) video.author.subscribers!!.toHumanNumber() + " " + requireContext().getString(R.string.subscribers) else "" + (channelName.layoutParams as MarginLayoutParams).setMargins( + 0, (dp5 * -1).toInt(), 0, 0 + ) + } else { + channelMeta.text = "" + (channelName.layoutParams as MarginLayoutParams).setMargins(0, (dp2).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) + } + + val subTitleSegments: ArrayList = ArrayList() + if (video.viewCount > 0) subTitleSegments.add("${video.viewCount.toHumanNumber()} ${if (video.isLive) requireContext().getString(R.string.watching_now) else requireContext().getString(R.string.views)}") + if (video.datetime != null) { + val diff = video.datetime?.getNowDiffSeconds() ?: 0 + val ago = video.datetime?.toHumanNowDiffString(true) + if (diff >= 0) subTitleSegments.add("$ago ago") + else subTitleSegments.add("available in $ago") + } + + platform.setPlatformFromClientID(video.id.pluginId) + subTitle.text = subTitleSegments.joinToString(" • ") + creatorThumbnail.setThumbnail(video.author.thumbnail, false) + + setPolycentricProfile(null, animate = false) + _taskLoadPolycentricProfile.run(video.author.id) + + when (video.rating) { + is RatingLikeDislikes -> { + val r = video.rating as RatingLikeDislikes + layoutRating.visibility = VISIBLE + + textLikes.visibility = VISIBLE + imageLikeIcon.visibility = VISIBLE + textLikes.text = r.likes.toHumanNumber() + + imageDislikeIcon.visibility = VISIBLE + textDislikes.visibility = VISIBLE + textDislikes.text = r.dislikes.toHumanNumber() + } + + is RatingLikes -> { + val r = video.rating as RatingLikes + layoutRating.visibility = VISIBLE + + textLikes.visibility = VISIBLE + imageLikeIcon.visibility = VISIBLE + textLikes.text = r.likes.toHumanNumber() + + imageDislikeIcon.visibility = GONE + textDislikes.visibility = GONE + } + + else -> { + layoutRating.visibility = GONE + } + } + + monetization.onSupportTap.subscribe { + containerContentSupport.setPolycentricProfile(polycentricProfile) + animateOpenOverlayView(containerContentSupport) + } + + monetization.onStoreTap.subscribe { + polycentricProfile?.systemState?.store?.let { + try { + val uri = it.toUri() + val intent = Intent(Intent.ACTION_VIEW) + intent.data = uri + requireContext().startActivity(intent) + } catch (e: Throwable) { + Logger.e(TAG, "Failed to open URI: '${it}'.", e) + } + } + } + monetization.onUrlTap.subscribe { + mainFragment!!.navigate(it) + } + + addCommentView.onCommentAdded.subscribe { + commentsList.addComment(it) + } + + channelButton.setOnClickListener { + mainFragment!!.navigate(video.author) + } + + return bottomSheetDialog + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + animateCloseOverlayView() + } + + private fun setPolycentricProfile(profile: PolycentricProfile?, animate: Boolean) { + polycentricProfile = profile + + val dp35 = 35.dp(requireContext().resources) + val avatar = profile?.systemState?.avatar?.selectBestImage(dp35 * dp35) + ?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) } + + if (avatar != null) { + creatorThumbnail.setThumbnail(avatar, animate) + } else { + creatorThumbnail.setThumbnail(video.author.thumbnail, animate) + creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto()) + } + + val username = profile?.systemState?.username + if (username != null) { + channelName.text = username + } + + monetization.setPolycentricProfile(profile) + } + + private fun setTabIndex(index: Int?, forceReload: Boolean = false) { + Logger.i(TAG, "setTabIndex (index: ${index}, forceReload: ${forceReload})") + val changed = tabIndex != index || forceReload + if (!changed) { + return + } + + tabIndex = index + buttonPlatform.setTextColor(resources.getColor(if (index == 1) R.color.white else R.color.gray_ac, null)) + buttonPolycentric.setTextColor(resources.getColor(if (index == 0) R.color.white else R.color.gray_ac, null)) + + when (index) { + null -> { + addCommentView.visibility = GONE + commentsList.clear() + } + + 0 -> { + addCommentView.visibility = VISIBLE + fetchPolycentricComments() + } + + 1 -> { + addCommentView.visibility = GONE + fetchComments() + } + } + } + + private fun fetchComments() { + Logger.i(TAG, "fetchComments") + video.let { + commentsList.load(true) { StatePlatform.instance.getComments(it) } + } + } + + private fun fetchPolycentricComments() { + Logger.i(TAG, "fetchPolycentricComments") + val video = video + val idValue = video.id.value + if (video.url.isEmpty()) { + Logger.w(TAG, "Failed to fetch polycentric comments because url was null") + commentsList.clear() + return + } + + val ref = Models.referenceFromBuffer(video.url.toByteArray()) + val extraBytesRef = idValue?.let { if (it.isNotEmpty()) it.toByteArray() else null } + commentsList.load(false) { StatePolycentric.instance.getCommentPager(video.url, ref, listOfNotNull(extraBytesRef)); } + } + + private fun updateDescriptionUI(text: Spanned) { + containerContentDescription.load(text) + description.text = text + + if (description.text.isNotEmpty()) descriptionContainer.visibility = VISIBLE + else descriptionContainer.visibility = GONE + } + + private fun animateOpenOverlayView(view: View) { + if (contentOverlayView != null) { + Logger.e(TAG, "Content overlay already open") + return + } + + behavior.isDraggable = false + behavior.state = BottomSheetBehavior.STATE_EXPANDED + + val animHeight = containerContentMain.height + + view.translationY = animHeight.toFloat() + view.visibility = VISIBLE + + view.animate().setDuration(300).translationY(0f).withEndAction { + contentOverlayView = view + }.start() + } + + private fun animateCloseOverlayView() { + val curView = contentOverlayView + if (curView == null) { + Logger.e(TAG, "No content overlay open") + return + } + + behavior.isDraggable = true + + val animHeight = contentOverlayView!!.height + + curView.animate().setDuration(300).translationY(animHeight.toFloat()).withEndAction { + curView.visibility = GONE + contentOverlayView = null + }.start() + } + + companion object { + const val TAG = "ModalBottomSheet" + } + } +} diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt new file mode 100644 index 00000000..f082c91d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt @@ -0,0 +1,359 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.annotation.SuppressLint +import android.graphics.drawable.Animatable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.SoundEffectConstants +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 +import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.views.buttons.BigButton + +@UnstableApi +class ShortsFragment : MainFragment() { + override val isMainView: Boolean = true + override val isTab: Boolean = true + override val hasBottomBar: Boolean get() = true + + private var loadPagerTask: TaskHandler>? = null + private var nextPageTask: TaskHandler>? = null + + private var mainShortsPager: IPager? = null + private val mainShorts: MutableList = mutableListOf() + + // the pager to call next on + private var currentShortsPager: IPager? = null + + // the shorts array bound to the ViewPager2 adapter + private val currentShorts: MutableList = mutableListOf() + + private var channelShortsPager: IPager? = null + private val channelShorts: MutableList = mutableListOf() + val isChannelShortsMode: Boolean + get() = channelShortsPager != null + + private var viewPager: ViewPager2? = null + private lateinit var zeroState: LinearLayout + private lateinit var sourcesButton: BigButton + private lateinit var overlayLoading: FrameLayout + private lateinit var overlayLoadingSpinner: ImageView + private lateinit var overlayQualityContainer: FrameLayout + private var customViewAdapter: CustomViewAdapter? = null + + // we just completely reset the data structure so we want to tell the adapter that + @SuppressLint("NotifyDataSetChanged") + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + (activity as MainActivity?)?.getFragment()?.closeVideoDetails() + super.onShownWithView(parameter, isBack) + + if (parameter is Triple<*, *, *>) { + setLoading(false) + channelShorts.clear() + @Suppress("UNCHECKED_CAST") // TODO replace with a strongly typed parameter + channelShorts.addAll(parameter.third as ArrayList) + @Suppress("UNCHECKED_CAST") // TODO replace with a strongly typed parameter + channelShortsPager = parameter.second as IPager + + currentShorts.clear() + currentShorts.addAll(channelShorts) + currentShortsPager = channelShortsPager + + viewPager?.adapter?.notifyDataSetChanged() + + viewPager?.post { + viewPager?.currentItem = channelShorts.indexOfFirst { + return@indexOfFirst (parameter.first as IPlatformVideo).id == it.id + } + } + } else if (isChannelShortsMode) { + channelShortsPager = null + channelShorts.clear() + currentShorts.clear() + + if (loadPagerTask == null) { + currentShorts.addAll(mainShorts) + currentShortsPager = mainShortsPager + } else { + setLoading(true) + } + + viewPager?.adapter?.notifyDataSetChanged() + viewPager?.currentItem = 0 + } + + updateZeroState() + } + + override fun onCreateMainView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + return inflater.inflate(R.layout.fragment_shorts, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewPager = view.findViewById(R.id.view_pager) + zeroState = view.findViewById(R.id.zero_state) + sourcesButton = view.findViewById(R.id.sources_button) + overlayLoading = view.findViewById(R.id.short_view_loading_overlay) + overlayLoadingSpinner = view.findViewById(R.id.short_view_loader) + overlayQualityContainer = view.findViewById(R.id.shorts_quality_overview) + + sourcesButton.onClick.subscribe { + sourcesButton.playSoundEffect(SoundEffectConstants.CLICK) + navigate() + } + + setLoading(true) + + Logger.i(TAG, "Creating adapter") + val customViewAdapter = + CustomViewAdapter(currentShorts, layoutInflater, this@ShortsFragment, overlayQualityContainer, { isChannelShortsMode }) { + if (!currentShortsPager!!.hasMorePages()) { + return@CustomViewAdapter + } + nextPage() + } + customViewAdapter.onResetTriggered.subscribe { + setLoading(true) + loadPager() + + loadPagerTask!!.success { + setLoading(false) + } + } + val viewPager = viewPager!! + viewPager.adapter = customViewAdapter + + this.customViewAdapter = customViewAdapter + + if (loadPagerTask == null && currentShorts.isEmpty()) { + loadPager() + + loadPagerTask!!.success { + setLoading(false) + updateZeroState() + } + } else { + setLoading(false) + updateZeroState() + } + + viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + fun play(adapter: CustomViewAdapter, position: Int) { + val recycler = (viewPager.getChildAt(0) as RecyclerView) + val viewHolder = + recycler.findViewHolderForAdapterPosition(position) as CustomViewHolder? + + if (viewHolder == null) { + adapter.needToPlay = position + } else { + val focusedView = viewHolder.shortView + focusedView.play() + adapter.previousShownView = focusedView + } + } + + override fun onPageSelected(position: Int) { + val adapter = (viewPager.adapter as CustomViewAdapter) + if (adapter.previousShownView == null) { + // play if this page selection didn't trigger by a swipe from another page + play(adapter, position) + } else { + adapter.previousShownView?.stop() + adapter.previousShownView = null + adapter.newPosition = position + } + } + + // wait for the state to idle to prevent UI lag + override fun onPageScrollStateChanged(state: Int) { + super.onPageScrollStateChanged(state) + if (state == ViewPager2.SCROLL_STATE_IDLE) { + val adapter = (viewPager.adapter as CustomViewAdapter) + val position = adapter.newPosition ?: return + adapter.newPosition = null + + play(adapter, position) + } + } + }) + } + + private fun updateZeroState() { + if (mainShorts.isEmpty() && !isChannelShortsMode && loadPagerTask == null) { + zeroState.visibility = View.VISIBLE + } else { + zeroState.visibility = View.GONE + } + } + + private fun nextPage() { + nextPageTask?.cancel() + + val nextPageTask = + TaskHandler>(StateApp.instance.scopeGetter, { + currentShortsPager!!.nextPage() + + return@TaskHandler currentShortsPager!!.getResults() + }).success { newVideos -> + val prevCount = customViewAdapter!!.itemCount + currentShorts.addAll(newVideos) + if (isChannelShortsMode) { + channelShorts.addAll(newVideos) + } else { + mainShorts.addAll(newVideos) + } + customViewAdapter!!.notifyItemRangeInserted(prevCount, newVideos.size) + nextPageTask = null + } + + nextPageTask.run(this) + + this.nextPageTask = nextPageTask + } + + // we just completely reset the data structure so we want to tell the adapter that + @SuppressLint("NotifyDataSetChanged") + private fun loadPager() { + loadPagerTask?.cancel() + + val loadPagerTask = + TaskHandler>(StateApp.instance.scopeGetter, { + val pager = StatePlatform.instance.getShorts() + + return@TaskHandler pager + }).success { pager -> + mainShorts.clear() + mainShorts.addAll(pager.getResults()) + mainShortsPager = pager + + if (!isChannelShortsMode) { + currentShorts.clear() + currentShorts.addAll(mainShorts) + currentShortsPager = pager + + // if the view pager exists go back to the beginning + viewPager?.adapter?.notifyDataSetChanged() + viewPager?.currentItem = 0 + } + + loadPagerTask = null + }.exception { err -> + val message = "Unable to load shorts $err" + Logger.i(TAG, message) + if (context != null) { + UIDialogs.showDialog( + requireContext(), R.drawable.ic_sources, message, null, null, 0, UIDialogs.Action( + "Close", { }, UIDialogs.ActionStyle.PRIMARY + ) + ) + } + return@exception + } + + this.loadPagerTask = loadPagerTask + + loadPagerTask.run(this) + } + + private fun setLoading(isLoading: Boolean) { + if (isLoading) { + (overlayLoadingSpinner.drawable as Animatable?)?.start() + overlayLoading.visibility = View.VISIBLE + } else { + overlayLoading.visibility = View.GONE + (overlayLoadingSpinner.drawable as Animatable?)?.stop() + } + } + + override fun onPause() { + super.onPause() + customViewAdapter?.previousShownView?.pause() + } + + override fun onDestroy() { + super.onDestroy() + loadPagerTask?.cancel() + loadPagerTask = null + nextPageTask?.cancel() + nextPageTask = null + customViewAdapter?.previousShownView?.stop() + } + + companion object { + private const val TAG = "ShortsFragment" + + fun newInstance() = ShortsFragment() + } + + class CustomViewAdapter( + private val videos: MutableList, + private val inflater: LayoutInflater, + private val fragment: MainFragment, + private val overlayQualityContainer: FrameLayout, + private val isChannelShortsMode: () -> Boolean, + private val onNearEnd: () -> Unit, + ) : RecyclerView.Adapter() { + val onResetTriggered = Event0() + var previousShownView: ShortView? = null + var newPosition: Int? = null + var needToPlay: Int? = null + + @OptIn(UnstableApi::class) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomViewHolder { + val shortView = ShortView(inflater, fragment, overlayQualityContainer) + shortView.onResetTriggered.subscribe { + onResetTriggered.emit() + } + return CustomViewHolder(shortView) + } + + @OptIn(UnstableApi::class) + override fun onBindViewHolder(holder: CustomViewHolder, position: Int) { + holder.shortView.changeVideo(videos[position], isChannelShortsMode()) + + if (position == itemCount - 1) { + onNearEnd() + } + } + + override fun onViewRecycled(holder: CustomViewHolder) { + super.onViewRecycled(holder) + holder.shortView.cancel() + } + + override fun onViewAttachedToWindow(holder: CustomViewHolder) { + super.onViewAttachedToWindow(holder) + + if (holder.absoluteAdapterPosition == needToPlay) { + holder.shortView.play() + needToPlay = null + previousShownView = holder.shortView + } + } + + override fun getItemCount(): Int = videos.size + } + + @OptIn(UnstableApi::class) + class CustomViewHolder(val shortView: ShortView) : RecyclerView.ViewHolder(shortView) +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt index e0a68cc5..6c2c5b04 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt @@ -25,10 +25,10 @@ data class SuggestionsFragmentData(val query: String, val searchType: SearchType class SuggestionsFragment : MainFragment { override val isMainView : Boolean = true; - override val hasBottomBar: Boolean = false; + override val hasBottomBar: Boolean = true; override val isHistory: Boolean = false; - private var _recyclerSuggestions: RecyclerView? = null; + private var _recyclerSuggestions: RecyclerView? = null; private var _llmSuggestions: LinearLayoutManager? = null; private var _radioGroupView: RadioGroupView? = null; private val _suggestions: ArrayList = ArrayList(); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/TutorialFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/TutorialFragment.kt index ba1c60d6..73ee0295 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/TutorialFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/TutorialFragment.kt @@ -32,6 +32,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment +import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import com.futo.platformplayer.views.pills.WidePillButton import java.time.OffsetDateTime @@ -152,6 +153,9 @@ class TutorialFragment : MainFragment() { override val viewCount: Long = -1 override val video: IVideoSourceDescriptor = TutorialVideoSourceDescriptor(videoUrl, duration, width, height) override val isShort: Boolean = false; + override var playbackTime: Long = -1; + @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) + override var playbackDate: OffsetDateTime? = null; override fun getComments(client: IPlatformClient): IPager { return EmptyPager() } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt index 3404de15..304f52b2 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt @@ -337,13 +337,6 @@ class VideoDetailFragment() : MainFragment() { closeVideoDetails(); }; it.onMaximize.subscribe { maximizeVideoDetail(it) }; - it.onPlayChanged.subscribe { - if(isInPictureInPicture) { - val params = _viewDetail?.getPictureInPictureParams(); - if (params != null) - activity?.setPictureInPictureParams(params); - } - }; it.onEnterPictureInPicture.subscribe { Logger.i(TAG, "onEnterPictureInPicture") isInPictureInPicture = true; @@ -446,9 +439,14 @@ class VideoDetailFragment() : MainFragment() { val viewDetail = _viewDetail; Logger.i(TAG, "onUserLeaveHint preventPictureInPicture=${viewDetail?.preventPictureInPicture} isCasting=${StateCasting.instance.isCasting} isBackgroundPictureInPicture=${Settings.instance.playback.isBackgroundPictureInPicture()} allowBackground=${viewDetail?.allowBackground}"); - if(viewDetail?.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.allowBackground) { - _leavingPiP = false; + if (viewDetail === null) { + return + } + if (viewDetail.shouldEnterPictureInPicture) { + _leavingPiP = false + } + if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.allowBackground) { val params = _viewDetail?.getPictureInPictureParams(); if(params != null) { Logger.i(TAG, "enterPictureInPictureMode") @@ -457,7 +455,7 @@ class VideoDetailFragment() : MainFragment() { } if (isFullscreen) { - viewDetail?.restoreBrightness() + viewDetail.restoreBrightness() } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index 780917ed..5382ef13 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -16,6 +16,7 @@ import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.Icon import android.net.Uri +import android.os.Build import android.support.v4.media.session.PlaybackStateCompat import android.text.Spanned import android.util.AttributeSet @@ -50,6 +51,7 @@ import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.activities.SyncShowPairingCodeActivity.Companion.activity import com.futo.platformplayer.api.media.IPluginSourced import com.futo.platformplayer.api.media.LiveChatManager import com.futo.platformplayer.api.media.PlatformID @@ -247,7 +249,13 @@ class VideoDetailView : ConstraintLayout { private val _buttonPins: RoundButtonGroup; //private val _buttonMore: RoundButton; - var preventPictureInPicture: Boolean = false; + var preventPictureInPicture: Boolean = false + set(value) { + if (field != value) { + field = value + onShouldEnterPictureInPictureChanged.emit() + } + } private val _addCommentView: AddCommentView; private var _tabIndex: Int? = null; @@ -316,11 +324,24 @@ class VideoDetailView : ConstraintLayout { val onClose = Event0(); val onFullscreenChanged = Event1(); val onEnterPictureInPicture = Event0(); - val onPlayChanged = Event1(); val onVideoChanged = Event2() - var allowBackground : Boolean = false - private set; + var allowBackground: Boolean = false + private set(value) { + if (field != value) { + field = value + onShouldEnterPictureInPictureChanged.emit() + } + } + + val shouldEnterPictureInPicture: Boolean + get() = !preventPictureInPicture && + !StateCasting.instance.isCasting && + Settings.instance.playback.isBackgroundPictureInPicture() && + !allowBackground && + isPlaying + + val onShouldEnterPictureInPictureChanged = Event0(); val onTouchCancel = Event0(); private var _lastPositionSaveTime: Long = -1; @@ -433,6 +454,29 @@ class VideoDetailView : ConstraintLayout { fragment.navigate(it.targetUrl); }; + _container_content_liveChat.onUrlClick.subscribe { uri -> + val c = context + if (c is MainActivity) { + fragment.lifecycleScope.launch(Dispatchers.Main) { + try { + if (!c.handleUrl(uri.toString())) { + Intent(Intent.ACTION_VIEW, uri).apply { + addCategory(Intent.CATEGORY_BROWSABLE) + context.startActivity(this) + } + } + } catch (e: Throwable) { + Log.e(TAG, "Failed to handle live chat URL") + } + } + } else { + Intent(Intent.ACTION_VIEW, uri).apply { + addCategory(Intent.CATEGORY_BROWSABLE) + context.startActivity(this) + } + } + } + _monetization.onSupportTap.subscribe { _container_content_support.setPolycentricProfile(_polycentricProfile); switchContentView(_container_content_support); @@ -457,11 +501,6 @@ class VideoDetailView : ConstraintLayout { _player.attachPlayer(); - _container_content_liveChat.onRaidNow.subscribe { - StatePlayer.instance.clearQueue(); - fragment.navigate(it.targetUrl); - }; - StateApp.instance.preventPictureInPicture.subscribe(this) { Logger.i(TAG, "StateApp.instance.preventPictureInPicture.subscribe preventPictureInPicture = true"); preventPictureInPicture = true; @@ -622,8 +661,13 @@ class VideoDetailView : ConstraintLayout { } }; + onShouldEnterPictureInPictureChanged.subscribe { + val params = getPictureInPictureParams() + fragment.activity?.setPictureInPictureParams(params) + } + if (!isInEditMode) { - StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState -> + StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { device, connectionState -> if (_onPauseCalled) { return@subscribe; } @@ -635,7 +679,7 @@ class VideoDetailView : ConstraintLayout { setCastEnabled(true); } CastConnectionState.DISCONNECTED -> { - loadCurrentVideo(lastPositionMilliseconds); + loadCurrentVideo(lastPositionMilliseconds, playWhenReady = device.isPlaying); updatePillButtonVisibilities(); setCastEnabled(false); @@ -719,7 +763,7 @@ class VideoDetailView : ConstraintLayout { }; MediaControlReceiver.onBackgroundReceived.subscribe(this) { Logger.i(TAG, "MediaControlReceiver.onBackgroundReceived") - _player.switchToAudioMode(); + _player.switchToAudioMode(video); allowBackground = true; StateApp.instance.contextOrNull?.let { try { @@ -938,6 +982,7 @@ class VideoDetailView : ConstraintLayout { return@let it.config.reduceFunctionsInLimitedVersion && BuildConfig.IS_PLAYSTORE_BUILD else false; } ?: false; + val buttons = listOf(RoundButton(context, R.drawable.ic_add, context.getString(R.string.add), TAG_ADD) { (video ?: _searchVideo)?.let { _slideUpOverlay = UISlideOverlays.showAddToOverlay(it, _overlayContainer) { @@ -966,7 +1011,7 @@ class VideoDetailView : ConstraintLayout { } else null, if (!isLimitedVersion) RoundButton(context, R.drawable.ic_screen_share, if (allowBackground) context.getString(R.string.background_revert) else context.getString(R.string.background), TAG_BACKGROUND) { if (!allowBackground) { - _player.switchToAudioMode(); + _player.switchToAudioMode(video); allowBackground = true; it.text.text = resources.getString(R.string.background_revert); } else { @@ -1113,6 +1158,7 @@ class VideoDetailView : ConstraintLayout { //Requested behavior to leave it in audio mode. leaving it commented if it causes issues, revert? if(!allowBackground) { _player.switchToVideoMode(); + allowBackground = false; _buttonPins.getButtonByTag(TAG_BACKGROUND)?.text?.text = resources.getString(R.string.background); } } @@ -1136,12 +1182,18 @@ class VideoDetailView : ConstraintLayout { when (Settings.instance.playback.backgroundPlay) { 0 -> handlePause(); 1 -> { - if(!(video?.isLive ?: false)) - _player.switchToAudioMode(); + if(!(video?.isLive ?: false)) { + _player.switchToAudioMode(video); + allowBackground = true; + } StatePlayer.instance.startOrUpdateMediaSession(context, video); } } } + + if (_player.isFullScreen) { + restoreBrightness() + } } fun onStop() { Logger.i(TAG, "onStop"); @@ -1743,12 +1795,19 @@ class VideoDetailView : ConstraintLayout { _liveChat?.stop(); _liveChat = null; + var gotLive = false; if (video.isLive && video.live != null) { loadLiveChat(video); + gotLive = true; } - if (video.isLive && video.live == null && !video.video.videoSources.any()) + if (video.isLive && video.live == null && !video.video.videoSources.any()) { startLiveTry(video); - + gotLive = true; + } + if(!gotLive && video is JSVideoDetails && video.hasVODEvents()) { + Logger.i(TAG, "Loading VOD chat"); + loadVODChat(video); + } _player.updateNextPrevious(); updateMoreButtons(); @@ -1772,6 +1831,43 @@ class VideoDetailView : ConstraintLayout { _taskLoadRecommendations.run(videoDetail.url) } } + fun loadVODChat(video: IPlatformVideoDetails) { + _liveChat?.stop(); + _container_content_liveChat.cancel(); + _liveChat = null; + if(video !is JSVideoDetails) + return; + fragment.lifecycleScope.launch(Dispatchers.IO) { + try { + var livePager: IPager?; + try { + //TODO: Create video.getLiveEvents shortcut/optimalization + livePager = video.getVODEvents(video.url); + } catch (ex: Throwable) { + Logger.e(TAG, "Failed to obtain VODEvents pager", ex); + livePager = null; + } + val liveChat = livePager?.let { + val liveChatManager = LiveChatManager(fragment.lifecycleScope, livePager, video.viewCount); + liveChatManager.start(); + return@let liveChatManager; + } + _liveChat = liveChat; + + fragment.lifecycleScope.launch(Dispatchers.Main) { + try { + _container_content_liveChat.load(fragment.lifecycleScope, liveChat, null, if(liveChat != null) video.viewCount else null); + switchContentView(_container_content_liveChat); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to switch content view to vod chat.", e); + } + } + } + catch(ex: Throwable) { + Logger.e(TAG, "Failed to load vod chat", ex); + } + } + } fun loadLiveChat(video: IPlatformVideoDetails) { _liveChat?.stop(); _container_content_liveChat.cancel(); @@ -1846,7 +1942,7 @@ class VideoDetailView : ConstraintLayout { } //Source Loads - private fun loadCurrentVideo(resumePositionMs: Long = 0) { + private fun loadCurrentVideo(resumePositionMs: Long = 0, playWhenReady: Boolean = true) { _didStop = false; val video = (videoLocal ?: video) ?: return; @@ -1867,26 +1963,52 @@ class VideoDetailView : ConstraintLayout { if (!isCasting) { setCastEnabled(false); - val thumbnail = video.thumbnails.getHQThumbnail(); - if (videoSource == null && !thumbnail.isNullOrBlank()) - Glide.with(context).asBitmap().load(thumbnail) - .into(object: CustomTarget() { - override fun onResourceReady(resource: Bitmap, transition: Transition?) { - _player.setArtwork(BitmapDrawable(resources, resource)); - } - override fun onLoadCleared(placeholder: Drawable?) { - _player.setArtwork(null); - } - }); - else - _player.setArtwork(null); - _player.setSource(videoSource, audioSource, _playWhenReady, false, resume = resumePositionMs > 0); - if(subtitleSource != null) - _player.swapSubtitles(fragment.lifecycleScope, subtitleSource); - _player.seekTo(resumePositionMs); + val isLimitedVersion = StatePlatform.instance.getContentClientOrNull(video.url)?.let { + if (it is JSClient) + return@let it.config.reduceFunctionsInLimitedVersion && BuildConfig.IS_PLAYSTORE_BUILD + else false; + } ?: false; + + if (isLimitedVersion && _player.isAudioMode) { + _player.switchToVideoMode() + allowBackground = false; + } else { + val thumbnail = video.thumbnails.getHQThumbnail(); + if ((videoSource == null || _player.isAudioMode) && !thumbnail.isNullOrBlank()) + Glide.with(context).asBitmap().load(thumbnail) + .into(object: CustomTarget() { + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + _player.setArtwork(BitmapDrawable(resources, resource)); + } + override fun onLoadCleared(placeholder: Drawable?) { + _player.setArtwork(null); + } + }); + else + _player.setArtwork(null); + } + + fragment.lifecycleScope.launch(Dispatchers.Main) { + try { + _player.setSource(videoSource, audioSource, _playWhenReady && playWhenReady, false, resume = resumePositionMs > 0); + if(subtitleSource != null) + _player.swapSubtitles(subtitleSource); + _player.seekTo(resumePositionMs); + } catch (e: Throwable) { + Logger.e(TAG, "loadCurrentVideo failed", e) + } + } } - else - loadCurrentVideoCast(video, videoSource, audioSource, subtitleSource, resumePositionMs, Settings.instance.playback.getDefaultPlaybackSpeed().toDouble()); + else { + fragment.lifecycleScope.launch(Dispatchers.Main) { + try { + loadCurrentVideoCast(video, videoSource, audioSource, subtitleSource, resumePositionMs, Settings.instance.playback.getDefaultPlaybackSpeed().toDouble()); + } catch (e: Throwable) { + Logger.e(TAG, "loadCurrentVideo failed (casting)", e) + } + } + } + _lastVideoSource = videoSource; _lastAudioSource = audioSource; @@ -1901,47 +2023,45 @@ class VideoDetailView : ConstraintLayout { UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_load_media), ex); } } - private fun loadCurrentVideoCast(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) { + private suspend fun loadCurrentVideoCast(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) { Logger.i(TAG, "loadCurrentVideoCast(video=$video, videoSource=$videoSource, audioSource=$audioSource, resumePositionMs=$resumePositionMs)") castIfAvailable(context.contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed) } - private fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) { - fragment.lifecycleScope.launch(Dispatchers.IO) { + private suspend fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) { + try { + val plugin = if (videoSource is JSSource) videoSource.getUnderlyingPlugin() + else if (audioSource is JSSource) audioSource.getUnderlyingPlugin() + else null + + val startId = plugin?.getUnderlyingPlugin()?.runtimeId try { - val plugin = if (videoSource is JSSource) videoSource.getUnderlyingPlugin() - else if (audioSource is JSSource) audioSource.getUnderlyingPlugin() - else null + val castingSucceeded = StateCasting.instance.castIfAvailable(contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed, onLoading = { + _cast.setLoading(it) + }, onLoadingEstimate = { + _cast.setLoading(it) + }) - val startId = plugin?.getUnderlyingPlugin()?.runtimeId - try { - val castingSucceeded = StateCasting.instance.castIfAvailable(contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed, onLoading = { - _cast.setLoading(it) - }, onLoadingEstimate = { - _cast.setLoading(it) - }) - - if (castingSucceeded) { - withContext(Dispatchers.Main) { - _cast.setVideoDetails(video, resumePositionMs / 1000); - setCastEnabled(true); - } + if (castingSucceeded) { + withContext(Dispatchers.Main) { + _cast.setVideoDetails(video, resumePositionMs / 1000); + setCastEnabled(true); } - } catch (e: ScriptReloadRequiredException) { - Log.i(TAG, "Reload required exception", e) - if (plugin == null) - throw e - - if (startId != -1 && plugin.getUnderlyingPlugin().runtimeId != startId) - throw e - - StatePlatform.instance.handleReloadRequired(e, { - fetchVideo() - }); } - } catch (e: Throwable) { - Logger.e(TAG, "loadCurrentVideoCast", e) + } catch (e: ScriptReloadRequiredException) { + Log.i(TAG, "Reload required exception", e) + if (plugin == null) + throw e + + if (startId != -1 && plugin.getUnderlyingPlugin().runtimeId != startId) + throw e + + StatePlatform.instance.handleReloadRequired(e, { + fetchVideo() + }); } + } catch (e: Throwable) { + Logger.e(TAG, "loadCurrentVideoCast", e) } } @@ -1982,6 +2102,10 @@ class VideoDetailView : ConstraintLayout { videoTrackFormats.distinctBy { it.height }.sortedByDescending { it.height }, audioTrackFormats.distinctBy { it.bitrate }.sortedByDescending { it.bitrate }); } + + _layoutPlayerContainer.post { + onShouldEnterPictureInPictureChanged.emit() + } } private var _didTriggerDatasourceErrorCount = 0; @@ -2442,7 +2566,6 @@ class VideoDetailView : ConstraintLayout { } isPlaying = playing; - onPlayChanged.emit(playing); updateTracker(lastPositionMilliseconds, playing, true); } @@ -2453,11 +2576,17 @@ class VideoDetailView : ConstraintLayout { if(_lastVideoSource == videoSource) return; - val d = StateCasting.instance.activeDevice; - if (d != null && d.connectionState == CastConnectionState.CONNECTED) - castIfAvailable(context.contentResolver, video, videoSource, _lastAudioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); - else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true)) - _player.hideControls(false); //TODO: Disable player? + fragment.lifecycleScope.launch(Dispatchers.Main) { + try { + val d = StateCasting.instance.activeDevice; + if (d != null && d.connectionState == CastConnectionState.CONNECTED) + castIfAvailable(context.contentResolver, video, videoSource, _lastAudioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); + else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true)) + _player.hideControls(false); //TODO: Disable player? + } catch (e: Throwable) { + Logger.e(TAG, "handleSelectVideoTrack failed", e) + } + } _lastVideoSource = videoSource; } @@ -2468,11 +2597,17 @@ class VideoDetailView : ConstraintLayout { if(_lastAudioSource == audioSource) return; - val d = StateCasting.instance.activeDevice; - if (d != null && d.connectionState == CastConnectionState.CONNECTED) - castIfAvailable(context.contentResolver, video, _lastVideoSource, audioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed) - else(!_player.swapSources(_lastVideoSource, audioSource, true, true, true)) - _player.hideControls(false); //TODO: Disable player? + fragment.lifecycleScope.launch(Dispatchers.Main) { + try { + val d = StateCasting.instance.activeDevice; + if (d != null && d.connectionState == CastConnectionState.CONNECTED) + castIfAvailable(context.contentResolver, video, _lastVideoSource, audioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed) + else if (!_player.swapSources(_lastVideoSource, audioSource, true, true, true)) + _player.hideControls(false); //TODO: Disable player? + } catch (e: Throwable) { + Logger.e(TAG, "handleSelectAudioTrack failed", e) + } + } _lastAudioSource = audioSource; } @@ -2484,12 +2619,18 @@ class VideoDetailView : ConstraintLayout { if(_lastSubtitleSource == subtitleSource) toSet = null; - val d = StateCasting.instance.activeDevice; - if (d != null && d.connectionState == CastConnectionState.CONNECTED) - castIfAvailable(context.contentResolver, video, _lastVideoSource, _lastAudioSource, toSet, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); - else - _player.swapSubtitles(fragment.lifecycleScope, toSet); - + fragment.lifecycleScope.launch(Dispatchers.Main) { + try { + val d = StateCasting.instance.activeDevice; + if (d != null && d.connectionState == CastConnectionState.CONNECTED) + castIfAvailable(context.contentResolver, video, _lastVideoSource, _lastAudioSource, toSet, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); + else { + _player.swapSubtitles(toSet); + } + } catch (e: Throwable) { + Logger.e(TAG, "handleSelectSubtitleTrack failed", e) + } + } _lastSubtitleSource = toSet; } @@ -2576,6 +2717,9 @@ class VideoDetailView : ConstraintLayout { setProgressBarOverlayed(false); } onFullscreenChanged.emit(fullscreen); + _layoutPlayerContainer.post { + onShouldEnterPictureInPictureChanged.emit() + } } private fun setCastEnabled(isCasting: Boolean) { @@ -2603,6 +2747,8 @@ class VideoDetailView : ConstraintLayout { if (changed) { stopAllGestures(); } + + onShouldEnterPictureInPictureChanged.emit() } fun isLandscapeVideo(): Boolean? { @@ -2833,6 +2979,7 @@ class VideoDetailView : ConstraintLayout { _overlayContainer.removeAllViews(); _overlay_quality_selector?.hide(); + _container_content.visibility = GONE _player.fillHeight(false) _layoutPlayerContainer.setPadding(0, 0, 0, 0); @@ -2841,6 +2988,7 @@ class VideoDetailView : ConstraintLayout { Logger.i(TAG, "handleLeavePictureInPicture") if(!_player.isFullScreen) { + _container_content.visibility = VISIBLE _player.fitHeight(); _layoutPlayerContainer.setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, Resources.getSystem().displayMetrics).toInt()); } else { @@ -2856,29 +3004,40 @@ class VideoDetailView : ConstraintLayout { videoSourceHeight = 9; } val aspectRatio = videoSourceWidth.toDouble() / videoSourceHeight; + val r = _player.getVideoRect() if(aspectRatio > 2.38) { videoSourceWidth = 16; videoSourceHeight = 9; + + // shrink the left and right equally to get the rect to be 16 by 9 aspect ratio + // we don't want a picture in picture mode that's more squashed than 16 by 9 + val targetWidth = r.height() * 16 / 9 + val shrinkAmount = (r.width() - targetWidth) / 2 + r.left += shrinkAmount + r.right -= shrinkAmount } else if(aspectRatio < 0.43) { videoSourceHeight = 16; videoSourceWidth = 9; } - val r = Rect(); - _player.getGlobalVisibleRect(r); - r.right = r.right - _player.paddingEnd; val playpauseAction = if(_player.playing) RemoteAction(Icon.createWithResource(context, R.drawable.ic_pause_notif), context.getString(R.string.pause), context.getString(R.string.pauses_the_video), MediaControlReceiver.getPauseIntent(context, 5)); else RemoteAction(Icon.createWithResource(context, R.drawable.ic_play_notif), context.getString(R.string.play), context.getString(R.string.resumes_the_video), MediaControlReceiver.getPlayIntent(context, 6)); val toBackgroundAction = RemoteAction(Icon.createWithResource(context, R.drawable.ic_screen_share), context.getString(R.string.background), context.getString(R.string.background_switch_audio), MediaControlReceiver.getToBackgroundIntent(context, 7)); - return PictureInPictureParams.Builder() + + val params = PictureInPictureParams.Builder() .setAspectRatio(Rational(videoSourceWidth, videoSourceHeight)) .setSourceRectHint(r) .setActions(listOf(toBackgroundAction, playpauseAction)) - .build(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + params.setAutoEnterEnabled(shouldEnterPictureInPicture) + } + + return params.build() } //Other @@ -2896,6 +3055,8 @@ class VideoDetailView : ConstraintLayout { private fun setLastPositionMilliseconds(positionMilliseconds: Long, updateHistory: Boolean) { lastPositionMilliseconds = positionMilliseconds; + _liveChat?.setVideoPosition(lastPositionMilliseconds); + val v = video ?: return; val currentTime = System.currentTimeMillis(); if (updateHistory && (_lastPositionSaveTime == -1L || currentTime - _lastPositionSaveTime > 5000)) { diff --git a/app/src/main/java/com/futo/platformplayer/others/PlatformLinkMovementMethod.kt b/app/src/main/java/com/futo/platformplayer/others/PlatformLinkMovementMethod.kt index 76652236..b29ebd87 100644 --- a/app/src/main/java/com/futo/platformplayer/others/PlatformLinkMovementMethod.kt +++ b/app/src/main/java/com/futo/platformplayer/others/PlatformLinkMovementMethod.kt @@ -13,6 +13,8 @@ import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.receivers.MediaControlReceiver import com.futo.platformplayer.timestampRegex +import com.futo.platformplayer.views.behavior.NonScrollingTextView +import com.futo.platformplayer.views.behavior.NonScrollingTextView.Companion import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -91,7 +93,11 @@ class PlatformLinkMovementMethod(private val _context: Context) : LinkMovementMe } withContext(Dispatchers.Main) { - c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url))) + try { + c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url))) + } catch (e: Throwable) { + Logger.i(TAG, "Failed to start activity.", e) + } } } } diff --git a/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt b/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt index 5ab75011..8141d191 100644 --- a/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt +++ b/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt @@ -48,6 +48,7 @@ class DownloadService : Service() { private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Default); private var _notificationManager: NotificationManager? = null; private var _notificationChannel: NotificationChannel? = null; + private var _isForeground = false private val _client = ManagedHttpClient(OkHttpClient.Builder() //.proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(InetAddress.getByName("192.168.1.175"), 8081))) @@ -66,6 +67,7 @@ class DownloadService : Service() { if(!FragmentedStorage.isInitialized) { Logger.i(TAG, "Attempted to start DownloadService without initialized files"); + stopSelf() closeDownloadSession(); return START_NOT_STICKY; } @@ -116,6 +118,22 @@ class DownloadService : Service() { override fun onCreate() { Logger.i(TAG, "onCreate"); super.onCreate() + + setupNotificationRequirements() + + val bootstrapNotif = NotificationCompat.Builder(this, DOWNLOAD_NOTIF_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_download) + .setContentTitle("Preparing downloads...") + .setOngoing(true) + .setSilent(true) + .build() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + startForeground(DOWNLOAD_NOTIF_ID, bootstrapNotif, FOREGROUND_SERVICE_TYPE_DATA_SYNC) + else + startForeground(DOWNLOAD_NOTIF_ID, bootstrapNotif) + + _isForeground = true } override fun onBind(p0: Intent?): IBinder? { @@ -246,15 +264,14 @@ class DownloadService : Service() { } private fun notifyDownload(download: VideoDownload?) { - val channel = _notificationChannel ?: return; - + val channelId = DOWNLOAD_NOTIF_CHANNEL_ID val bringUpIntent = Intent(this, MainActivity::class.java); bringUpIntent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); bringUpIntent.action = "TAB"; bringUpIntent.putExtra("TAB", "Downloads"); - var builder = if(download != null) - NotificationCompat.Builder(this, DOWNLOAD_NOTIF_TAG) + val builder = if(download != null) + NotificationCompat.Builder(this, channelId) .setSmallIcon(R.drawable.ic_download) .setOngoing(true) .setSilent(true) @@ -262,16 +279,16 @@ class DownloadService : Service() { .setContentTitle("${download.state}: ${download.name}") .setContentText(download.getDownloadInfo()) .setProgress(100, (download.progress * 100).toInt(), download.progress == 0.0) - .setChannelId(channel.id) + .setChannelId(channelId) else - NotificationCompat.Builder(this, DOWNLOAD_NOTIF_TAG) + NotificationCompat.Builder(this, channelId) .setSmallIcon(R.drawable.ic_download) .setOngoing(true) .setSilent(true) .setContentIntent(PendingIntent.getActivity(this, 5, bringUpIntent, PendingIntent.FLAG_IMMUTABLE)) .setContentTitle("Preparing for download...") .setContentText("Initializing download process...") - .setChannelId(channel.id) + .setChannelId(channelId) val notif = builder.build(); notif.flags = notif.flags or NotificationCompat.FLAG_ONGOING_EVENT or NotificationCompat.FLAG_NO_CLEAR; diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index 5a77cb02..7e5371e6 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -657,6 +657,20 @@ class StateApp { } } } + + scopeOrNull?.launch(Dispatchers.IO) { + val enabledPlugins = StatePlatform.instance.getEnabledClients(); + for(plugin in enabledPlugins) { + try { + if(plugin is JSClient) { + if(plugin.descriptor.appSettings.sync.enableHistorySync == true) + StateHistory.instance.syncRemoteHistory(plugin); + } + } catch (ex: Throwable) { + Logger.e(TAG, "Failed to update remote history for ${plugin.name}", ex); + } + } + } } fun mainAppStartedWithExternalFiles(context: Context) { diff --git a/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt b/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt index 97ab82c1..f3bcca55 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt @@ -1,15 +1,18 @@ package com.futo.platformplayer.states import com.futo.platformplayer.UIDialogs +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.SerializedPlatformVideo +import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.models.ImportCache -import com.futo.platformplayer.states.StatePlaylists.Companion +import com.futo.platformplayer.states.StateApp.Companion import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringDateMapStorage import com.futo.platformplayer.stores.db.ManagedDBStore import com.futo.platformplayer.stores.db.types.DBHistory import com.futo.platformplayer.stores.v2.ReconstructStore @@ -19,7 +22,6 @@ import kotlinx.coroutines.launch import java.time.OffsetDateTime import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentMap -import kotlin.math.min class StateHistory { //Legacy @@ -31,6 +33,8 @@ class StateHistory { }) .load(); + private val _remoteHistoryDatesStore = FragmentedStorage.get("remoteHistoryDates"); + private val historyIndex: ConcurrentMap = ConcurrentHashMap(); val _historyDBStore = ManagedDBStore.create("history", DBHistory.Descriptor()) .withIndex({ it.url }, historyIndex, false, true) @@ -186,8 +190,95 @@ class StateHistory { val toDelete = _historyDBStore.getAllIndexes().filter { minutesToDelete == -1L || (now - it.datetime) < minutesToDelete * 60 }; for(item in toDelete) _historyDBStore.delete(item); + _remoteHistoryDatesStore.map = HashMap(); + _remoteHistoryDatesStore.save(); } + fun syncRemoteHistory(plugin: JSClient) { + if (plugin.capabilities.hasGetUserHistory && + plugin.isLoggedIn) { + Logger.i(TAG, "Syncing remote history for plugin [${plugin.name}]"); + + val hist = StatePlatform.instance.getUserHistory(plugin.id); + + syncRemoteHistory(plugin.id, hist, 100, 3); + } + } + fun syncRemoteHistory(pluginId: String, videos: IPager, maxVideos: Int, maxPages: Int) { + val lastDate = _remoteHistoryDatesStore.get(pluginId) ?: OffsetDateTime.MIN; + val maxVideosCount = if(maxVideos <= 0) 500 else maxVideos; + val maxPageCount = if(maxPages <= 0) 3 else maxPages; + var exceededDate = false; + try { + val toSync = mutableListOf(); + var pageCount = 0; + var videoCount = 0; + var isFirst = true; + var oldestPlayback = OffsetDateTime.MAX; + var newestPlayback = OffsetDateTime.MIN; + do { + if (!isFirst) videos.nextPage(); + val newVideos = videos.getResults(); + + var foundVideos = false; + var toSyncAddedCount = 0; + for(video in newVideos) { + if(video is IPlatformVideo && video.playbackDate != null) { + + if(video.playbackDate!! < lastDate) { + exceededDate = true; + break; + } + + if(video.playbackTime > 0) { + toSync.add(video); + toSyncAddedCount++; + foundVideos = true; + oldestPlayback = video.playbackDate!!; + if(newestPlayback == OffsetDateTime.MIN) + newestPlayback = video.playbackDate!!; + } + } + } + + pageCount++; + videoCount += newVideos.size; + isFirst = false; + + if(!foundVideos) + { + Logger.i(TAG, "Found no more videos in remote history"); + break; + } + } + while(videos.hasMorePages() && videoCount <= maxVideosCount && pageCount <= maxPageCount && !exceededDate); + + var updated = 0; + if(oldestPlayback < OffsetDateTime.MAX) { + for(video in toSync){ + val hist = getHistoryByVideo(video, true, video.playbackDate); + if(hist != null && hist.position < video.playbackTime) { + Logger.i(TAG, "Updated history for video [${video.name}] from remote history"); + updateHistoryPosition(video, hist, true, video.playbackTime, video.playbackDate, false); + updated++; + } + } + if(updated > 0) { + _remoteHistoryDatesStore.setAndSave(pluginId, newestPlayback); + + try { + val client = StatePlatform.instance.getClient(pluginId); + UIDialogs.appToast("Updated ${updated} history from ${client.name}") + } + catch(ex: Throwable){} + } + } + } + catch(ex: Throwable) { + val plugin = if(pluginId != StateDeveloper.DEV_ID) StatePlugins.instance.getPlugin(pluginId) else null; + Logger.e(TAG, "Sync Remote History failed for [${plugin?.config?.name}] due to: " + ex.message) + } + } companion object { val TAG = "StateHistory"; diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt index bd952cf2..cba656dd 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt @@ -463,6 +463,47 @@ class StatePlatform { pager.initialize(); return pager; } + fun getShorts(): IPager { + Logger.i(TAG, "Platform - getShorts"); + var clientIdsOngoing = mutableListOf(); + val clients = getSortedEnabledClient().filter { if (it is JSClient) it.enableInShorts else true }; + + StateApp.instance.scopeOrNull?.let { + it.launch(Dispatchers.Default) { + try { + // plugins that take longer than 5 seconds to load are considered "slow" + delay(5000); + val slowClients = synchronized(clientIdsOngoing) { + return@synchronized clients.filter { clientIdsOngoing.contains(it.id) }; + }; + for(client in slowClients) + UIDialogs.toast("${client.name} is still loading..\nConsider disabling it for Home", false); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to show toast for slow source.", e) + } + } + } + + val pages = clients.parallelStream() + .map { + Logger.i(TAG, "getShorts - ${it.name}") + synchronized(clientIdsOngoing) { + clientIdsOngoing.add(it.id); + } + val shortsResult = it.fromPool(_pagerClientPool).getShorts(); + synchronized(clientIdsOngoing) { + clientIdsOngoing.remove(it.id); + } + return@map shortsResult; + } + .asSequence() + .toList() + .associateWith { 1f }; + + val pager = MultiDistributionContentPager(pages); + pager.initialize(); + return pager; + } suspend fun getHomeRefresh(scope: CoroutineScope): IPager { Logger.i(TAG, "Platform - getHome (Refresh)"); val clients = getSortedEnabledClient().filter { if (it is JSClient) it.enableInHome else true }; @@ -995,6 +1036,16 @@ class StatePlatform { return client.getLiveChatWindow(url); } + //Account + fun getUserHistory(id: String): IPager { + val client = getClient(id); + if(client is JSClient && client.isLoggedIn) { + return client.fromPool(_pagerClientPool).getUserHistory() + } + return EmptyPager(); + } + + fun injectDevPlugin(source: SourcePluginConfig, script: String): String? { var devId: String? = null; diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt index 15c91025..eae8adf5 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt @@ -38,6 +38,7 @@ class StatePlayer { //Players private var _exoplayer : PlayerManager? = null; private var _thumbnailExoPlayer : PlayerManager? = null; + private var _shortExoPlayer: PlayerManager? = null //Video Status var rotationLock: Boolean = false @@ -633,6 +634,13 @@ class StatePlayer { } return _thumbnailExoPlayer!!; } + fun getShortPlayerOrCreate(context: Context) : PlayerManager { + if(_shortExoPlayer == null) { + val player = createExoPlayer(context); + _shortExoPlayer = PlayerManager(player); + } + return _shortExoPlayer!!; + } @OptIn(UnstableApi::class) private fun createExoPlayer(context : Context): ExoPlayer { @@ -656,10 +664,13 @@ class StatePlayer { fun dispose(){ val player = _exoplayer; val thumbPlayer = _thumbnailExoPlayer; + val shortPlayer = _shortExoPlayer _exoplayer = null; _thumbnailExoPlayer = null; + _shortExoPlayer = null player?.release(); thumbPlayer?.release(); + shortPlayer?.release() } diff --git a/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt b/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt index 22926841..90da8898 100644 --- a/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt +++ b/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt @@ -34,15 +34,18 @@ class PlayerManager { @Synchronized fun attach(view: PlayerView, stateName: String) { - if(view != _currentView) { - _currentView?.player = null; - switchState(stateName); - view.player = player; - _currentView = view; + if (view != _currentView) { + _currentView?.player = null + _currentView = null + switchState(stateName) + view.player = player + _currentView = view } } + fun detach() { - _currentView?.player = null; + _currentView?.player = null + _currentView = null } fun getState(name: String): PlayerState { diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt index 3bd06903..c13a2df0 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt @@ -9,8 +9,10 @@ import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.constructs.Event3 import com.futo.platformplayer.fragment.channel.tab.ChannelAboutFragment import com.futo.platformplayer.fragment.channel.tab.ChannelContentsFragment import com.futo.platformplayer.fragment.channel.tab.ChannelListFragment @@ -38,6 +40,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec val onContentUrlClicked = Event2() val onUrlClicked = Event1() val onContentClicked = Event2() + val onShortClicked = Event3, ArrayList>?>() val onChannelClicked = Event1() val onAddToClicked = Event1() val onAddToQueueClicked = Event1() @@ -81,7 +84,9 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec when (_tabs[position]) { ChannelTab.VIDEOS -> { fragment = ChannelContentsFragment.newInstance().apply { - onContentClicked.subscribe(this@ChannelViewPagerAdapter.onContentClicked::emit) + onContentClicked.subscribe { video, num, _ -> + this@ChannelViewPagerAdapter.onContentClicked.emit(video, num) + } onContentUrlClicked.subscribe(this@ChannelViewPagerAdapter.onContentUrlClicked::emit) onUrlClicked.subscribe(this@ChannelViewPagerAdapter.onUrlClicked::emit) onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit) @@ -94,7 +99,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec ChannelTab.SHORTS -> { fragment = ChannelContentsFragment.newInstance(ResultCapabilities.TYPE_SHORTS).apply { - onContentClicked.subscribe(this@ChannelViewPagerAdapter.onContentClicked::emit) + onContentClicked.subscribe(this@ChannelViewPagerAdapter.onShortClicked::emit) onContentUrlClicked.subscribe(this@ChannelViewPagerAdapter.onContentUrlClicked::emit) onUrlClicked.subscribe(this@ChannelViewPagerAdapter.onUrlClicked::emit) onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit) diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/ContentPreviewViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/ContentPreviewViewHolder.kt index 67e946e0..c9558e4c 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/ContentPreviewViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/ContentPreviewViewHolder.kt @@ -10,7 +10,7 @@ abstract class ContentPreviewViewHolder(itemView: View) : ViewHolder(itemView) { abstract fun bind(content: IPlatformContent); - abstract fun preview(details: IPlatformContentDetails?, paused: Boolean); + abstract suspend fun preview(details: IPlatformContentDetails?, paused: Boolean); abstract fun stopPreview(); abstract fun pausePreview(); abstract fun resumePreview(); diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/EmptyPreviewViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/EmptyPreviewViewHolder.kt index 05b33db2..6830036a 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/EmptyPreviewViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/EmptyPreviewViewHolder.kt @@ -11,7 +11,7 @@ class EmptyPreviewViewHolder(viewGroup: ViewGroup) : ContentPreviewViewHolder(Vi override fun bind(content: IPlatformContent) {} - override fun preview(details: IPlatformContentDetails?, paused: Boolean) {} + override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) {} override fun stopPreview() {} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewChannelViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewChannelViewHolder.kt index 17754984..ad09e0e9 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewChannelViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewChannelViewHolder.kt @@ -29,7 +29,7 @@ class PreviewChannelViewHolder : ContentPreviewViewHolder { override fun bind(content: IPlatformContent) = view.bind(content); - override fun preview(details: IPlatformContentDetails?, paused: Boolean) = Unit; + override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) = Unit; override fun stopPreview() = Unit; override fun pausePreview() = Unit; override fun resumePreview() = Unit; diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewContentListAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewContentListAdapter.kt index 327497fb..20327904 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewContentListAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewContentListAdapter.kt @@ -4,6 +4,7 @@ import android.content.Context import android.util.Log import android.view.View import android.view.ViewGroup +import androidx.lifecycle.lifecycleScope 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 @@ -15,6 +16,8 @@ import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.debug.Stopwatch +import com.futo.platformplayer.fragment.mainactivity.main.ShortView +import com.futo.platformplayer.fragment.mainactivity.main.ShortView.Companion import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StatePlatform @@ -23,6 +26,9 @@ 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 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import okhttp3.internal.platform.Platform class PreviewContentListAdapter : InsertedViewAdapterWithLoader { @@ -33,6 +39,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader(); val onContentUrlClicked = Event2(); @@ -43,15 +50,9 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader(); val onLongPress = Event1(); - private var _taskLoadContent = TaskHandler, Pair>( - StateApp.instance.scopeGetter, { (viewHolder, video) -> - val stopwatch = Stopwatch() - val contentDetails = StatePlatform.instance.getContentDetails(video.url).await(); - stopwatch.logAndNext(TAG, "Retrieving video detail (IO thread)") - return@TaskHandler Pair(viewHolder, contentDetails) - }).exception { Logger.e(TAG, "Failed to retrieve preview content.", it) }.success { previewContentDetails(it.first, it.second) } + private var _taskLoadContent: TaskHandler, Pair> - constructor(context: Context, feedStyle : FeedStyle, dataSet: ArrayList, exoPlayer: PlayerManager? = null, + constructor(scope: CoroutineScope, context: Context, feedStyle : FeedStyle, dataSet: ArrayList, exoPlayer: PlayerManager? = null, initialPlay: Boolean = false, viewsToPrepend: ArrayList = arrayListOf(), viewsToAppend: ArrayList = arrayListOf(), shouldShowTimeBar: Boolean = true) : super(context, viewsToPrepend, viewsToAppend) { @@ -60,6 +61,24 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader, Pair>( + { scope }, { (viewHolder, video) -> + val stopwatch = Stopwatch() + val contentDetails = StatePlatform.instance.getContentDetails(video.url).await(); + stopwatch.logAndNext(TAG, "Retrieving video detail (IO thread)") + return@TaskHandler Pair(viewHolder, contentDetails) + }).exception { Logger.e(TAG, "Failed to retrieve preview content.", it) }.success { + + _scope.launch(Dispatchers.Main) { + try { + previewContentDetails(it.first, it.second) + } catch (e: Throwable) { + Logger.e(TAG, "bindChild preview failed", e) + } + } + } } override fun getChildCount(): Int = _dataSet.size; @@ -132,12 +151,18 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader(); val onRaidPrevent = Event1(); + val onUrlClick = Event1() private val _argJsonSerializer = Json; @@ -116,6 +122,18 @@ class LiveChatOverlay : LinearLayout { view?.evaluateJavascript("setInterval(()=>{" + toRemoveJSInterval + "}, 1000)") {}; }; } + + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + onUrlClick.emit(request.url) + return true + } + + // API < 24 + @Suppress("DEPRECATION") + override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { + onUrlClick.emit(url.toUri()) + return true + } }; _chatContainer = findViewById(R.id.chatContainer); @@ -202,6 +220,8 @@ class LiveChatOverlay : LinearLayout { if(viewerCount != null) _textViewers.text = viewerCount.toHumanNumber() + " " + context.getString(R.string.viewers); + else if(manager != null && manager.isVOD) + _textViewers.text = manager.viewCount.toHumanNumber() + " past viewers"; else if(manager != null) _textViewers.text = manager.viewCount.toHumanNumber() + " " + context.getString(R.string.viewers); else diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/WebviewOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/WebviewOverlay.kt index 27befb1e..0dba3c4f 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/WebviewOverlay.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/WebviewOverlay.kt @@ -19,7 +19,9 @@ class WebviewOverlay : LinearLayout { inflate(context, R.layout.overlay_webview, this) _topbar = findViewById(R.id.topbar); _webview = findViewById(R.id.webview); - _webview.settings.javaScriptEnabled = true; + if (!isInEditMode){ + _webview.settings.javaScriptEnabled = true; + } _topbar.onClose.subscribe(this, onClose::emit); } diff --git a/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt b/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt index a99eb1ff..6a8ce723 100644 --- a/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt +++ b/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt @@ -68,15 +68,7 @@ class CommentsList : ConstraintLayout { UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadNextPage() }); }; - private val _scrollListener = object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(recyclerView, dx, dy); - onScrolled(); - - val totalScrollDistance = recyclerView.computeVerticalScrollOffset() - _layoutScrollToTop.visibility = if (totalScrollDistance > recyclerView.height) View.VISIBLE else View.GONE - } - }; + private val _scrollListener: RecyclerView.OnScrollListener private var _loader: (suspend () -> IPager)? = null; private val _adapterComments: InsertedViewAdapterWithLoader; @@ -131,6 +123,14 @@ class CommentsList : ConstraintLayout { _llmReplies = LinearLayoutManager(context); _recyclerComments.layoutManager = _llmReplies; _recyclerComments.adapter = _adapterComments; + _scrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy); + onScrolled(); + + _layoutScrollToTop.visibility = if (_llmReplies.findFirstCompletelyVisibleItemPosition() > 5) View.VISIBLE else View.GONE + } + }; _recyclerComments.addOnScrollListener(_scrollListener); } diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt new file mode 100644 index 00000000..cb5f1240 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt @@ -0,0 +1,153 @@ +package com.futo.platformplayer.views.video + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.animation.LinearInterpolator +import androidx.annotation.OptIn +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.DefaultTimeBar +import androidx.media3.ui.PlayerView +import androidx.media3.ui.TimeBar +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.video.PlayerManager + +@UnstableApi +class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) : + FutoVideoPlayerBase(PLAYER_STATE_NAME, context, attrs) { + + companion object { + private const val TAG = "FutoShortVideoPlayer" + private const val PLAYER_STATE_NAME: String = "ShortPlayer" + } + + private var playerAttached = false + private val videoView: PlayerView + private val progressBar: DefaultTimeBar + private lateinit var player: PlayerManager + private var progressAnimator: ValueAnimator = createProgressBarAnimator() + + val onPlaybackStateChanged = Event1(); + + private var playerEventListener = object : Player.Listener { + override fun onEvents(player: Player, events: Player.Events) { + if (events.containsAny( + Player.EVENT_POSITION_DISCONTINUITY, Player.EVENT_IS_PLAYING_CHANGED, Player.EVENT_PLAYBACK_STATE_CHANGED + ) + ) { + progressAnimator.cancel() + if (player.duration >= 0) { + progressAnimator.duration = player.duration + setProgressBarDuration(player.duration) + progressAnimator.currentPlayTime = player.currentPosition + } + + if (player.isPlaying) { + progressAnimator.start() + } + } + + if (events.containsAny(Player.EVENT_PLAYBACK_STATE_CHANGED)) { + onPlaybackStateChanged.emit(player.playbackState) + } + } + } + + init { + LayoutInflater.from(context).inflate(R.layout.view_short_player, this, true) + videoView = findViewById(R.id.short_player_view) + progressBar = findViewById(R.id.short_player_progress_bar) + + if (!isInEditMode) { + player = StatePlayer.instance.getShortPlayerOrCreate(context) + player.player.repeatMode = Player.REPEAT_MODE_ONE + } + + progressBar.addListener(object : TimeBar.OnScrubListener { + override fun onScrubStart(timeBar: TimeBar, position: Long) { + progressAnimator.cancel() + } + + override fun onScrubMove(timeBar: TimeBar, position: Long) {} + + override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { + if (canceled) { + progressAnimator.currentPlayTime = player.player.currentPosition + progressAnimator.duration = player.player.duration + progressAnimator.start() + return + } + + // the progress bar should never be available to the user without the player being attached to this view + assert(playerAttached) + seekTo(position) + } + }) + } + + @OptIn(UnstableApi::class) + private fun createProgressBarAnimator(): ValueAnimator { + return ValueAnimator.ofFloat(0f, 1f).apply { + interpolator = LinearInterpolator() + + addUpdateListener { animation -> + progressBar.setPosition(animation.currentPlayTime) + } + } + } + + fun setProgressBarDuration(duration: Long) { + progressBar.setDuration(duration) + } + + /** + * Attaches this short player instance to the exo player instance for shorts + */ + fun attach() { + // connect the exo player for shorts to the view for this instance + player.attach(videoView, PLAYER_STATE_NAME) + + // direct the base player what exo player instance to use + changePlayer(player) + + playerAttached = true + + player.player.addListener(playerEventListener) + } + + fun detach() { + playerAttached = false + player.player.removeListener(playerEventListener) + player.detach() + } + + @OptIn(UnstableApi::class) + fun setArtwork(drawable: Drawable?) { + if (drawable != null) { + videoView.artworkDisplayMode = PlayerView.ARTWORK_DISPLAY_MODE_FILL + videoView.defaultArtwork = drawable + } else { + videoView.artworkDisplayMode = PlayerView.ARTWORK_DISPLAY_MODE_OFF + videoView.defaultArtwork = null + } + } + + fun getPlaybackRate(): Float { + return exoPlayer?.player?.playbackParameters?.speed ?: 1.0f + } + + fun setPlaybackRate(playbackRate: Float) { + val exoPlayer = exoPlayer?.player + Logger.i(TAG, "setPlaybackRate playbackRate=$playbackRate exoPlayer=${exoPlayer}") + + val param = PlaybackParameters(playbackRate) + exoPlayer?.playbackParameters = param + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoThumbnailPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoThumbnailPlayer.kt index 1b75050b..80175ab8 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoThumbnailPlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoThumbnailPlayer.kt @@ -126,7 +126,7 @@ class FutoThumbnailPlayer : FutoVideoPlayerBase { _evMuteChanged.add(callback); } - fun setPreview(video: IPlatformVideoDetails) { + suspend fun setPreview(video: IPlatformVideoDetails) { if (video.live != null) { setSource(video.live, null,true, false); } else { diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt index 995c3c55..f6432752 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt @@ -4,7 +4,10 @@ import android.animation.ValueAnimator import android.content.Context import android.content.Intent import android.content.res.Resources +import android.graphics.Bitmap import android.graphics.Color +import android.graphics.Rect +import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.media.AudioManager import android.net.Uri @@ -29,6 +32,9 @@ import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerControlView import androidx.media3.ui.PlayerView import androidx.media3.ui.TimeBar +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs @@ -36,6 +42,7 @@ import com.futo.platformplayer.api.media.models.chapters.ChapterType import com.futo.platformplayer.api.media.models.chapters.IChapter 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.video.IPlatformVideoDetails import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 @@ -491,6 +498,13 @@ class FutoVideoPlayer : FutoVideoPlayerBase { _control_autoplay_fullscreen.setColorFilter(ContextCompat.getColor(context, if (StatePlayer.instance.autoplay) com.futo.futopay.R.color.primary else R.color.white)) } + fun getVideoRect(): Rect { + val r = Rect() + // this is the only way i could reliably get a reference to a view that matches perfectly with the video playback + _videoView.subtitleView?.getGlobalVisibleRect(r) + return r + } + private fun setSystemBrightness(brightness: Float) { Log.i(TAG, "setSystemBrightness $brightness") if (android.provider.Settings.System.canWrite(context)) { @@ -890,4 +904,29 @@ class FutoVideoPlayer : FutoVideoPlayerBase { _loaderGame.visibility = View.VISIBLE _loaderGame.startLoader(expectedDurationMs.toLong()) } + + override fun switchToVideoMode() { + super.switchToVideoMode() + setArtwork(null) + } + + override fun switchToAudioMode(video: IPlatformVideoDetails?) { + super.switchToAudioMode(video) + val thumbnail = video?.thumbnails?.getHQThumbnail() + if (!thumbnail.isNullOrBlank()) { + Glide.with(context).asBitmap().load(thumbnail) + .into(object : CustomTarget() { + override fun onResourceReady( + resource: Bitmap, + transition: Transition? + ) { + setArtwork(BitmapDrawable(resources, resource)); + } + + override fun onLoadCleared(placeholder: Drawable?) { + setArtwork(null); + } + }) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt index 023c79fc..ff147b65 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt @@ -1,11 +1,15 @@ package com.futo.platformplayer.views.video import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable import android.net.Uri import android.util.AttributeSet import android.view.LayoutInflater import android.widget.RelativeLayout import androidx.annotation.OptIn +import androidx.constraintlayout.widget.ConstraintLayout import androidx.lifecycle.coroutineScope import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.lifecycleScope @@ -30,6 +34,10 @@ import androidx.media3.exoplayer.source.MergingMediaSource import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.exoplayer.source.SingleSampleMediaSource import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.media.models.PlatformAuthorLink @@ -86,8 +94,6 @@ import kotlin.math.abs abstract class FutoVideoPlayerBase : RelativeLayout { private val TAG = "FutoVideoPlayerBase" - private val TEMP_DIRECTORY = StateApp.instance.getTempDirectory(); - private var _mediaSource: MediaSource? = null; var lastVideoSource: IVideoSource? = null @@ -267,7 +273,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout { StateApp.instance.onConnectionAvailable.remove(_referenceObject); } - fun switchToVideoMode() { + open fun switchToVideoMode() { Logger.i(TAG, "Switching to Video Mode"); isAudioMode = false; val player = exoPlayer ?: return @@ -277,7 +283,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout { .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, isAudioMode) .build() } - fun switchToAudioMode() { + open fun switchToAudioMode(video: IPlatformVideoDetails?) { Logger.i(TAG, "Switching to Audio Mode"); isAudioMode = true; val player = exoPlayer ?: return @@ -359,48 +365,63 @@ abstract class FutoVideoPlayerBase : RelativeLayout { return _chapters?.let { chaps -> chaps.find { pos.toDouble() / 1000 > it.timeStart && pos.toDouble() / 1000 < it.timeEnd && (toIgnore.isEmpty() || !toIgnore.contains(it)) } }; } - fun setSource(videoSource: IVideoSource?, audioSource: IAudioSource? = null, play: Boolean = false, keepSubtitles: Boolean = false, resume: Boolean = false) { + suspend fun setSource(videoSource: IVideoSource?, audioSource: IAudioSource? = null, play: Boolean = false, keepSubtitles: Boolean = false, resume: Boolean = false) { swapSources(videoSource, audioSource,resume, play, keepSubtitles); } - fun swapSources(videoSource: IVideoSource?, audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true, keepSubtitles: Boolean = false): Boolean { - var videoSourceUsed = videoSource; - var audioSourceUsed = audioSource; - if(videoSource is JSDashManifestRawSource && audioSource is JSDashManifestRawAudioSource){ - videoSource.getUnderlyingPlugin()?.busy { - videoSourceUsed = JSDashManifestMergingRawSource(videoSource, audioSource); - audioSourceUsed = null; + suspend fun swapSources(videoSource: IVideoSource?, audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true, keepSubtitles: Boolean = false): Boolean { + val didSet = withContext(Dispatchers.IO) { + var videoSourceUsed = videoSource; + var audioSourceUsed = audioSource; + if(videoSource is JSDashManifestRawSource && audioSource is JSDashManifestRawAudioSource){ + videoSource.getUnderlyingPlugin()?.busy { + videoSourceUsed = JSDashManifestMergingRawSource(videoSource, audioSource); + audioSourceUsed = null; + } } + + val didSetVideo = swapSourceInternal(videoSourceUsed, play, resume); + val didSetAudio = swapSourceInternal(audioSourceUsed, play, resume); + if(!keepSubtitles) + _lastSubtitleMediaSource = null; + + return@withContext didSetVideo && didSetAudio } - val didSetVideo = swapSourceInternal(videoSourceUsed, play, resume); - val didSetAudio = swapSourceInternal(audioSourceUsed, play, resume); - if(!keepSubtitles) - _lastSubtitleMediaSource = null; - if(didSetVideo && didSetAudio) - return loadSelectedSources(play, resume); - else - return true; + return withContext(Dispatchers.Main) { + if (didSet) + return@withContext loadSelectedSources(play, resume) + else + return@withContext true + } } - fun swapSource(videoSource: IVideoSource?, resume: Boolean = true, play: Boolean = true): Boolean { - var videoSourceUsed = videoSource; - if(videoSource is JSDashManifestRawSource && lastVideoSource is JSDashManifestMergingRawSource) - videoSourceUsed = JSDashManifestMergingRawSource(videoSource, (lastVideoSource as JSDashManifestMergingRawSource).audio); - val didSet = swapSourceInternal(videoSourceUsed, play, resume); - if(didSet) - return loadSelectedSources(play, resume); - else - return true; + suspend fun swapSource(videoSource: IVideoSource?, resume: Boolean = true, play: Boolean = true): Boolean { + val didSet = withContext(Dispatchers.IO) { + var videoSourceUsed = videoSource; + if (videoSource is JSDashManifestRawSource && lastVideoSource is JSDashManifestMergingRawSource) + videoSourceUsed = JSDashManifestMergingRawSource(videoSource, (lastVideoSource as JSDashManifestMergingRawSource).audio); + return@withContext swapSourceInternal(videoSourceUsed, play, resume); + } + return withContext(Dispatchers.Main) { + if (didSet) + return@withContext loadSelectedSources(play, resume); + else + return@withContext true; + } } - fun swapSource(audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true): Boolean { - if(audioSource is JSDashManifestRawAudioSource && lastVideoSource is JSDashManifestMergingRawSource) - swapSourceInternal(JSDashManifestMergingRawSource((lastVideoSource as JSDashManifestMergingRawSource).video, audioSource), play, resume); - else - swapSourceInternal(audioSource, play, resume); - return loadSelectedSources(play, resume); + suspend fun swapSource(audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true): Boolean { + withContext(Dispatchers.IO) { + if (audioSource is JSDashManifestRawAudioSource && lastVideoSource is JSDashManifestMergingRawSource) + swapSourceInternal(JSDashManifestMergingRawSource((lastVideoSource as JSDashManifestMergingRawSource).video, audioSource), play, resume); + else + swapSourceInternal(audioSource, play, resume); + } + return withContext(Dispatchers.Main) { + return@withContext loadSelectedSources(play, resume); + } } @OptIn(UnstableApi::class) - fun swapSubtitles(scope: CoroutineScope, subtitles: ISubtitleSource?) { + suspend fun swapSubtitles(subtitles: ISubtitleSource?) { if(subtitles == null) clearSubtitles(); else { @@ -414,9 +435,9 @@ abstract class FutoVideoPlayerBase : RelativeLayout { C.TIME_UNSET); loadSelectedSources(true, true); } else { - scope.launch(Dispatchers.IO) { + withContext(Dispatchers.IO) { try { - val subUri = subtitles.getSubtitlesURI() ?: return@launch; + val subUri = subtitles.getSubtitlesURI() ?: return@withContext; withContext(Dispatchers.Main) { try { _lastSubtitleMediaSource = SingleSampleMediaSource.Factory(DefaultDataSource.Factory(context, DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT))) diff --git a/app/src/main/res/drawable/button_shadow.xml b/app/src/main/res/drawable/button_shadow.xml new file mode 100644 index 00000000..8b2e08a8 --- /dev/null +++ b/app/src/main/res/drawable/button_shadow.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/desktop_comments.xml b/app/src/main/res/drawable/desktop_comments.xml new file mode 100644 index 00000000..acdb15b4 --- /dev/null +++ b/app/src/main/res/drawable/desktop_comments.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/desktop_gear.xml b/app/src/main/res/drawable/desktop_gear.xml new file mode 100644 index 00000000..2001c903 --- /dev/null +++ b/app/src/main/res/drawable/desktop_gear.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/desktop_refresh.xml b/app/src/main/res/drawable/desktop_refresh.xml new file mode 100644 index 00000000..9625ff95 --- /dev/null +++ b/app/src/main/res/drawable/desktop_refresh.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/desktop_share.xml b/app/src/main/res/drawable/desktop_share.xml new file mode 100644 index 00000000..a98111ad --- /dev/null +++ b/app/src/main/res/drawable/desktop_share.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/desktop_thumb_down.xml b/app/src/main/res/drawable/desktop_thumb_down.xml new file mode 100644 index 00000000..ca85aa53 --- /dev/null +++ b/app/src/main/res/drawable/desktop_thumb_down.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/desktop_thumb_down_filled.xml b/app/src/main/res/drawable/desktop_thumb_down_filled.xml new file mode 100644 index 00000000..7939d8b8 --- /dev/null +++ b/app/src/main/res/drawable/desktop_thumb_down_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/desktop_thumb_up.xml b/app/src/main/res/drawable/desktop_thumb_up.xml new file mode 100644 index 00000000..8a8eb280 --- /dev/null +++ b/app/src/main/res/drawable/desktop_thumb_up.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/desktop_thumb_up_filled.xml b/app/src/main/res/drawable/desktop_thumb_up_filled.xml new file mode 100644 index 00000000..5e4a7790 --- /dev/null +++ b/app/src/main/res/drawable/desktop_thumb_up_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_comment.xml b/app/src/main/res/drawable/ic_comment.xml new file mode 100644 index 00000000..f67f9d5c --- /dev/null +++ b/app/src/main/res/drawable/ic_comment.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_high_quality.xml b/app/src/main/res/drawable/ic_high_quality.xml new file mode 100644 index 00000000..4afa3e96 --- /dev/null +++ b/app/src/main/res/drawable/ic_high_quality.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_smart_display.xml b/app/src/main/res/drawable/ic_smart_display.xml index 68758978..f9ae21c4 100644 --- a/app/src/main/res/drawable/ic_smart_display.xml +++ b/app/src/main/res/drawable/ic_smart_display.xml @@ -5,5 +5,5 @@ android:viewportHeight="960"> + android:pathData="M405.85,617L619.69,478.77L405.85,341.31L405.85,617ZM175.38,760Q152.33,760 136.16,743.84Q120,727.67 120,704.62L120,255.38Q120,232.33 136.16,216.16Q152.33,200 175.38,200L784.62,200Q807.67,200 823.84,216.16Q840,232.33 840,255.38L840,704.62Q840,727.67 823.84,743.84Q807.67,760 784.62,760L175.38,760ZM175.38,729.23L784.62,729.23Q793.85,729.23 801.54,721.54Q809.23,713.85 809.23,704.62L809.23,255.38Q809.23,246.15 801.54,238.46Q793.85,230.77 784.62,230.77L175.38,230.77Q166.15,230.77 158.46,238.46Q150.77,246.15 150.77,255.38L150.77,704.62Q150.77,713.85 158.46,721.54Q166.15,729.23 175.38,729.23ZM150.77,729.23Q150.77,729.23 150.77,721.54Q150.77,713.85 150.77,704.62L150.77,255.38Q150.77,246.15 150.77,238.46Q150.77,230.77 150.77,230.77L150.77,230.77Q150.77,230.77 150.77,238.46Q150.77,246.15 150.77,255.38L150.77,704.62Q150.77,713.85 150.77,721.54Q150.77,729.23 150.77,729.23Z"/> diff --git a/app/src/main/res/drawable/ic_smart_display_filled.xml b/app/src/main/res/drawable/ic_smart_display_filled.xml new file mode 100644 index 00000000..14245c9c --- /dev/null +++ b/app/src/main/res/drawable/ic_smart_display_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_thumb_down.xml b/app/src/main/res/drawable/ic_thumb_down.xml index 8de5f492..3a80a3e3 100644 --- a/app/src/main/res/drawable/ic_thumb_down.xml +++ b/app/src/main/res/drawable/ic_thumb_down.xml @@ -1,9 +1,9 @@ + android:viewportWidth="960" + android:viewportHeight="960"> + android:pathData="M262.65,192.31L666,192.31L666,628.92L415.69,880L403.6,871.19Q398.38,866.31 395.38,859.23Q392.38,852.15 392.38,843.77L392.38,839.92L433.54,628.92L136.85,628.92Q115.46,628.92 98.46,611.92Q81.46,594.92 81.46,573.54L81.46,523.18Q81.46,517.62 81.12,511.27Q80.77,504.92 83,499.46L195.15,237.15Q202.49,218.21 222.44,205.26Q242.39,192.31 262.65,192.31ZM635.23,223.08L256.69,223.08Q248.23,223.08 239.38,227.69Q230.54,232.31 225.92,243.08L112.23,512.08L112.23,573.54Q112.23,583.54 119.15,590.85Q126.08,598.15 136.85,598.15L470.62,598.15L424.54,829.46L635.23,615.46L635.23,223.08ZM635.23,615.46L635.23,615.46L635.23,598.15L635.23,598.15Q635.23,598.15 635.23,590.85Q635.23,583.54 635.23,573.54L635.23,512.08L635.23,243.08Q635.23,232.31 635.23,227.69Q635.23,223.08 635.23,223.08L635.23,223.08L635.23,615.46ZM666,628.92L666,598.15L809,598.15L809,223.08L666,223.08L666,192.31L839.77,192.31L839.77,628.92L666,628.92Z"/> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_thumb_down_filled.xml b/app/src/main/res/drawable/ic_thumb_down_filled.xml new file mode 100644 index 00000000..5517d2c6 --- /dev/null +++ b/app/src/main/res/drawable/ic_thumb_down_filled.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_thumb_up.xml b/app/src/main/res/drawable/ic_thumb_up.xml index fdcf53d4..489f31d9 100644 --- a/app/src/main/res/drawable/ic_thumb_up.xml +++ b/app/src/main/res/drawable/ic_thumb_up.xml @@ -1,9 +1,9 @@ + android:viewportWidth="960" + android:viewportHeight="960"> + android:pathData="M696.77,800L293.54,800L293.54,363.38L543.08,112.31L555.84,121.12Q561.15,126 564.15,133.08Q567.15,140.15 567.15,147.77L567.15,152.38L525.23,363.38L822.69,363.38Q844.08,363.38 861.08,380.38Q878.08,397.38 878.08,418.77L878.08,469.13Q878.08,474.69 878.04,481.04Q878,487.38 875.77,492.85L764.38,755.15Q756,774.22 736.19,787.11Q716.38,800 696.77,800ZM324.31,769.23L702.85,769.23Q710.54,769.23 719.77,764.62Q729,760 733.62,749.23L847.31,480.23L847.31,418.77Q847.31,408.77 840,401.46Q832.69,394.15 822.69,394.15L488.92,394.15L534.23,162.85L324.31,376.85L324.31,769.23ZM324.31,376.85L324.31,376.85L324.31,394.15L324.31,394.15Q324.31,394.15 324.31,401.46Q324.31,408.77 324.31,418.77L324.31,480.23L324.31,749.23Q324.31,760 324.31,764.62Q324.31,769.23 324.31,769.23L324.31,769.23L324.31,376.85ZM293.54,363.38L293.54,394.15L150.54,394.15L150.54,769.23L293.54,769.23L293.54,800L119.77,800L119.77,363.38L293.54,363.38Z"/> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_thumb_up_filled.xml b/app/src/main/res/drawable/ic_thumb_up_filled.xml new file mode 100644 index 00000000..03a4da48 --- /dev/null +++ b/app/src/main/res/drawable/ic_thumb_up_filled.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/thumb_down_selector.xml b/app/src/main/res/drawable/thumb_down_selector.xml new file mode 100644 index 00000000..4ae564a7 --- /dev/null +++ b/app/src/main/res/drawable/thumb_down_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/thumb_up_selector.xml b/app/src/main/res/drawable/thumb_up_selector.xml new file mode 100644 index 00000000..97d02623 --- /dev/null +++ b/app/src/main/res/drawable/thumb_up_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 1708eeb4..df3abc69 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,9 +1,10 @@ - - \ No newline at end of file + diff --git a/app/src/main/res/layout/fragment_shorts.xml b/app/src/main/res/layout/fragment_shorts.xml new file mode 100644 index 00000000..76c1a426 --- /dev/null +++ b/app/src/main/res/layout/fragment_shorts.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/list_video_preview.xml b/app/src/main/res/layout/list_video_preview.xml index d0e19dea..3f798150 100644 --- a/app/src/main/res/layout/list_video_preview.xml +++ b/app/src/main/res/layout/list_video_preview.xml @@ -3,24 +3,21 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:layout_height="wrap_content" + android:layout_height="match_parent" android:orientation="vertical"> - - + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toTopOf="@id/video_meta"> @@ -226,6 +223,7 @@ android:layout_height="wrap_content" android:orientation="horizontal" android:paddingEnd="6dp" + app:layout_constraintTop_toBottomOf="@id/creator_thumbnail" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintRight_toRightOf="parent"> diff --git a/app/src/main/res/layout/modal_comments.xml b/app/src/main/res/layout/modal_comments.xml new file mode 100644 index 00000000..ca0e4c6c --- /dev/null +++ b/app/src/main/res/layout/modal_comments.xml @@ -0,0 +1,337 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +