mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-04-19 19:14:51 +00:00
Merge branch 'subs-exchange' into 'master'
Experimental Subs Exchange See merge request videostreaming/grayjay!91
This commit is contained in:
commit
b57abb646f
43 changed files with 1312 additions and 61 deletions
|
@ -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;
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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<IVideoSource> get() = video.videoSource.toTypedArray();
|
|
@ -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,
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
package com.futo.platformplayer.api.media.platforms.local
|
||||
|
||||
class LocalClient {
|
||||
//TODO
|
||||
}
|
|
@ -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<ISubtitleSource> = 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<IPlatformComment>? {
|
||||
return null;
|
||||
}
|
||||
|
||||
override fun getPlaybackTracker(): IPlaybackTracker? {
|
||||
return null;
|
||||
}
|
||||
|
||||
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -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<IVideoSource> get() = arrayOf(video);
|
||||
}
|
|
@ -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<String>, order: String? = null): Cursor? {
|
||||
val cursor = context.contentResolver.query(URI, PROJECTION, selection, args, order ?: ORDER, null);
|
||||
return cursor;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -229,7 +229,7 @@ class DownloadsFragment : MainFragment() {
|
|||
fun filterDownloads(vids: List<VideoLocal>): List<VideoLocal>{
|
||||
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 };
|
||||
|
|
|
@ -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<IPlatformVideo> = arrayListOf();
|
||||
var allPlaylists: ArrayList<Playlist> = arrayListOf();
|
||||
var playlists: ArrayList<Playlist> = 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<RecyclerView>(R.id.recycler_watch_later);
|
||||
|
||||
|
@ -105,6 +121,7 @@ class PlaylistsFragment : MainFragment() {
|
|||
buttonCreatePlaylist.setOnClickListener {
|
||||
_slideUpOverlay = UISlideOverlays.showCreatePlaylistOverlay(findViewById<FrameLayout>(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<TextView>(R.id.text_view_all).setOnClickListener { _fragment.navigate<WatchLaterFragment>(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<Playlist>): List<Playlist> {
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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<IChapter>? = 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<ChannelFragment>(it)
|
||||
if(it.url.isNotBlank())
|
||||
fragment.navigate<ChannelFragment>(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";
|
||||
|
|
|
@ -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<IPlatformVideo>? = 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<IPlatformVideo>): List<IPlatformVideo> {
|
||||
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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -39,4 +39,16 @@ class OffsetDateTimeSerializer : KSerializer<OffsetDateTime> {
|
|||
return OffsetDateTime.MIN;
|
||||
return OffsetDateTime.of(LocalDateTime.ofEpochSecond(epochSecond, 0, ZoneOffset.UTC), ZoneOffset.UTC);
|
||||
}
|
||||
}
|
||||
class OffsetDateTimeStringSerializer : KSerializer<OffsetDateTime> {
|
||||
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);
|
||||
}
|
||||
}
|
|
@ -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<List<Subscription>, Boolean>();
|
||||
|
||||
private val _subsExchangeServer = "https://exchange.grayjay.app/";
|
||||
private val _subscriptionKey = FragmentedStorage.get<StringStorage>("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<IPager<IPlatformContent>, List<Throwable>> {
|
||||
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)
|
||||
|
||||
|
|
|
@ -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<Subscription, List<String>>): List<SubscriptionTask> {
|
||||
val allTasks: List<SubscriptionTask> = subs.flatMap { entry ->
|
||||
val sub = entry.key;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Subscription, List<String>>): 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<Throwable> = arrayListOf();
|
||||
|
||||
var contract: ExchangeContract? = null;
|
||||
var providedTasks: MutableList<SubscriptionTask>? = 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<String>();
|
||||
val cachedChannels = mutableListOf<String>()
|
||||
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++;
|
||||
|
|
|
@ -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
|
||||
);
|
|
@ -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<SerializedPlatformContent>,
|
||||
@SerialName("Channel")
|
||||
var channel: IPlatformChannel? = null
|
||||
)
|
|
@ -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<SerializedPlatformContent>,
|
||||
@SerialName("Channel")
|
||||
var channel: IPlatformChannel? = null
|
||||
)
|
|
@ -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<ChannelRequest>,
|
||||
@SerialName("Provided")
|
||||
var provided: List<String> = listOf(),
|
||||
@SerialName("Required")
|
||||
var required: List<String> = listOf(),
|
||||
@SerialName("Expire")
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeStringSerializer::class)
|
||||
var expired: OffsetDateTime = OffsetDateTime.MIN,
|
||||
@SerialName("ContractVersion")
|
||||
var contractVersion: Int = 1
|
||||
)
|
|
@ -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
|
||||
)
|
|
@ -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<ChannelResult> {
|
||||
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<ChannelResult> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<IChapter>();
|
||||
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";
|
||||
}
|
||||
}
|
|
@ -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<IChapter>();
|
||||
|
||||
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<IChapter>?) {
|
||||
_chaptersList?.setChapters(chapters ?: listOf());
|
||||
}
|
||||
|
||||
|
||||
fun cleanup() {
|
||||
_topbar.onClose.remove(this);
|
||||
_onChapterClicked = null;
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ChaptersOverlay"
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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<ChapterViewHolder>;
|
||||
private val _recyclerChapters: RecyclerView;
|
||||
private val _chapters: ArrayList<IChapter> = arrayListOf();
|
||||
private val _prependedView: FrameLayout;
|
||||
private var _readonly: Boolean = false;
|
||||
private val _layoutScrollToTop: FrameLayout;
|
||||
|
||||
var onChapterClick = Event1<IChapter>();
|
||||
var onCommentsLoaded = Event1<Int>();
|
||||
|
||||
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<IChapter>) {
|
||||
_chapters.clear();
|
||||
_chapters.addAll(chapters);
|
||||
_adapterChapters.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_chapters.clear();
|
||||
_adapterChapters.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CommentsList";
|
||||
}
|
||||
}
|
|
@ -145,6 +145,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||
val onVideoClicked = Event0();
|
||||
val onTimeBarChanged = Event2<Long, Long>();
|
||||
|
||||
val onChapterClicked = Event1<IChapter>();
|
||||
|
||||
@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
|
||||
|
|
10
app/src/main/res/drawable/ic_search_off.xml
Normal file
10
app/src/main/res/drawable/ic_search_off.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M280,824.62Q213.15,824.62 166.58,778.04Q120,731.46 120,664.62Q120,597.77 166.58,551.19Q213.15,504.62 280,504.62Q346.85,504.62 393.42,551.19Q440,597.77 440,664.62Q440,731.46 393.42,778.04Q346.85,824.62 280,824.62ZM817.85,806.15L566.46,554.77Q555.69,564 540.69,572.46Q525.69,580.92 512.62,585.54Q508.31,577.15 503.27,569.04Q498.23,560.92 492.92,554.31Q543.23,533.38 576.23,487.62Q609.23,441.85 609.23,380Q609.23,301.15 554.04,245.96Q498.85,190.77 420,190.77Q341.15,190.77 285.96,245.96Q230.77,301.15 230.77,380Q230.77,392.15 232.81,404.58Q234.85,417 237.38,428.38Q228.62,428.85 217.88,432.15Q207.15,435.46 198.62,438.85Q195.08,426.31 192.92,411.08Q190.77,395.85 190.77,380Q190.77,284.08 257.42,217.42Q324.08,150.77 420,150.77Q515.92,150.77 582.58,217.42Q649.23,284.08 649.23,380Q649.23,423 634.19,461.12Q619.15,499.23 595.92,527.38L846.15,777.85L817.85,806.15ZM209.77,756.69L280,686.46L350,756.69L372.08,734.85L301.85,664.62L372.08,594.38L350.23,572.54L280,642.77L209.77,572.54L187.92,594.38L258.15,664.62L187.92,734.85L209.77,756.69Z"/>
|
||||
</vector>
|
|
@ -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.." />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/app_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="230dp"
|
||||
android:layout_height="315dp"
|
||||
android:background="@color/transparent"
|
||||
app:elevation="0dp">
|
||||
|
||||
|
@ -87,7 +87,7 @@
|
|||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="25dp"
|
||||
android:layout_height="110dp"
|
||||
android:minHeight="0dp"
|
||||
app:contentInsetStart="0dp"
|
||||
app:contentInsetEnd="0dp"
|
||||
|
@ -96,35 +96,82 @@
|
|||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_playlists"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="110dp"
|
||||
android:orientation="vertical">
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="16dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:text="@string/playlists"
|
||||
android:paddingStart="15dp"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/recycler_watch_later" />
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<Space
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1" />
|
||||
<TextView
|
||||
android:id="@+id/text_playlists"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="16dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:text="@string/playlists"
|
||||
android:paddingStart="15dp"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/recycler_watch_later" />
|
||||
|
||||
<Space
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/button_create_playlist"
|
||||
android:layout_width="35dp"
|
||||
android:layout_height="20dp"
|
||||
android:contentDescription="@string/cd_button_create_playlist"
|
||||
app:srcCompat="@drawable/ic_add_white_16dp"
|
||||
android:paddingEnd="15dp"
|
||||
android:paddingStart="15dp"
|
||||
android:layout_marginEnd="12dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/playlists_filter_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/playlists_search"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_marginLeft="15dp"
|
||||
android:layout_marginRight="15dp"
|
||||
android:background="@drawable/background_button_round"
|
||||
android:hint="Search.." />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="14dp"
|
||||
android:textColor="@color/gray_ac"
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:text="@string/sort_by"
|
||||
android:paddingStart="20dp" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinner_sortby"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="20dp"
|
||||
android:paddingEnd="20dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/button_create_playlist"
|
||||
android:layout_width="35dp"
|
||||
android:layout_height="20dp"
|
||||
android:contentDescription="@string/cd_button_create_playlist"
|
||||
app:srcCompat="@drawable/ic_add_white_16dp"
|
||||
android:paddingEnd="15dp"
|
||||
android:paddingStart="15dp"
|
||||
android:layout_marginEnd="12dp" />
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.appcompat.widget.Toolbar>
|
||||
|
@ -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"
|
||||
|
|
|
@ -30,7 +30,7 @@
|
|||
android:orientation="vertical">
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="220dp">
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/image_playlist_thumbnail"
|
||||
|
@ -53,6 +53,22 @@
|
|||
android:scaleType="fitXY" />
|
||||
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/button_edit"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:contentDescription="@string/cd_button_edit"
|
||||
android:background="@drawable/background_button_round"
|
||||
android:gravity="center"
|
||||
android:layout_marginStart="5dp"
|
||||
android:layout_marginRight="10dp"
|
||||
app:layout_constraintRight_toLeftOf="@id/button_export"
|
||||
app:layout_constraintTop_toTopOf="@id/button_share"
|
||||
android:orientation="horizontal"
|
||||
android:scaleType="fitCenter"
|
||||
app:srcCompat="@drawable/ic_edit"
|
||||
android:padding="10dp"
|
||||
app:tint="@color/white" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/button_export"
|
||||
|
@ -89,7 +105,7 @@
|
|||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="120dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="-90dp"
|
||||
android:layout_marginStart="20dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent">
|
||||
|
@ -116,6 +132,8 @@
|
|||
app:layout_constraintLeft_toLeftOf="@id/container_buttons"
|
||||
app:layout_constraintBottom_toTopOf="@id/container_buttons" />
|
||||
|
||||
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/container_buttons"
|
||||
android:layout_width="match_parent"
|
||||
|
@ -176,20 +194,18 @@
|
|||
</LinearLayout>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/button_edit"
|
||||
android:id="@+id/button_search"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:contentDescription="@string/cd_button_edit"
|
||||
android:contentDescription="@string/cd_search_icon"
|
||||
android:background="@drawable/background_button_round"
|
||||
android:gravity="center"
|
||||
android:layout_marginStart="5dp"
|
||||
app:layout_constraintLeft_toRightOf="@id/button_shuffle"
|
||||
app:layout_constraintBottom_toBottomOf="@id/button_play_all"
|
||||
android:layout_marginStart="10dp"
|
||||
android:orientation="horizontal"
|
||||
android:scaleType="fitCenter"
|
||||
app:srcCompat="@drawable/ic_edit"
|
||||
android:padding="10dp"
|
||||
app:tint="@color/white" />
|
||||
app:srcCompat="@drawable/ic_search"
|
||||
app:tint="@color/white"
|
||||
android:padding="5dp"
|
||||
android:scaleType="fitCenter" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/button_download"
|
||||
|
@ -207,6 +223,16 @@
|
|||
</LinearLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
<com.futo.platformplayer.views.SearchView
|
||||
android:id="@+id/search_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="-10dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintTop_toBottomOf="@+id/container_buttons"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
/>
|
||||
</LinearLayout>
|
||||
</androidx.appcompat.widget.Toolbar>
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
|
|
@ -579,6 +579,12 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<com.futo.platformplayer.views.overlays.ChaptersOverlay
|
||||
android:id="@+id/videodetail_container_chapters"
|
||||
android:visibility="gone"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<com.futo.platformplayer.views.overlays.SupportOverlay
|
||||
android:id="@+id/videodetail_container_support"
|
||||
android:visibility="gone"
|
||||
|
|
81
app/src/main/res/layout/list_chapter.xml
Normal file
81
app/src/main/res/layout/list_chapter.xml
Normal file
|
@ -0,0 +1,81 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/layout_chapter"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:layout_marginBottom="5dp"
|
||||
android:layout_marginStart="14dp"
|
||||
android:layout_marginEnd="14dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/chapter_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="2dp"
|
||||
android:padding="15dp"
|
||||
android:background="@drawable/background_1b_round_6dp"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent">
|
||||
<TextView
|
||||
android:id="@+id/text_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:maxLines="1"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="14sp"
|
||||
tools:text="Some chapter text" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_meta"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:maxLines="1"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:textColor="@color/text_color_tinted"
|
||||
android:textSize="11sp"
|
||||
tools:text="test" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_timestamp"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:maxLines="1"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:background="@drawable/background_thumbnail_duration"
|
||||
android:paddingLeft="10dp"
|
||||
android:paddingRight="10dp"
|
||||
android:paddingTop="5dp"
|
||||
android:paddingBottom="5dp"
|
||||
android:textColor="@color/gray_ac"
|
||||
android:textSize="14sp"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="1:23" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
34
app/src/main/res/layout/overlay_chapters.xml
Normal file
34
app/src/main/res/layout/overlay_chapters.xml
Normal file
|
@ -0,0 +1,34 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/black"
|
||||
android:orientation="vertical"
|
||||
android:id="@+id/layout_items">
|
||||
|
||||
<com.futo.platformplayer.views.overlays.OverlayTopbar
|
||||
android:id="@+id/topbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="50dp"
|
||||
android:paddingTop="5dp"
|
||||
android:paddingBottom="5dp"
|
||||
android:layout_marginBottom="5dp"
|
||||
app:title="Chapters"
|
||||
app:metadata="" />
|
||||
|
||||
|
||||
|
||||
<com.futo.platformplayer.views.segments.ChaptersList
|
||||
android:id="@+id/chapters_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginTop="12dp" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
31
app/src/main/res/layout/view_chapters_list.xml
Normal file
31
app/src/main/res/layout/view_chapters_list.xml
Normal file
|
@ -0,0 +1,31 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler_chapters"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|center_horizontal"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:paddingBottom="7dp"
|
||||
android:paddingEnd="14dp"
|
||||
android:paddingTop="7dp"
|
||||
android:paddingStart="14dp"
|
||||
android:background="@drawable/background_pill"
|
||||
android:id="@+id/layout_scroll_to_top">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/scroll_to_top"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:textSize="14dp"/>
|
||||
</FrameLayout>
|
||||
</FrameLayout>
|
|
@ -412,6 +412,8 @@
|
|||
<string name="background_switch_audio_description">Optimize bandwidth usage by switching to audio-only stream in background if available, may cause stutter</string>
|
||||
<string name="subscription_group_menu">Groups</string>
|
||||
<string name="show_subscription_group">Show Subscription Groups</string>
|
||||
<string name="use_subscription_exchange">Use Subscription Exchange (Experimental)</string>
|
||||
<string name="use_subscription_exchange_description">Uses a centralized crowd-sourced server to significantly reduce the required requests for subscriptions, in exchange you submit your subscriptions to the server.</string>
|
||||
<string name="show_subscription_group_description">If subscription groups should be shown above your subscriptions to filter</string>
|
||||
<string name="preview_feed_items">Preview Feed Items</string>
|
||||
<string name="preview_feed_items_description">When the preview feedstyle is used, if items should auto-preview when scrolling over them</string>
|
||||
|
@ -663,6 +665,7 @@
|
|||
<string name="failed_to_load_post">Failed to load post.</string>
|
||||
<string name="replies">replies</string>
|
||||
<string name="Replies">Replies</string>
|
||||
<string name="chapters">Chapters</string>
|
||||
<string name="plugin_settings_saved">Plugin settings saved</string>
|
||||
<string name="plugin_settings">Plugin settings</string>
|
||||
<string name="these_settings_are_defined_by_the_plugin">These settings are defined by the plugin</string>
|
||||
|
@ -970,6 +973,16 @@
|
|||
<item>Release Date (Oldest)</item>
|
||||
<item>Release Date (Newest)</item>
|
||||
</string-array>
|
||||
<string-array name="playlists_sortby_array">
|
||||
<item>Name (Ascending)</item>
|
||||
<item>Name (Descending)</item>
|
||||
<item>Modified Date (Oldest)</item>
|
||||
<item>Modified Date (Newest)</item>
|
||||
<item>Creation Date (Oldest)</item>
|
||||
<item>Creation Date (Newest)</item>
|
||||
<item>Play Date (Oldest)</item>
|
||||
<item>Play Date (Newest)</item>
|
||||
</string-array>
|
||||
<string-array name="feed_style">
|
||||
<item>Preview</item>
|
||||
<item>List</item>
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Reference in a new issue