diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index c421c9a3..989997c3 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -294,6 +294,9 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.show_subscription_group, FieldForm.TOGGLE, R.string.show_subscription_group_description, 5) var showSubscriptionGroups: Boolean = true; + @FormField(R.string.use_subscription_exchange, FieldForm.TOGGLE, R.string.use_subscription_exchange_description, 6) + var useSubscriptionExchange: Boolean = false; + @FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6) var previewFeedItems: Boolean = true; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt index 554a4723..edb1caa3 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt @@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.contents import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import kotlinx.serialization.Serializable import java.time.OffsetDateTime interface IPlatformContent { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/LocalVideoMuxedSourceDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/DownloadedVideoMuxedSourceDescriptor.kt similarity index 89% rename from app/src/main/java/com/futo/platformplayer/api/media/models/streams/LocalVideoMuxedSourceDescriptor.kt rename to app/src/main/java/com/futo/platformplayer/api/media/models/streams/DownloadedVideoMuxedSourceDescriptor.kt index b5309931..df96de13 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/LocalVideoMuxedSourceDescriptor.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/DownloadedVideoMuxedSourceDescriptor.kt @@ -3,7 +3,7 @@ package com.futo.platformplayer.api.media.models.streams import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource import com.futo.platformplayer.downloads.VideoLocal -class LocalVideoMuxedSourceDescriptor( +class DownloadedVideoMuxedSourceDescriptor( private val video: VideoLocal ) : VideoMuxedSourceDescriptor() { override val videoSources: Array get() = video.videoSource.toTypedArray(); 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 49b6265b..68bb5cb9 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 @@ -14,6 +14,7 @@ import java.time.OffsetDateTime @kotlinx.serialization.Serializable open class SerializedPlatformVideo( + override val contentType: ContentType = ContentType.MEDIA, override val id: PlatformID, override val name: String, override val thumbnails: Thumbnails, @@ -27,7 +28,6 @@ open class SerializedPlatformVideo( override val viewCount: Long, override val isShort: Boolean = false ) : IPlatformVideo, SerializedPlatformContent { - override val contentType: ContentType = ContentType.MEDIA; override val isLive: Boolean = false; @@ -44,6 +44,7 @@ open class SerializedPlatformVideo( companion object { fun fromVideo(video: IPlatformVideo) : SerializedPlatformVideo { return SerializedPlatformVideo( + ContentType.MEDIA, video.id, video.name, video.thumbnails, diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt new file mode 100644 index 00000000..3f6e5b82 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt @@ -0,0 +1,5 @@ +package com.futo.platformplayer.api.media.platforms.local + +class LocalClient { + //TODO +} \ 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 new file mode 100644 index 00000000..1c169e64 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoDetails.kt @@ -0,0 +1,85 @@ +package com.futo.platformplayer.api.media.platforms.local.models + +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.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 +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 java.io.File +import java.time.Instant +import java.time.OffsetDateTime +import java.time.ZoneId + +class LocalVideoDetails: IPlatformVideoDetails { + + override val contentType: ContentType get() = ContentType.UNKNOWN; + + override val id: PlatformID; + override val name: String; + override val author: PlatformAuthorLink; + + override val datetime: OffsetDateTime?; + + override val url: String; + override val shareUrl: String; + override val rating: IRating = RatingLikes(0); + override val description: String = ""; + + override val video: IVideoSourceDescriptor; + override val preview: IVideoSourceDescriptor? = null; + override val live: IVideoSource? = null; + override val dash: IDashManifestSource? = null; + override val hls: IHLSManifestSource? = null; + override val subtitles: List = listOf() + + override val thumbnails: Thumbnails; + override val duration: Long; + override val viewCount: Long = 0; + override val isLive: Boolean = false; + override val isShort: Boolean = false; + + constructor(file: File) { + id = PlatformID("Local", file.path, "LOCAL") + name = file.name; + author = PlatformAuthorLink.UNKNOWN; + + url = file.canonicalPath; + shareUrl = ""; + + duration = 0; + thumbnails = Thumbnails(arrayOf()); + + datetime = OffsetDateTime.ofInstant( + Instant.ofEpochMilli(file.lastModified()), + ZoneId.systemDefault() + ); + video = LocalVideoMuxedSourceDescriptor(LocalVideoFileSource(file)); + } + + override fun getComments(client: IPlatformClient): IPager? { + return null; + } + + override fun getPlaybackTracker(): IPlaybackTracker? { + return null; + } + + override fun getContentRecommendations(client: IPlatformClient): IPager? { + return null; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoMuxedSourceDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoMuxedSourceDescriptor.kt new file mode 100644 index 00000000..da8ae431 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoMuxedSourceDescriptor.kt @@ -0,0 +1,13 @@ +package com.futo.platformplayer.api.media.platforms.local.models + +import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource +import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource +import com.futo.platformplayer.downloads.VideoLocal + +class LocalVideoMuxedSourceDescriptor( + private val video: LocalVideoFileSource +) : VideoMuxedSourceDescriptor() { + override val videoSources: Array get() = arrayOf(video); +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/MediaStoreVideo.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/MediaStoreVideo.kt new file mode 100644 index 00000000..52876b90 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/MediaStoreVideo.kt @@ -0,0 +1,25 @@ +package com.futo.platformplayer.api.media.platforms.local.models + +import android.content.Context +import android.database.Cursor +import android.provider.MediaStore +import android.provider.MediaStore.Video + +class MediaStoreVideo { + + + companion object { + val URI = MediaStore.Files.getContentUri("external"); + val PROJECTION = arrayOf(Video.Media._ID, Video.Media.TITLE, Video.Media.DURATION, Video.Media.HEIGHT, Video.Media.WIDTH, Video.Media.MIME_TYPE); + val ORDER = MediaStore.Video.Media.TITLE; + + fun readMediaStoreVideo(cursor: Cursor) { + + } + + fun query(context: Context, selection: String, args: Array, order: String? = null): Cursor? { + val cursor = context.contentResolver.query(URI, PROJECTION, selection, args, order ?: ORDER, null); + return cursor; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoFileSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoFileSource.kt new file mode 100644 index 00000000..9e2f7792 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoFileSource.kt @@ -0,0 +1,31 @@ +package com.futo.platformplayer.api.media.platforms.local.models.sources + +import android.content.Context +import android.provider.MediaStore +import android.provider.MediaStore.Video +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource +import com.futo.platformplayer.helpers.VideoHelper +import java.io.File + +class LocalVideoFileSource: IVideoSource { + + + override val name: String; + override val width: Int; + override val height: Int; + override val container: String; + override val codec: String = "" + override val bitrate: Int = 0 + override val duration: Long; + override val priority: Boolean = false; + + constructor(file: File) { + name = file.name; + width = 0; + height = 0; + container = VideoHelper.videoExtensionToMimetype(file.extension) ?: ""; + duration = 0; + } + +} \ No newline at end of file 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 578a5812..06095058 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt @@ -10,7 +10,7 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContent 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.streams.IVideoSourceDescriptor -import com.futo.platformplayer.api.media.models.streams.LocalVideoMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.DownloadedVideoMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.LocalVideoUnMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource @@ -57,7 +57,7 @@ class VideoLocal: IPlatformVideoDetails, IStoreItem { override val video: IVideoSourceDescriptor get() = if(audioSource.isNotEmpty()) LocalVideoUnMuxedSourceDescriptor(this) else - LocalVideoMuxedSourceDescriptor(this); + DownloadedVideoMuxedSourceDescriptor(this); override val preview: IVideoSourceDescriptor? get() = videoSerialized.preview; override val live: IVideoSource? get() = videoSerialized.live; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt index 440aa235..d402a6e2 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt @@ -229,7 +229,7 @@ class DownloadsFragment : MainFragment() { fun filterDownloads(vids: List): List{ var vidsToReturn = vids; if(!_listDownloadSearch.text.isNullOrEmpty()) - vidsToReturn = vids.filter { it.name.contains(_listDownloadSearch.text, true) }; + vidsToReturn = vids.filter { it.name.contains(_listDownloadSearch.text, true) || it.author.name.contains(_listDownloadSearch.text, true) }; if(!ordering.isNullOrEmpty()) { vidsToReturn = when(ordering){ "downloadDateAsc" -> vidsToReturn.sortedBy { it.downloadDate ?: OffsetDateTime.MAX }; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt index bcc01ed1..9d188415 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt @@ -6,12 +6,17 @@ import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.EditText import android.widget.FrameLayout import android.widget.ImageButton import android.widget.LinearLayout +import android.widget.Spinner import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.widget.addTextChangedListener import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -21,11 +26,13 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.views.SearchView import com.futo.platformplayer.views.adapters.* import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay import com.google.android.material.appbar.AppBarLayout import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.time.OffsetDateTime class PlaylistsFragment : MainFragment() { @@ -65,6 +72,7 @@ class PlaylistsFragment : MainFragment() { private val _fragment: PlaylistsFragment; var watchLater: ArrayList = arrayListOf(); + var allPlaylists: ArrayList = arrayListOf(); var playlists: ArrayList = arrayListOf(); private var _appBar: AppBarLayout; private var _adapterWatchLater: VideoListHorizontalAdapter; @@ -72,12 +80,20 @@ class PlaylistsFragment : MainFragment() { private var _layoutWatchlist: ConstraintLayout; private var _slideUpOverlay: SlideUpMenuOverlay? = null; + private var _listPlaylistsSearch: EditText; + + private var _ordering: String? = null; + + constructor(fragment: PlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) { _fragment = fragment; inflater.inflate(R.layout.fragment_playlists, this); + _listPlaylistsSearch = findViewById(R.id.playlists_search); + watchLater = ArrayList(); playlists = ArrayList(); + allPlaylists = ArrayList(); val recyclerWatchLater = findViewById(R.id.recycler_watch_later); @@ -105,6 +121,7 @@ class PlaylistsFragment : MainFragment() { buttonCreatePlaylist.setOnClickListener { _slideUpOverlay = UISlideOverlays.showCreatePlaylistOverlay(findViewById(R.id.overlay_create_playlist)) { val playlist = Playlist(it, arrayListOf()); + allPlaylists.add(0, playlist); playlists.add(0, playlist); StatePlaylists.instance.createOrUpdatePlaylist(playlist); @@ -120,6 +137,34 @@ class PlaylistsFragment : MainFragment() { _appBar = findViewById(R.id.app_bar); _layoutWatchlist = findViewById(R.id.layout_watchlist); + + _listPlaylistsSearch.addTextChangedListener { + updatePlaylistsFiltering(); + } + val spinnerSortBy: Spinner = findViewById(R.id.spinner_sortby); + spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.playlists_sortby_array)).also { + it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); + }; + spinnerSortBy.setSelection(0); + spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { + when(pos) { + 0 -> _ordering = "nameAsc" + 1 -> _ordering = "nameDesc" + 2 -> _ordering = "dateEditAsc" + 3 -> _ordering = "dateEditDesc" + 4 -> _ordering = "dateCreateAsc" + 5 -> _ordering = "dateCreateDesc" + 6 -> _ordering = "datePlayAsc" + 7 -> _ordering = "datePlayDesc" + else -> _ordering = null + } + updatePlaylistsFiltering() + } + override fun onNothingSelected(parent: AdapterView<*>?) = Unit + }; + + findViewById(R.id.text_view_all).setOnClickListener { _fragment.navigate(context.getString(R.string.watch_later)); }; StatePlaylists.instance.onWatchLaterChanged.subscribe(this) { fragment.lifecycleScope.launch(Dispatchers.Main) { @@ -134,10 +179,12 @@ class PlaylistsFragment : MainFragment() { @SuppressLint("NotifyDataSetChanged") fun onShown() { + allPlaylists.clear(); playlists.clear() - playlists.addAll( + allPlaylists.addAll( StatePlaylists.instance.getPlaylists().sortedByDescending { maxOf(it.datePlayed, it.dateUpdate, it.dateCreation) } ); + playlists.addAll(filterPlaylists(allPlaylists)); _adapterPlaylist.notifyDataSetChanged(); updateWatchLater(); @@ -157,6 +204,32 @@ class PlaylistsFragment : MainFragment() { return false; } + private fun updatePlaylistsFiltering() { + val toFilter = allPlaylists ?: return; + playlists.clear(); + playlists.addAll(filterPlaylists(toFilter)); + _adapterPlaylist.notifyDataSetChanged(); + } + private fun filterPlaylists(pls: List): List { + var playlistsToReturn = pls; + if(!_listPlaylistsSearch.text.isNullOrEmpty()) + playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) }; + if(!_ordering.isNullOrEmpty()){ + playlistsToReturn = when(_ordering){ + "nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() } + "nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() }; + "dateEditAsc" -> playlistsToReturn.sortedBy { it.dateUpdate ?: OffsetDateTime.MAX }; + "dateEditDesc" -> playlistsToReturn.sortedByDescending { it.dateUpdate ?: OffsetDateTime.MIN } + "dateCreateAsc" -> playlistsToReturn.sortedBy { it.dateCreation ?: OffsetDateTime.MAX }; + "dateCreateDesc" -> playlistsToReturn.sortedByDescending { it.dateCreation ?: OffsetDateTime.MIN } + "datePlayAsc" -> playlistsToReturn.sortedBy { it.datePlayed ?: OffsetDateTime.MAX }; + "datePlayDesc" -> playlistsToReturn.sortedByDescending { it.datePlayed ?: OffsetDateTime.MIN } + else -> playlistsToReturn + } + } + return playlistsToReturn; + } + private fun updateWatchLater() { val watchList = StatePlaylists.instance.getWatchLater(); if (watchList.isNotEmpty()) { @@ -164,7 +237,7 @@ class PlaylistsFragment : MainFragment() { _appBar.let { appBar -> val layoutParams: CoordinatorLayout.LayoutParams = appBar.layoutParams as CoordinatorLayout.LayoutParams; - layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 230.0f, resources.displayMetrics).toInt(); + layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 315.0f, resources.displayMetrics).toInt(); appBar.layoutParams = layoutParams; } } else { @@ -172,7 +245,7 @@ class PlaylistsFragment : MainFragment() { _appBar.let { appBar -> val layoutParams: CoordinatorLayout.LayoutParams = appBar.layoutParams as CoordinatorLayout.LayoutParams; - layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 25.0f, resources.displayMetrics).toInt(); + layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 110.0f, resources.displayMetrics).toInt(); appBar.layoutParams = layoutParams; }; } 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 8d930838..25b03cb1 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 @@ -132,6 +132,7 @@ import com.futo.platformplayer.views.behavior.TouchInterceptFrameLayout import com.futo.platformplayer.views.casting.CastView import com.futo.platformplayer.views.comments.AddCommentView import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.overlays.ChaptersOverlay import com.futo.platformplayer.views.overlays.DescriptionOverlay import com.futo.platformplayer.views.overlays.LiveChatOverlay import com.futo.platformplayer.views.overlays.QueueEditorOverlay @@ -147,6 +148,7 @@ import com.futo.platformplayer.views.pills.PillRatingLikesDislikes import com.futo.platformplayer.views.pills.RoundButton import com.futo.platformplayer.views.pills.RoundButtonGroup import com.futo.platformplayer.views.platform.PlatformIndicator +import com.futo.platformplayer.views.segments.ChaptersList import com.futo.platformplayer.views.segments.CommentsList import com.futo.platformplayer.views.subscriptions.SubscribeButton import com.futo.platformplayer.views.video.FutoVideoPlayer @@ -195,6 +197,8 @@ class VideoDetailView : ConstraintLayout { private var _liveChat: LiveChatManager? = null; private var _videoResumePositionMilliseconds : Long = 0L; + private var _chapters: List? = null; + private val _player: FutoVideoPlayer; private val _cast: CastView; private val _playerProgress: PlayerControlView; @@ -263,6 +267,7 @@ class VideoDetailView : ConstraintLayout { private val _container_content_liveChat: LiveChatOverlay; private val _container_content_browser: WebviewOverlay; private val _container_content_support: SupportOverlay; + private val _container_content_chapters: ChaptersOverlay; private var _container_content_current: View; @@ -374,6 +379,7 @@ class VideoDetailView : ConstraintLayout { _container_content_liveChat = findViewById(R.id.videodetail_container_livechat); _container_content_support = findViewById(R.id.videodetail_container_support); _container_content_browser = findViewById(R.id.videodetail_container_webview) + _container_content_chapters = findViewById(R.id.videodetail_container_chapters); _addCommentView = findViewById(R.id.add_comment_view); _commentsList = findViewById(R.id.comments_list); @@ -398,6 +404,10 @@ class VideoDetailView : ConstraintLayout { _monetization = findViewById(R.id.monetization); _player.attachPlayer(); + _player.onChapterClicked.subscribe { + showChaptersUI(); + }; + _buttonSubscribe.onSubscribed.subscribe { _slideUpOverlay = UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer); @@ -686,6 +696,11 @@ class VideoDetailView : ConstraintLayout { _container_content_replies.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_support.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_browser.onClose.subscribe { switchContentView(_container_content_main); }; + _container_content_chapters.onClose.subscribe { switchContentView(_container_content_main); }; + + _container_content_chapters.onClick.subscribe { + handleSeek(it.timeStart.toLong() * 1000); + } _description_viewMore.setOnClickListener { switchContentView(_container_content_description); @@ -852,6 +867,22 @@ class VideoDetailView : ConstraintLayout { _cast.stopAllGestures(); } + fun showChaptersUI(){ + video?.let { + try { + _chapters?.let { + if(it.size == 0) + return@let; + _container_content_chapters.setChapters(_chapters); + switchContentView(_container_content_chapters); + } + } + catch(ex: Throwable) { + + } + } + } + fun updateMoreButtons() { val isLimitedVersion = video?.url != null && StatePlatform.instance.getContentClientOrNull(video!!.url)?.let { if (it is JSClient) @@ -865,6 +896,13 @@ class VideoDetailView : ConstraintLayout { }; } }, + _chapters?.let { + if(it != null && it.size > 0) + RoundButton(context, R.drawable.ic_list, "Chapters", TAG_CHAPTERS) { + showChaptersUI(); + } + else null + }, if(video?.isLive ?: false) RoundButton(context, R.drawable.ic_chat, context.getString(R.string.live_chat), TAG_LIVECHAT) { video?.let { @@ -1340,10 +1378,12 @@ class VideoDetailView : ConstraintLayout { val chapters = null ?: StatePlatform.instance.getContentChapters(video.url); _player.setChapters(chapters); _cast.setChapters(chapters); + _chapters = _player.getChapters(); } catch (ex: Throwable) { Logger.e(TAG, "Failed to get chapters", ex); _player.setChapters(null); _cast.setChapters(null); + _chapters = null; /*withContext(Dispatchers.Main) { UIDialogs.toast(context, "Failed to get chapters\n" + ex.message); @@ -1382,6 +1422,10 @@ class VideoDetailView : ConstraintLayout { ); } } + + fragment.lifecycleScope.launch(Dispatchers.Main) { + updateMoreButtons(); + } }; } @@ -1863,7 +1907,7 @@ class VideoDetailView : ConstraintLayout { else null; withContext(Dispatchers.Main) { video = newDetails; - _player.setSource(newVideoSource, newAudioSource, true, true); + _player.setSource(newVideoSource, newAudioSource, true, true, true); } } } catch (e: Throwable) { @@ -2601,7 +2645,10 @@ class VideoDetailView : ConstraintLayout { } onChannelClicked.subscribe { - fragment.navigate(it) + if(it.url.isNotBlank()) + fragment.navigate(it) + else + UIDialogs.appToast("No author url present"); } onAddToWatchLaterClicked.subscribe(this) { @@ -3077,6 +3124,7 @@ class VideoDetailView : ConstraintLayout { const val TAG_SHARE = "share"; const val TAG_OVERLAY = "overlay"; const val TAG_LIVECHAT = "livechat"; + const val TAG_CHAPTERS = "chapters"; const val TAG_OPEN = "open"; const val TAG_SEND_TO_DEVICE = "send_to_device"; const val TAG_MORE = "MORE"; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt index b458a093..441f7421 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt @@ -9,6 +9,7 @@ import android.widget.ImageButton import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView +import androidx.core.view.isVisible import androidx.core.view.setPadding import com.bumptech.glide.Glide import com.futo.platformplayer.R @@ -22,6 +23,7 @@ import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.toHumanDuration import com.futo.platformplayer.toHumanTime +import com.futo.platformplayer.views.SearchView import com.futo.platformplayer.views.lists.VideoListEditorView abstract class VideoListEditorView : LinearLayout { @@ -37,9 +39,15 @@ abstract class VideoListEditorView : LinearLayout { protected var _buttonExport: ImageButton; private var _buttonShare: ImageButton; private var _buttonEdit: ImageButton; + private var _buttonSearch: ImageButton; + + private var _search: SearchView; private var _onShare: (()->Unit)? = null; + private var _loadedVideos: List? = null; + private var _loadedVideosCanEdit: Boolean = false; + constructor(inflater: LayoutInflater) : super(inflater.context) { inflater.inflate(R.layout.fragment_video_list_editor, this); @@ -57,6 +65,26 @@ abstract class VideoListEditorView : LinearLayout { _buttonDownload.visibility = View.GONE; _buttonExport = findViewById(R.id.button_export); _buttonExport.visibility = View.GONE; + _buttonSearch = findViewById(R.id.button_search); + + _search = findViewById(R.id.search_bar); + _search.visibility = View.GONE; + _search.onSearchChanged.subscribe { + updateVideoFilters(); + } + + _buttonSearch.setOnClickListener { + if(_search.isVisible) { + _search.visibility = View.GONE; + _search.textSearch.text = ""; + updateVideoFilters(); + _buttonSearch.setImageResource(R.drawable.ic_search); + } + else { + _search.visibility = View.VISIBLE; + _buttonSearch.setImageResource(R.drawable.ic_search_off); + } + } _buttonShare = findViewById(R.id.button_share); val onShare = _onShare; @@ -171,9 +199,22 @@ abstract class VideoListEditorView : LinearLayout { .load(R.drawable.placeholder_video_thumbnail) .into(_imagePlaylistThumbnail) } - + _loadedVideos = videos; + _loadedVideosCanEdit = canEdit; _videoListEditorView.setVideos(videos, canEdit); } + fun filterVideos(videos: List): List { + var toReturn = videos; + val searchStr = _search.textSearch.text + if(!searchStr.isNullOrBlank()) + toReturn = toReturn.filter { it.name.contains(searchStr, true) || it.author.name.contains(searchStr, true) }; + return toReturn; + } + + fun updateVideoFilters() { + val videos = _loadedVideos ?: return; + _videoListEditorView.setVideos(filterVideos(videos), _loadedVideosCanEdit); + } protected fun setButtonDownloadVisible(isVisible: Boolean) { _buttonDownload.visibility = if (isVisible) View.VISIBLE else View.GONE; diff --git a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt index 522647de..cad49efd 100644 --- a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt +++ b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt @@ -214,5 +214,38 @@ class VideoHelper { } else return 0; } + + fun mediaExtensionToMimetype(extension: String): String? { + return videoExtensionToMimetype(extension) ?: audioExtensionToMimetype(extension); + } + fun videoExtensionToMimetype(extension: String): String? { + val extensionTrimmed = extension.trim('.').lowercase(); + return when (extensionTrimmed) { + "mp4" -> return "video/mp4"; + "webm" -> return "video/webm"; + "m3u8" -> return "video/x-mpegURL"; + "3gp" -> return "video/3gpp"; + "mov" -> return "video/quicktime"; + "mkv" -> return "video/x-matroska"; + "mp4a" -> return "audio/vnd.apple.mpegurl"; + "mpga" -> return "audio/mpga"; + "mp3" -> return "audio/mp3"; + "webm" -> return "audio/webm"; + "3gp" -> return "audio/3gpp"; + else -> null; + } + } + fun audioExtensionToMimetype(extension: String): String? { + val extensionTrimmed = extension.trim('.').lowercase(); + return when (extensionTrimmed) { + "mkv" -> return "audio/x-matroska"; + "mp4a" -> return "audio/vnd.apple.mpegurl"; + "mpga" -> return "audio/mpga"; + "mp3" -> return "audio/mp3"; + "webm" -> return "audio/webm"; + "3gp" -> return "audio/3gpp"; + else -> null; + } + } } } diff --git a/app/src/main/java/com/futo/platformplayer/models/HistoryVideo.kt b/app/src/main/java/com/futo/platformplayer/models/HistoryVideo.kt index b491f95f..80574968 100644 --- a/app/src/main/java/com/futo/platformplayer/models/HistoryVideo.kt +++ b/app/src/main/java/com/futo/platformplayer/models/HistoryVideo.kt @@ -3,6 +3,7 @@ package com.futo.platformplayer.models import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.serializers.OffsetDateTimeSerializer import java.time.LocalDateTime @@ -46,6 +47,7 @@ class HistoryVideo { val name = str.substring(indexNext + 3); val video = resolve?.invoke(url) ?: SerializedPlatformVideo( + ContentType.MEDIA, id = PlatformID.asUrlID(url), name = name, thumbnails = Thumbnails(), diff --git a/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt b/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt index 00594df7..97fe6408 100644 --- a/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt +++ b/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt @@ -8,6 +8,7 @@ import com.bumptech.glide.Glide import com.futo.platformplayer.PresetImages import com.futo.platformplayer.R import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateSubscriptions import kotlinx.serialization.Contextual import kotlinx.serialization.Transient import java.io.File @@ -35,6 +36,12 @@ data class ImageVariable( } else if(!url.isNullOrEmpty()) { Glide.with(imageView) .load(url) + .error(if(!subscriptionUrl.isNullOrBlank()) StateSubscriptions.instance.getSubscription(subscriptionUrl!!)?.channel?.thumbnail else null) + .placeholder(R.drawable.placeholder_channel_thumbnail) + .into(imageView); + } else if(!subscriptionUrl.isNullOrEmpty()) { + Glide.with(imageView) + .load(StateSubscriptions.instance.getSubscription(subscriptionUrl!!)?.channel?.thumbnail) .placeholder(R.drawable.placeholder_channel_thumbnail) .into(imageView); } else if(!presetName.isNullOrEmpty()) { diff --git a/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt b/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt index 31fbaadd..faee4e3b 100644 --- a/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt +++ b/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt @@ -39,4 +39,16 @@ class OffsetDateTimeSerializer : KSerializer { return OffsetDateTime.MIN; return OffsetDateTime.of(LocalDateTime.ofEpochSecond(epochSecond, 0, ZoneOffset.UTC), ZoneOffset.UTC); } +} +class OffsetDateTimeStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("OffsetDateTime", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: OffsetDateTime) { + encoder.encodeString(value.toString()); + } + override fun deserialize(decoder: Decoder): OffsetDateTime { + val str = decoder.decodeString(); + + return OffsetDateTime.parse(str); + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt index 65892a1e..1d1acff6 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.states +import SubsExchangeClient import com.futo.platformplayer.Settings import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.models.channels.IPlatformChannel @@ -18,6 +19,7 @@ import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.resolveChannelUrl import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.StringDateMapStorage +import com.futo.platformplayer.stores.StringStorage import com.futo.platformplayer.stores.StringStringMapStorage import com.futo.platformplayer.stores.SubscriptionStorage import com.futo.platformplayer.stores.v2.ReconstructStore @@ -67,10 +69,24 @@ class StateSubscriptions { val onSubscriptionsChanged = Event2, Boolean>(); + private val _subsExchangeServer = "https://exchange.grayjay.app/"; + private val _subscriptionKey = FragmentedStorage.get("sub_exchange_key"); + init { global.onUpdateProgress.subscribe { progress, total -> onFeedProgress.emit(null, progress, total); } + if(_subscriptionKey.value.isNullOrBlank()) + generateNewSubsExchangeKey(); + } + + fun generateNewSubsExchangeKey(){ + _subscriptionKey.setAndSave(SubsExchangeClient.createPrivateKey()); + } + fun getSubsExchangeClient(): SubsExchangeClient { + if(_subscriptionKey.value.isNullOrBlank()) + throw IllegalStateException("No valid subscription exchange key set"); + return SubsExchangeClient(_subsExchangeServer, _subscriptionKey.value); } fun getOldestUpdateTime(): OffsetDateTime { @@ -359,7 +375,17 @@ class StateSubscriptions { } fun getSubscriptionsFeedWithExceptions(allowFailure: Boolean = false, withCacheFallback: Boolean = false, cacheScope: CoroutineScope, onProgress: ((Int, Int)->Unit)? = null, onNewCacheHit: ((Subscription, IPlatformContent)->Unit)? = null, subGroup: SubscriptionGroup? = null): Pair, List> { - val algo = SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, cacheScope, allowFailure, withCacheFallback, _subscriptionsPool); + var exchangeClient: SubsExchangeClient? = null; + if(Settings.instance.subscriptions.useSubscriptionExchange) { + try { + exchangeClient = getSubsExchangeClient(); + } + catch(ex: Throwable){ + Logger.e(TAG, "Failed to get subs exchange client: ${ex.message}", ex); + } + } + + val algo = SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, cacheScope, allowFailure, withCacheFallback, _subscriptionsPool, exchangeClient); if(onNewCacheHit != null) algo.onNewCacheHit.subscribe(onNewCacheHit) diff --git a/app/src/main/java/com/futo/platformplayer/subscription/SmartSubscriptionAlgorithm.kt b/app/src/main/java/com/futo/platformplayer/subscription/SmartSubscriptionAlgorithm.kt index dfed7fd2..de89d195 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SmartSubscriptionAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SmartSubscriptionAlgorithm.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.subscription +import SubsExchangeClient import com.futo.platformplayer.Settings import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.platforms.js.JSClient @@ -15,8 +16,9 @@ class SmartSubscriptionAlgorithm( scope: CoroutineScope, allowFailure: Boolean = false, withCacheFallback: Boolean = true, - threadPool: ForkJoinPool? = null -): SubscriptionsTaskFetchAlgorithm(scope, allowFailure, withCacheFallback, threadPool) { + threadPool: ForkJoinPool? = null, + subsExchangeClient: SubsExchangeClient? = null +): SubscriptionsTaskFetchAlgorithm(scope, allowFailure, withCacheFallback, threadPool, subsExchangeClient) { override fun getSubscriptionTasks(subs: Map>): List { val allTasks: List = subs.flatMap { entry -> val sub = entry.key; diff --git a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionFetchAlgorithm.kt b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionFetchAlgorithm.kt index a34f0e33..269a75f5 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionFetchAlgorithm.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.subscription +import SubsExchangeClient import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.structures.IPager @@ -33,11 +34,11 @@ abstract class SubscriptionFetchAlgorithm( companion object { public val TAG = "SubscriptionAlgorithm"; - fun getAlgorithm(algo: SubscriptionFetchAlgorithms, scope: CoroutineScope, allowFailure: Boolean = false, withCacheFallback: Boolean = false, pool: ForkJoinPool? = null): SubscriptionFetchAlgorithm { + fun getAlgorithm(algo: SubscriptionFetchAlgorithms, scope: CoroutineScope, allowFailure: Boolean = false, withCacheFallback: Boolean = false, pool: ForkJoinPool? = null, withExchangeClient: SubsExchangeClient? = null): SubscriptionFetchAlgorithm { return when(algo) { SubscriptionFetchAlgorithms.CACHE -> CachedSubscriptionAlgorithm(scope, allowFailure, withCacheFallback, pool, 50); SubscriptionFetchAlgorithms.SIMPLE -> SimpleSubscriptionAlgorithm(scope, allowFailure, withCacheFallback, pool); - SubscriptionFetchAlgorithms.SMART -> SmartSubscriptionAlgorithm(scope, allowFailure, withCacheFallback, pool); + SubscriptionFetchAlgorithms.SMART -> SmartSubscriptionAlgorithm(scope, allowFailure, withCacheFallback, pool, withExchangeClient); } } } diff --git a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt index eff83030..123b0320 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt @@ -1,19 +1,23 @@ package com.futo.platformplayer.subscription +import SubsExchangeClient import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.structures.DedupContentPager import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.MultiChronoContentPager +import com.futo.platformplayer.api.media.structures.PlatformContentPager import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCriticalException -import com.futo.platformplayer.engine.exceptions.ScriptException import com.futo.platformplayer.exceptions.ChannelException import com.futo.platformplayer.findNonRuntimeException import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment @@ -24,6 +28,9 @@ import com.futo.platformplayer.states.StateCache import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.states.StateSubscriptions +import com.futo.platformplayer.subsexchange.ChannelRequest +import com.futo.platformplayer.subsexchange.ChannelResolve +import com.futo.platformplayer.subsexchange.ExchangeContract import kotlinx.coroutines.CoroutineScope import java.time.OffsetDateTime import java.util.concurrent.ExecutionException @@ -35,7 +42,8 @@ abstract class SubscriptionsTaskFetchAlgorithm( scope: CoroutineScope, allowFailure: Boolean = false, withCacheFallback: Boolean = true, - _threadPool: ForkJoinPool? = null + _threadPool: ForkJoinPool? = null, + private val subsExchangeClient: SubsExchangeClient? = null ) : SubscriptionFetchAlgorithm(scope, allowFailure, withCacheFallback, _threadPool) { @@ -45,7 +53,7 @@ abstract class SubscriptionsTaskFetchAlgorithm( } override fun getSubscriptions(subs: Map>): Result { - val tasks = getSubscriptionTasks(subs); + var tasks = getSubscriptionTasks(subs).toMutableList() val tasksGrouped = tasks.groupBy { it.client } @@ -70,6 +78,32 @@ abstract class SubscriptionsTaskFetchAlgorithm( val exs: ArrayList = arrayListOf(); + var contract: ExchangeContract? = null; + var providedTasks: MutableList? = null; + + try { + val contractableTasks = + tasks.filter { !it.fromPeek && !it.fromCache && (it.type == ResultCapabilities.TYPE_VIDEOS || it.type == ResultCapabilities.TYPE_MIXED) }; + contract = + if (contractableTasks.size > 10) subsExchangeClient?.requestContract(*contractableTasks.map { + ChannelRequest(it.url) + }.toTypedArray()) else null; + if (contract?.provided?.isNotEmpty() == true) + Logger.i(TAG, "Received subscription exchange contract (Requires ${contract?.required?.size}, Provides ${contract?.provided?.size}), ID: ${contract?.id}"); + if (contract != null && contract.required.isNotEmpty()) { + providedTasks = mutableListOf() + for (task in tasks.toList()) { + if (!task.fromCache && !task.fromPeek && contract.provided.contains(task.url)) { + providedTasks.add(task); + tasks.remove(task); + } + } + } + } + catch(ex: Throwable){ + Logger.e("SubscriptionsTaskFetchAlgorithm", "Failed to retrieve SubsExchange contract due to: " + ex.message, ex); + } + val failedPlugins = mutableListOf(); val cachedChannels = mutableListOf() val forkTasks = executeSubscriptionTasks(tasks, failedPlugins, cachedChannels); @@ -104,6 +138,42 @@ abstract class SubscriptionsTaskFetchAlgorithm( }; } } + + //Resolve Subscription Exchange + if(contract != null) { + try { + val resolves = taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) && contract.required.contains(it.task.url) }.map { + ChannelResolve( + it.task.url, + it.pager!!.getResults().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) } + ) + }.toTypedArray() + val resolve = subsExchangeClient?.resolveContract( + contract, + *resolves + ); + if (resolve != null) { + UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size})") + for(result in resolve){ + val task = providedTasks?.find { it.url == result.channelUrl }; + if(task != null) { + taskResults.add(SubscriptionTaskResult(task, PlatformContentPager(result.content, result.content.size), null)); + providedTasks?.remove(task); + } + } + } + if (providedTasks != null) { + for(task in providedTasks) { + taskResults.add(SubscriptionTaskResult(task, null, IllegalStateException("No data received from exchange"))); + } + } + } + catch(ex: Throwable) { + //TODO: fetch remainder after all? + Logger.e(TAG, "Failed to resolve Subscription Exchange contract due to: " + ex.message, ex); + } + } + Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms") //Cache pagers grouped by channel @@ -173,6 +243,8 @@ abstract class SubscriptionsTaskFetchAlgorithm( Logger.e(StateSubscriptions.TAG, "Subscription peek [${task.sub.channel.name}] failed", ex); } } + + //Intercepts task.fromCache & task.fromPeek synchronized(cachedChannels) { if(task.fromCache || task.fromPeek) { finished++; diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelRequest.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelRequest.kt new file mode 100644 index 00000000..a7939ae4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelRequest.kt @@ -0,0 +1,10 @@ +package com.futo.platformplayer.subsexchange + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +class ChannelRequest( + @SerialName("ChannelUrl") + var channelUrl: String +); \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResolve.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResolve.kt new file mode 100644 index 00000000..7bf5e022 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResolve.kt @@ -0,0 +1,19 @@ +package com.futo.platformplayer.subsexchange + +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.time.OffsetDateTime + +@Serializable +class ChannelResolve( + @SerialName("ChannelUrl") + var channelUrl: String, + @SerialName("Content") + var content: List, + @SerialName("Channel") + var channel: IPlatformChannel? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt new file mode 100644 index 00000000..c13f101c --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt @@ -0,0 +1,23 @@ +package com.futo.platformplayer.subsexchange + +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent +import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer +import com.futo.platformplayer.serializers.OffsetDateTimeSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.time.OffsetDateTime + +@Serializable +class ChannelResult( + @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) + @SerialName("DateTime") + var dateTime: OffsetDateTime, + @SerialName("ChannelUrl") + var channelUrl: String, + @SerialName("Content") + var content: List, + @SerialName("Channel") + var channel: IPlatformChannel? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt new file mode 100644 index 00000000..41a6bf75 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt @@ -0,0 +1,27 @@ +package com.futo.platformplayer.subsexchange + +import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer +import com.futo.platformplayer.serializers.OffsetDateTimeSerializer +import com.futo.platformplayer.serializers.OffsetDateTimeStringSerializer +import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Serializer +import java.time.OffsetDateTime + +@Serializable +class ExchangeContract( + @SerialName("ID") + var id: String, + @SerialName("Requests") + var requests: List, + @SerialName("Provided") + var provided: List = listOf(), + @SerialName("Required") + var required: List = listOf(), + @SerialName("Expire") + @kotlinx.serialization.Serializable(with = OffsetDateTimeStringSerializer::class) + var expired: OffsetDateTime = OffsetDateTime.MIN, + @SerialName("ContractVersion") + var contractVersion: Int = 1 +) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContractResolve.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContractResolve.kt new file mode 100644 index 00000000..8f42e0c3 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContractResolve.kt @@ -0,0 +1,14 @@ +package com.futo.platformplayer.subsexchange + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ExchangeContractResolve( + @SerialName("PublicKey") + val publicKey: String, + @SerialName("Signature") + val signature: String, + @SerialName("Data") + val data: String +) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt new file mode 100644 index 00000000..a58e17b0 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt @@ -0,0 +1,149 @@ +import com.futo.platformplayer.api.media.Serializer +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.subsexchange.ChannelRequest +import com.futo.platformplayer.subsexchange.ChannelResolve +import com.futo.platformplayer.subsexchange.ChannelResult +import com.futo.platformplayer.subsexchange.ExchangeContract +import com.futo.platformplayer.subsexchange.ExchangeContractResolve +import kotlinx.serialization.* +import kotlinx.serialization.json.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection +import java.net.URL +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.PublicKey +import java.security.Signature +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey +import java.util.Base64 +import java.io.InputStreamReader +import java.io.OutputStream +import java.io.OutputStreamWriter +import java.math.BigInteger +import java.nio.charset.StandardCharsets +import java.security.KeyPairGenerator +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.RSAPublicKeySpec + + +class SubsExchangeClient(private val server: String, private val privateKey: String) { + + private val json = Json { + ignoreUnknownKeys = true + } + + private val publicKey: String = extractPublicKey(privateKey) + + // Endpoints + + // Endpoint: Contract + fun requestContract(vararg channels: ChannelRequest): ExchangeContract { + val data = post("/api/Channel/Contract", Json.encodeToString(channels), "application/json") + return Json.decodeFromString(data) + } + suspend fun requestContractAsync(vararg channels: ChannelRequest): ExchangeContract { + val data = postAsync("/api/Channel/Contract", Json.encodeToString(channels), "application/json") + return Json.decodeFromString(data) + } + + // Endpoint: Resolve + fun resolveContract(contract: ExchangeContract, vararg resolves: ChannelResolve): Array { + val contractResolve = convertResolves(*resolves) + val result = post("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json") + return Serializer.json.decodeFromString(result) + } + suspend fun resolveContractAsync(contract: ExchangeContract, vararg resolves: ChannelResolve): Array { + val contractResolve = convertResolves(*resolves) + val result = postAsync("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json") + return Serializer.json.decodeFromString(result) + } + + + private fun convertResolves(vararg resolves: ChannelResolve): ExchangeContractResolve { + val data = Serializer.json.encodeToString(resolves) + val signature = createSignature(data, privateKey) + + return ExchangeContractResolve( + publicKey = publicKey, + signature = signature, + data = data + ) + } + + // IO methods + private fun post(query: String, body: String, contentType: String): String { + val url = URL("${server.trim('/')}$query") + with(url.openConnection() as HttpURLConnection) { + requestMethod = "POST" + setRequestProperty("Content-Type", contentType) + doOutput = true + OutputStreamWriter(outputStream, StandardCharsets.UTF_8).use { it.write(body); it.flush() } + + val status = responseCode; + Logger.i("SubsExchangeClient", "POST [${url}]: ${status}"); + + if(status == 200) + InputStreamReader(inputStream, StandardCharsets.UTF_8).use { + return it.readText() + } + else { + var errorStr = ""; + try { + errorStr = InputStreamReader(errorStream, StandardCharsets.UTF_8).use { + return@use it.readText() + } + } + catch(ex: Throwable){} + + throw Exception("Exchange server resulted in code ${status}:\n" + errorStr); + + } + } + } + private suspend fun postAsync(query: String, body: String, contentType: String): String { + return withContext(Dispatchers.IO) { + post(query, body, contentType) + } + } + + // Crypto methods + companion object { + fun createPrivateKey(): String { + val rsa = KeyFactory.getInstance("RSA") + val keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + val keyPair = keyPairGenerator.generateKeyPair(); + return Base64.getEncoder().encodeToString(keyPair.private.encoded); + } + + fun extractPublicKey(privateKey: String): String { + val keySpec = PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKey)) + val keyFactory = KeyFactory.getInstance("RSA") + val privateKeyObj = keyFactory.generatePrivate(keySpec) as RSAPrivateKey + val publicKeyObj: PublicKey? = keyFactory.generatePublic(RSAPublicKeySpec(privateKeyObj.modulus, BigInteger.valueOf(65537))); + var publicKeyBase64 = Base64.getEncoder().encodeToString(publicKeyObj?.encoded); + var pem = "-----BEGIN PUBLIC KEY-----" + while(publicKeyBase64.length > 0) { + val length = Math.min(publicKeyBase64.length, 64); + pem += "\n" + publicKeyBase64.substring(0, length); + publicKeyBase64 = publicKeyBase64.substring(length); + } + return pem + "\n-----END PUBLIC KEY-----"; + } + + fun createSignature(data: String, privateKey: String): String { + val keySpec = PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKey)) + val keyFactory = KeyFactory.getInstance("RSA") + val rsaPrivateKey = keyFactory.generatePrivate(keySpec) as RSAPrivateKey + + val signature = Signature.getInstance("SHA256withRSA") + signature.initSign(rsaPrivateKey) + signature.update(data.toByteArray(Charsets.UTF_8)) + + val signatureBytes = signature.sign() + return Base64.getEncoder().encodeToString(signatureBytes) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/ChapterViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/ChapterViewHolder.kt new file mode 100644 index 00000000..0b7efd4a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/ChapterViewHolder.kt @@ -0,0 +1,91 @@ +package com.futo.platformplayer.views.adapters + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +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.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.comments.LazyComment +import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.fixHtmlLinks +import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.toHumanDuration +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.toHumanNumber +import com.futo.platformplayer.toHumanTime +import com.futo.platformplayer.views.LoaderView +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.pills.PillButton +import com.futo.platformplayer.views.pills.PillRatingLikesDislikes +import com.futo.polycentric.core.ApiMethods +import com.futo.polycentric.core.Opinion +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class ChapterViewHolder : ViewHolder { + + private val _layoutChapter: ConstraintLayout; + + private val _containerChapter: ConstraintLayout; + + private val _textTitle: TextView; + private val _textTimestamp: TextView; + private val _textMeta: TextView; + + var onClick = Event1(); + var chapter: IChapter? = null + private set; + + constructor(viewGroup: ViewGroup) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_chapter, viewGroup, false)) { + _layoutChapter = itemView.findViewById(R.id.layout_chapter); + _containerChapter = itemView.findViewById(R.id.chapter_container); + + _containerChapter.setOnClickListener { + chapter?.let { + onClick.emit(it); + } + } + + _textTitle = itemView.findViewById(R.id.text_title); + _textTimestamp = itemView.findViewById(R.id.text_timestamp); + _textMeta = itemView.findViewById(R.id.text_meta); + } + + fun bind(chapter: IChapter) { + _textTitle.text = chapter.name; + _textTimestamp.text = chapter.timeStart.toLong().toHumanTime(false); + + if(chapter.type == ChapterType.NORMAL) { + _textMeta.isVisible = false; + } + else { + _textMeta.isVisible = true; + when(chapter.type) { + ChapterType.SKIP -> _textMeta.text = "(Skip)"; + ChapterType.SKIPPABLE -> _textMeta.text = "(Manual Skip)" + ChapterType.SKIPONCE -> _textMeta.text = "(Skip Once)" + else -> _textMeta.isVisible = false; + }; + } + this.chapter = chapter; + } + + companion object { + private const val TAG = "CommentViewHolder"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/ChaptersOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/ChaptersOverlay.kt new file mode 100644 index 00000000..becdb87b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/ChaptersOverlay.kt @@ -0,0 +1,72 @@ +package com.futo.platformplayer.views.overlays + +import android.content.Context +import android.net.Uri +import android.util.AttributeSet +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.api.media.models.chapters.IChapter +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.fixHtmlLinks +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.views.behavior.NonScrollingTextView +import com.futo.platformplayer.views.comments.AddCommentView +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.segments.ChaptersList +import com.futo.platformplayer.views.segments.CommentsList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import userpackage.Protocol + +class ChaptersOverlay : LinearLayout { + val onClose = Event0(); + val onClick = Event1(); + + private val _topbar: OverlayTopbar; + private val _chaptersList: ChaptersList; + private var _onChapterClicked: ((chapter: IChapter) -> Unit)? = null; + private val _layoutItems: LinearLayout + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + inflate(context, R.layout.overlay_chapters, this) + _layoutItems = findViewById(R.id.layout_items) + _topbar = findViewById(R.id.topbar); + _chaptersList = findViewById(R.id.chapters_list); + _chaptersList.onChapterClick.subscribe(onClick::emit); + _topbar.onClose.subscribe(this, onClose::emit); + _topbar.setInfo(context.getString(R.string.chapters), ""); + } + + fun setChapters(chapters: List?) { + _chaptersList?.setChapters(chapters ?: listOf()); + } + + + fun cleanup() { + _topbar.onClose.remove(this); + _onChapterClicked = null; + } + + companion object { + private const val TAG = "ChaptersOverlay" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/ImageVariableOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/ImageVariableOverlay.kt index 5d5a6a50..b8fb8a77 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/ImageVariableOverlay.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/ImageVariableOverlay.kt @@ -98,7 +98,11 @@ class ImageVariableOverlay: ConstraintLayout { UIDialogs.toast(context, "No thumbnail found"); return@subscribe; } - _selected = ImageVariable(it.channel.thumbnail); + val channelUrl = it.channel.url; + _selected = ImageVariable(it.channel.thumbnail).let { + it.subscriptionUrl = channelUrl; + return@let it; + } updateSelected(); }; }; diff --git a/app/src/main/java/com/futo/platformplayer/views/segments/ChaptersList.kt b/app/src/main/java/com/futo/platformplayer/views/segments/ChaptersList.kt new file mode 100644 index 00000000..50d7ba04 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/segments/ChaptersList.kt @@ -0,0 +1,103 @@ +package com.futo.platformplayer.views.segments + +import android.content.Context +import android.graphics.Color +import android.util.AttributeSet +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.media.models.chapters.IChapter +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.comments.LazyComment +import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.api.media.structures.IAsyncPager +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.views.adapters.ChapterViewHolder +import com.futo.platformplayer.views.adapters.CommentViewHolder +import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader +import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.net.UnknownHostException + +class ChaptersList : ConstraintLayout { + private val _llmReplies: LinearLayoutManager; + + private val _adapterChapters: InsertedViewAdapterWithLoader; + private val _recyclerChapters: RecyclerView; + private val _chapters: ArrayList = arrayListOf(); + private val _prependedView: FrameLayout; + private var _readonly: Boolean = false; + private val _layoutScrollToTop: FrameLayout; + + var onChapterClick = Event1(); + var onCommentsLoaded = Event1(); + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { + LayoutInflater.from(context).inflate(R.layout.view_chapters_list, this, true); + + _recyclerChapters = findViewById(R.id.recycler_chapters); + + _layoutScrollToTop = findViewById(R.id.layout_scroll_to_top); + _layoutScrollToTop.setOnClickListener { + _recyclerChapters.smoothScrollToPosition(0) + } + _layoutScrollToTop.visibility = View.GONE + + _prependedView = FrameLayout(context); + _prependedView.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT); + + _adapterChapters = InsertedViewAdapterWithLoader(context, arrayListOf(_prependedView), arrayListOf(), + childCountGetter = { _chapters.size }, + childViewHolderBinder = { viewHolder, position -> viewHolder.bind(_chapters[position]); }, + childViewHolderFactory = { viewGroup, _ -> + val holder = ChapterViewHolder(viewGroup); + holder.onClick.subscribe { c -> onChapterClick.emit(c) }; + return@InsertedViewAdapterWithLoader holder; + } + ); + + _llmReplies = LinearLayoutManager(context); + _recyclerChapters.layoutManager = _llmReplies; + _recyclerChapters.adapter = _adapterChapters; + } + + fun addChapter(chapter: IChapter) { + _chapters.add(0, chapter); + _adapterChapters.notifyItemRangeInserted(_adapterChapters.childToParentPosition(0), 1); + } + + fun setPrependedView(view: View) { + _prependedView.removeAllViews(); + _prependedView.addView(view); + } + + fun setChapters(chapters: List) { + _chapters.clear(); + _chapters.addAll(chapters); + _adapterChapters.notifyDataSetChanged(); + } + + fun clear() { + _chapters.clear(); + _adapterChapters.notifyDataSetChanged(); + } + + companion object { + private const val TAG = "CommentsList"; + } +} \ No newline at end of file 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 ec0345fb..1daa7808 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 @@ -145,6 +145,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase { val onVideoClicked = Event0(); val onTimeBarChanged = Event2(); + val onChapterClicked = Event1(); + @OptIn(UnstableApi::class) constructor(context: Context, attrs: AttributeSet? = null) : super(PLAYER_STATE_NAME, context, attrs) { LayoutInflater.from(context).inflate(R.layout.video_view, this, true); @@ -185,6 +187,12 @@ class FutoVideoPlayer : FutoVideoPlayerBase { _control_duration_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_duration); _control_pause_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_pause); + _control_chapter.setOnClickListener { + _currentChapter?.let { + onChapterClicked.emit(it); + } + } + val castVisibility = if (Settings.instance.casting.enabled) View.VISIBLE else View.GONE _control_cast.visibility = castVisibility _control_cast_fullscreen.visibility = castVisibility diff --git a/app/src/main/res/drawable/ic_search_off.xml b/app/src/main/res/drawable/ic_search_off.xml new file mode 100644 index 00000000..08810c6c --- /dev/null +++ b/app/src/main/res/drawable/ic_search_off.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/fragment_downloads.xml b/app/src/main/res/layout/fragment_downloads.xml index 2b971a0b..c1b12c0b 100644 --- a/app/src/main/res/layout/fragment_downloads.xml +++ b/app/src/main/res/layout/fragment_downloads.xml @@ -168,7 +168,7 @@ android:layout_height="wrap_content" android:layout_marginTop="10dp" android:background="@drawable/background_button_round" - android:hint="Seach.." /> + android:hint="Search.." /> @@ -87,7 +87,7 @@ - - + + android:gravity="center_vertical"> - + + + + + + + + + + + + + + + + + + - @@ -136,7 +183,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" app:layout_behavior="@string/appbar_scrolling_view_behavior" - app:layout_constraintTop_toBottomOf="@id/text_view_all" + app:layout_constraintTop_toBottomOf="@id/playlists_filter_container" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" android:paddingTop="10dp" diff --git a/app/src/main/res/layout/fragment_video_list_editor.xml b/app/src/main/res/layout/fragment_video_list_editor.xml index a906421b..f86b69e4 100644 --- a/app/src/main/res/layout/fragment_video_list_editor.xml +++ b/app/src/main/res/layout/fragment_video_list_editor.xml @@ -30,7 +30,7 @@ android:orientation="vertical"> + android:layout_height="wrap_content"> + @@ -116,6 +132,8 @@ app:layout_constraintLeft_toLeftOf="@id/container_buttons" app:layout_constraintBottom_toTopOf="@id/container_buttons" /> + + + app:srcCompat="@drawable/ic_search" + app:tint="@color/white" + android:padding="5dp" + android:scaleType="fitCenter" /> + diff --git a/app/src/main/res/layout/fragview_video_detail.xml b/app/src/main/res/layout/fragview_video_detail.xml index d5062c06..7b8c1dc2 100644 --- a/app/src/main/res/layout/fragview_video_detail.xml +++ b/app/src/main/res/layout/fragview_video_detail.xml @@ -579,6 +579,12 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/overlay_chapters.xml b/app/src/main/res/layout/overlay_chapters.xml new file mode 100644 index 00000000..6664847c --- /dev/null +++ b/app/src/main/res/layout/overlay_chapters.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_chapters_list.xml b/app/src/main/res/layout/view_chapters_list.xml new file mode 100644 index 00000000..8fda13f3 --- /dev/null +++ b/app/src/main/res/layout/view_chapters_list.xml @@ -0,0 +1,31 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8f5605c0..9d82ff85 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -412,6 +412,8 @@ Optimize bandwidth usage by switching to audio-only stream in background if available, may cause stutter Groups Show Subscription Groups + Use Subscription Exchange (Experimental) + Uses a centralized crowd-sourced server to significantly reduce the required requests for subscriptions, in exchange you submit your subscriptions to the server. If subscription groups should be shown above your subscriptions to filter Preview Feed Items When the preview feedstyle is used, if items should auto-preview when scrolling over them @@ -663,6 +665,7 @@ Failed to load post. replies Replies + Chapters Plugin settings saved Plugin settings These settings are defined by the plugin @@ -970,6 +973,16 @@ Release Date (Oldest) Release Date (Newest) + + Name (Ascending) + Name (Descending) + Modified Date (Oldest) + Modified Date (Newest) + Creation Date (Oldest) + Creation Date (Newest) + Play Date (Oldest) + Play Date (Newest) + Preview List diff --git a/app/src/test/java/com/futo/platformplayer/RequireMigrationTests.kt b/app/src/test/java/com/futo/platformplayer/RequireMigrationTests.kt index 1b5eabc8..3118194b 100644 --- a/app/src/test/java/com/futo/platformplayer/RequireMigrationTests.kt +++ b/app/src/test/java/com/futo/platformplayer/RequireMigrationTests.kt @@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.Serializer import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.Thumbnail import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails import com.futo.platformplayer.serializers.FlexibleBooleanSerializer @@ -39,6 +40,7 @@ class RequireMigrationTests { val viewCount = 1000L return SerializedPlatformVideo( + ContentType.MEDIA, platformId, name, thumbnails,