mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-04-19 19:14:51 +00:00
Merge
This commit is contained in:
commit
436846ce1f
97 changed files with 2344 additions and 544 deletions
6
.gitmodules
vendored
6
.gitmodules
vendored
|
@ -88,3 +88,9 @@
|
|||
[submodule "app/src/unstable/assets/sources/apple-podcasts"]
|
||||
path = app/src/unstable/assets/sources/apple-podcasts
|
||||
url = ../plugins/apple-podcasts.git
|
||||
[submodule "app/src/stable/assets/sources/tedtalks"]
|
||||
path = app/src/stable/assets/sources/tedtalks
|
||||
url = ../plugins/tedtalks.git
|
||||
[submodule "app/src/unstable/assets/sources/tedtalks"]
|
||||
path = app/src/unstable/assets/sources/tedtalks
|
||||
url = ../plugins/tedtalks.git
|
||||
|
|
|
@ -205,7 +205,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||
var home = HomeSettings();
|
||||
@Serializable
|
||||
class HomeSettings {
|
||||
@FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 5)
|
||||
@FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 3)
|
||||
@DropdownFieldOptionsId(R.array.feed_style)
|
||||
var homeFeedStyle: Int = 1;
|
||||
|
||||
|
@ -216,6 +216,11 @@ class Settings : FragmentedStorageFileJson() {
|
|||
return FeedStyle.THUMBNAIL;
|
||||
}
|
||||
|
||||
@FormField(R.string.show_home_filters, FieldForm.TOGGLE, R.string.show_home_filters_description, 4)
|
||||
var showHomeFilters: Boolean = true;
|
||||
@FormField(R.string.show_home_filters_plugin_names, FieldForm.TOGGLE, R.string.show_home_filters_plugin_names_description, 5)
|
||||
var showHomeFiltersPluginNames: Boolean = false;
|
||||
|
||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
|
||||
var previewFeedItems: Boolean = true;
|
||||
|
||||
|
@ -294,6 +299,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;
|
||||
|
||||
|
@ -356,7 +364,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||
var playback = PlaybackSettings();
|
||||
@Serializable
|
||||
class PlaybackSettings {
|
||||
@FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, -1)
|
||||
@FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, -2)
|
||||
@DropdownFieldOptionsId(R.array.audio_languages)
|
||||
var primaryLanguage: Int = 0;
|
||||
|
||||
|
@ -380,6 +388,8 @@ class Settings : FragmentedStorageFileJson() {
|
|||
else -> null
|
||||
}
|
||||
}
|
||||
@FormField(R.string.prefer_original_audio, FieldForm.TOGGLE, R.string.prefer_original_audio_description, -1)
|
||||
var preferOriginalAudio: Boolean = true;
|
||||
|
||||
//= context.resources.getStringArray(R.array.audio_languages)[primaryLanguage];
|
||||
|
||||
|
|
|
@ -402,7 +402,7 @@ class UISlideOverlays {
|
|||
UIDialogs.toast(container.context, "Variant video HLS playlist download started")
|
||||
slideUpMenuOverlay.hide()
|
||||
} else if (source is IHLSManifestAudioSource) {
|
||||
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, sourceUrl), null)
|
||||
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, sourceUrl), null)
|
||||
UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
|
||||
slideUpMenuOverlay.hide()
|
||||
} else {
|
||||
|
@ -1148,7 +1148,7 @@ class UISlideOverlays {
|
|||
container.context.getString(R.string.decide_which_buttons_should_be_pinned),
|
||||
tag = "",
|
||||
call = {
|
||||
showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) {
|
||||
showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }, {
|
||||
val selected = it
|
||||
.map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } }
|
||||
.filter { it != null }
|
||||
|
@ -1156,7 +1156,7 @@ class UISlideOverlays {
|
|||
.toList();
|
||||
|
||||
onPinnedbuttons?.invoke(selected + (visible + hidden).filter { !selected.contains(it) });
|
||||
}
|
||||
});
|
||||
},
|
||||
invokeParent = false
|
||||
))
|
||||
|
@ -1164,29 +1164,40 @@ class UISlideOverlays {
|
|||
|
||||
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.more_options), null, true, *views).apply { show() };
|
||||
}
|
||||
|
||||
fun showOrderOverlay(container: ViewGroup, title: String, options: List<Pair<String, Any>>, onOrdered: (List<Any>)->Unit) {
|
||||
fun showOrderOverlay(container: ViewGroup, title: String, options: List<Pair<String, Any>>, onOrdered: (List<Any>)->Unit, description: String? = null) {
|
||||
val selection: MutableList<Any> = mutableListOf();
|
||||
|
||||
var overlay: SlideUpMenuOverlay? = null;
|
||||
|
||||
overlay = SlideUpMenuOverlay(container.context, container, title, container.context.getString(R.string.save), true,
|
||||
options.map { SlideUpMenuItem(
|
||||
listOf(
|
||||
if(!description.isNullOrEmpty()) SlideUpMenuGroup(container.context, "", description, "", listOf()) else null,
|
||||
).filterNotNull() +
|
||||
(options.map { SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_move_up,
|
||||
it.first,
|
||||
"",
|
||||
tag = it.second,
|
||||
call = {
|
||||
val overlayItem = overlay?.getSlideUpItemByTag(it.second);
|
||||
if(overlay!!.selectOption(null, it.second, true, true)) {
|
||||
if(!selection.contains(it.second))
|
||||
if(!selection.contains(it.second)) {
|
||||
selection.add(it.second);
|
||||
} else
|
||||
if(overlayItem != null) {
|
||||
overlayItem.setSubText(selection.indexOf(it.second).toString());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
selection.remove(it.second);
|
||||
if(overlayItem != null) {
|
||||
overlayItem.setSubText("");
|
||||
}
|
||||
}
|
||||
},
|
||||
invokeParent = false
|
||||
)
|
||||
});
|
||||
}));
|
||||
overlay.onOK.subscribe {
|
||||
onOrdered.invoke(selection);
|
||||
overlay.hide();
|
||||
|
|
|
@ -27,15 +27,17 @@ import com.futo.platformplayer.logging.Logger
|
|||
import com.futo.platformplayer.models.PlatformVideoWithTime
|
||||
import com.futo.platformplayer.others.PlatformLinkMovementMethod
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.security.SecureRandom
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.*
|
||||
import java.util.concurrent.ThreadLocalRandom
|
||||
import java.util.zip.GZIPInputStream
|
||||
import java.util.zip.GZIPOutputStream
|
||||
|
||||
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
|
||||
fun getRandomString(sizeOfRandomString: Int): String {
|
||||
|
@ -292,4 +294,34 @@ fun generateReadablePassword(length: Int): String {
|
|||
sb.append(validChars[index])
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
fun ByteArray.toGzip(): ByteArray {
|
||||
if (this == null || this.isEmpty()) return ByteArray(0)
|
||||
|
||||
val gzipTimeStart = OffsetDateTime.now();
|
||||
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
GZIPOutputStream(outputStream).use { gzip ->
|
||||
gzip.write(this)
|
||||
}
|
||||
val result = outputStream.toByteArray();
|
||||
Logger.i("Utility", "Gzip compression time: ${gzipTimeStart.getNowDiffMiliseconds()}ms");
|
||||
return result;
|
||||
}
|
||||
|
||||
fun ByteArray.fromGzip(): ByteArray {
|
||||
if (this == null || this.isEmpty()) return ByteArray(0)
|
||||
|
||||
val inputStream = ByteArrayInputStream(this)
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
|
||||
GZIPInputStream(inputStream).use { gzip ->
|
||||
val buffer = ByteArray(1024)
|
||||
var bytesRead: Int
|
||||
while (gzip.read(buffer).also { bytesRead = it } != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead)
|
||||
}
|
||||
}
|
||||
return outputStream.toByteArray()
|
||||
}
|
|
@ -10,11 +10,13 @@ import androidx.appcompat.app.AppCompatActivity
|
|||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
|
||||
class AddSourceOptionsActivity : AppCompatActivity() {
|
||||
lateinit var _buttonBack: ImageButton;
|
||||
|
||||
lateinit var _overlayContainer: FrameLayout;
|
||||
lateinit var _buttonQR: BigButton;
|
||||
lateinit var _buttonBrowse: BigButton;
|
||||
lateinit var _buttonURL: BigButton;
|
||||
|
@ -54,6 +56,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
|
|||
setContentView(R.layout.activity_add_source_options);
|
||||
setNavigationBarColorAndIcons();
|
||||
|
||||
_overlayContainer = findViewById(R.id.overlay_container);
|
||||
_buttonBack = findViewById(R.id.button_back);
|
||||
|
||||
_buttonQR = findViewById(R.id.option_qr);
|
||||
|
@ -81,7 +84,25 @@ class AddSourceOptionsActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
_buttonURL.onClick.subscribe {
|
||||
UIDialogs.toast(this, getString(R.string.not_implemented_yet));
|
||||
val nameInput = SlideUpMenuTextInput(this, "ex. https://yourplugin.com/config.json");
|
||||
UISlideOverlays.showOverlay(_overlayContainer, "Enter your url", "Install", {
|
||||
|
||||
val content = nameInput.text;
|
||||
|
||||
val url = if (content.startsWith("https://")) {
|
||||
content
|
||||
} else if (content.startsWith("grayjay://plugin/")) {
|
||||
content.substring("grayjay://plugin/".length)
|
||||
} else {
|
||||
UIDialogs.toast(this, getString(R.string.not_a_plugin_url))
|
||||
return@showOverlay;
|
||||
}
|
||||
|
||||
val intent = Intent(this, AddSourceActivity::class.java).apply {
|
||||
data = Uri.parse(url);
|
||||
};
|
||||
startActivity(intent);
|
||||
}, nameInput)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -113,7 +113,7 @@ class LoginActivity : AppCompatActivity() {
|
|||
|
||||
companion object {
|
||||
private val TAG = "LoginActivity";
|
||||
private val REGEX_LOGIN_BUTTON = Regex("[a-zA-Z\\-\\.#_ ]*");
|
||||
private val REGEX_LOGIN_BUTTON = Regex("[a-zA-Z\\-\\.#:_ ]*");
|
||||
|
||||
private var _callback: ((SourceAuth?) -> Unit)? = null;
|
||||
|
||||
|
|
|
@ -2,6 +2,8 @@ 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 kotlinx.serialization.json.JsonNames
|
||||
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();
|
|
@ -13,7 +13,8 @@ class AudioUrlSource(
|
|||
override val codec: String = "",
|
||||
override val language: String = Language.UNKNOWN,
|
||||
override val duration: Long? = null,
|
||||
override var priority: Boolean = false
|
||||
override var priority: Boolean = false,
|
||||
override var original: Boolean = false
|
||||
) : IAudioUrlSource, IStreamMetaDataSource{
|
||||
override var streamMetaData: StreamMetaData? = null;
|
||||
|
||||
|
@ -36,7 +37,9 @@ class AudioUrlSource(
|
|||
source.container,
|
||||
source.codec,
|
||||
source.language,
|
||||
source.duration
|
||||
source.duration,
|
||||
source.priority,
|
||||
source.original
|
||||
);
|
||||
ret.streamMetaData = streamData;
|
||||
|
||||
|
|
|
@ -27,6 +27,7 @@ class HLSVariantAudioUrlSource(
|
|||
override val language: String,
|
||||
override val duration: Long?,
|
||||
override val priority: Boolean,
|
||||
override val original: Boolean,
|
||||
val url: String
|
||||
) : IAudioUrlSource {
|
||||
override fun getAudioUrl(): String {
|
||||
|
|
|
@ -8,4 +8,5 @@ interface IAudioSource {
|
|||
val language : String;
|
||||
val duration : Long?;
|
||||
val priority: Boolean;
|
||||
val original: Boolean;
|
||||
}
|
|
@ -15,6 +15,7 @@ class LocalAudioSource : IAudioSource, IStreamMetaDataSource {
|
|||
override val duration: Long? = null;
|
||||
|
||||
override var priority: Boolean = false;
|
||||
override val original: Boolean = false;
|
||||
|
||||
val filePath : String;
|
||||
val fileSize: Long;
|
||||
|
|
|
@ -10,15 +10,18 @@ import com.futo.polycentric.core.combineHashCodes
|
|||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
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,
|
||||
override val author: PlatformAuthorLink,
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
@JsonNames("datetime", "dateTime")
|
||||
override val datetime: OffsetDateTime? = null,
|
||||
override val url: String,
|
||||
override val shareUrl: String = "",
|
||||
|
@ -27,7 +30,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 +46,7 @@ open class SerializedPlatformVideo(
|
|||
companion object {
|
||||
fun fromVideo(video: IPlatformVideo) : SerializedPlatformVideo {
|
||||
return SerializedPlatformVideo(
|
||||
ContentType.MEDIA,
|
||||
video.id,
|
||||
video.name,
|
||||
video.thumbnails,
|
||||
|
|
|
@ -21,6 +21,8 @@ open class JSAudioUrlSource : IAudioUrlSource, JSSource {
|
|||
|
||||
override var priority: Boolean = false;
|
||||
|
||||
override var original: Boolean = false;
|
||||
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_AUDIOURL, plugin, obj) {
|
||||
val contextName = "AudioUrlSource";
|
||||
val config = plugin.config;
|
||||
|
@ -35,6 +37,7 @@ open class JSAudioUrlSource : IAudioUrlSource, JSSource {
|
|||
name = _obj.getOrDefault(config, "name", contextName, "${container} ${bitrate}") ?: "${container} ${bitrate}";
|
||||
|
||||
priority = if(_obj.has("priority")) obj.getOrThrow(config, "priority", contextName) else false;
|
||||
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
|
||||
}
|
||||
|
||||
override fun getAudioUrl() : String {
|
||||
|
|
|
@ -23,6 +23,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
|
|||
override val bitrate: Int;
|
||||
override val duration: Long;
|
||||
override val priority: Boolean;
|
||||
override var original: Boolean = false;
|
||||
|
||||
override val language: String;
|
||||
|
||||
|
@ -45,6 +46,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
|
|||
duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
|
||||
priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
|
||||
language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN;
|
||||
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
|
||||
hasGenerate = _obj.has("generate");
|
||||
}
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
|
|||
override val language: String;
|
||||
|
||||
override var priority: Boolean = false;
|
||||
override var original: Boolean = false;
|
||||
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) {
|
||||
val contextName = "HLSAudioSource";
|
||||
|
@ -32,6 +33,7 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
|
|||
language = _obj.getOrThrow(config, "language", contextName);
|
||||
|
||||
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
||||
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -6,7 +6,7 @@ import com.futo.platformplayer.constructs.Event1
|
|||
* A RefreshPager represents a pager that can be modified overtime (eg. By getting more results later, by recreating the pager)
|
||||
* When the onPagerChanged event is emitted, a new pager instance is passed, or requested via getCurrentPager
|
||||
*/
|
||||
interface IRefreshPager<T> {
|
||||
interface IRefreshPager<T>: IPager<T> {
|
||||
val onPagerChanged: Event1<IPager<T>>;
|
||||
val onPagerError: Event1<Throwable>;
|
||||
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package com.futo.platformplayer.api.media.structures
|
||||
|
||||
import com.futo.platformplayer.api.media.structures.ReusablePager.Window
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
|
||||
/**
|
||||
|
@ -9,8 +11,8 @@ import com.futo.platformplayer.logging.Logger
|
|||
* A "Window" is effectively a pager that just reads previous results from the shared results, but when the end is reached, it will call nextPage on the parent if possible for new results.
|
||||
* This allows multiple Windows to exist of the same pager, without messing with position, or duplicate requests
|
||||
*/
|
||||
class ReusablePager<T>: INestedPager<T>, IPager<T> {
|
||||
private val _pager: IPager<T>;
|
||||
open class ReusablePager<T>: INestedPager<T>, IReusablePager<T> {
|
||||
protected var _pager: IPager<T>;
|
||||
val previousResults = arrayListOf<T>();
|
||||
|
||||
constructor(subPager: IPager<T>) {
|
||||
|
@ -44,7 +46,7 @@ class ReusablePager<T>: INestedPager<T>, IPager<T> {
|
|||
return previousResults;
|
||||
}
|
||||
|
||||
fun getWindow(): Window<T> {
|
||||
override fun getWindow(): Window<T> {
|
||||
return Window(this);
|
||||
}
|
||||
|
||||
|
@ -95,4 +97,118 @@ class ReusablePager<T>: INestedPager<T>, IPager<T> {
|
|||
return ReusablePager(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public class ReusableRefreshPager<T>: INestedPager<T>, IReusablePager<T> {
|
||||
protected var _pager: IRefreshPager<T>;
|
||||
val previousResults = arrayListOf<T>();
|
||||
|
||||
private var _currentPage: IPager<T>;
|
||||
|
||||
|
||||
val onPagerChanged = Event1<IPager<T>>()
|
||||
val onPagerError = Event1<Throwable>()
|
||||
|
||||
constructor(subPager: IRefreshPager<T>) {
|
||||
this._pager = subPager;
|
||||
_currentPage = this;
|
||||
synchronized(previousResults) {
|
||||
previousResults.addAll(subPager.getResults());
|
||||
}
|
||||
_pager.onPagerError.subscribe(onPagerError::emit);
|
||||
_pager.onPagerChanged.subscribe {
|
||||
_currentPage = it;
|
||||
synchronized(previousResults) {
|
||||
previousResults.clear();
|
||||
previousResults.addAll(it.getResults());
|
||||
}
|
||||
|
||||
onPagerChanged.emit(_currentPage);
|
||||
};
|
||||
}
|
||||
|
||||
override fun findPager(query: (IPager<T>) -> Boolean): IPager<T>? {
|
||||
if(query(_pager))
|
||||
return _pager;
|
||||
else if(_pager is INestedPager<*>)
|
||||
return (_pager as INestedPager<T>).findPager(query);
|
||||
return null;
|
||||
}
|
||||
|
||||
override fun hasMorePages(): Boolean {
|
||||
return _pager.hasMorePages();
|
||||
}
|
||||
|
||||
override fun nextPage() {
|
||||
_pager.nextPage();
|
||||
}
|
||||
|
||||
override fun getResults(): List<T> {
|
||||
val results = _pager.getResults();
|
||||
synchronized(previousResults) {
|
||||
previousResults.addAll(results);
|
||||
}
|
||||
return previousResults;
|
||||
}
|
||||
|
||||
override fun getWindow(): RefreshWindow<T> {
|
||||
return RefreshWindow(this);
|
||||
}
|
||||
|
||||
|
||||
class RefreshWindow<T>: IPager<T>, INestedPager<T>, IRefreshPager<T> {
|
||||
private val _parent: ReusableRefreshPager<T>;
|
||||
private var _position: Int = 0;
|
||||
private var _read: Int = 0;
|
||||
|
||||
private var _currentResults: List<T>;
|
||||
|
||||
override val onPagerChanged = Event1<IPager<T>>();
|
||||
override val onPagerError = Event1<Throwable>();
|
||||
|
||||
|
||||
override fun getCurrentPager(): IPager<T> {
|
||||
return _parent.getWindow();
|
||||
}
|
||||
|
||||
constructor(parent: ReusableRefreshPager<T>) {
|
||||
_parent = parent;
|
||||
|
||||
synchronized(_parent.previousResults) {
|
||||
_currentResults = _parent.previousResults.toList();
|
||||
_read += _currentResults.size;
|
||||
}
|
||||
parent.onPagerChanged.subscribe(onPagerChanged::emit);
|
||||
parent.onPagerError.subscribe(onPagerError::emit);
|
||||
}
|
||||
|
||||
|
||||
override fun hasMorePages(): Boolean {
|
||||
return _parent.previousResults.size > _read || _parent.hasMorePages();
|
||||
}
|
||||
|
||||
override fun nextPage() {
|
||||
synchronized(_parent.previousResults) {
|
||||
if (_parent.previousResults.size <= _read) {
|
||||
_parent.nextPage();
|
||||
_parent.getResults();
|
||||
}
|
||||
_currentResults = _parent.previousResults.drop(_read).toList();
|
||||
_read += _currentResults.size;
|
||||
}
|
||||
}
|
||||
|
||||
override fun getResults(): List<T> {
|
||||
return _currentResults;
|
||||
}
|
||||
|
||||
override fun findPager(query: (IPager<T>) -> Boolean): IPager<T>? {
|
||||
return _parent.findPager(query);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface IReusablePager<T>: IPager<T> {
|
||||
fun getWindow(): IPager<T>;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -9,6 +9,7 @@ import androidx.lifecycle.lifecycleScope
|
|||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
|
@ -160,8 +161,14 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||
navigate<RemotePlaylistFragment>(it);
|
||||
else if(StatePlatform.instance.hasEnabledChannelClient(it))
|
||||
navigate<ChannelFragment>(it);
|
||||
else
|
||||
navigate<VideoDetailFragment>(it);
|
||||
else {
|
||||
val url = it;
|
||||
activity?.let {
|
||||
close()
|
||||
if(it is MainActivity)
|
||||
it.navigate(it.getFragment<VideoDetailFragment>(), url);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
setQuery(it, true);
|
||||
|
|
|
@ -21,6 +21,8 @@ import com.futo.platformplayer.models.Playlist
|
|||
import com.futo.platformplayer.states.StateDownloads
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.StringStorage
|
||||
import com.futo.platformplayer.toHumanBytesSize
|
||||
import com.futo.platformplayer.toHumanDuration
|
||||
import com.futo.platformplayer.views.AnyInsertedAdapterView
|
||||
|
@ -103,12 +105,15 @@ class DownloadsFragment : MainFragment() {
|
|||
private val _listDownloaded: AnyInsertedAdapterView<VideoLocal, VideoDownloadViewHolder>;
|
||||
|
||||
private var lastDownloads: List<VideoLocal>? = null;
|
||||
private var ordering: String? = "nameAsc";
|
||||
private var ordering = FragmentedStorage.get<StringStorage>("downloads_ordering")
|
||||
|
||||
constructor(frag: DownloadsFragment, inflater: LayoutInflater): super(frag.requireContext()) {
|
||||
inflater.inflate(R.layout.fragment_downloads, this);
|
||||
_frag = frag;
|
||||
|
||||
if(ordering.value.isNullOrBlank())
|
||||
ordering.value = "nameAsc";
|
||||
|
||||
_usageUsed = findViewById(R.id.downloads_usage_used);
|
||||
_usageAvailable = findViewById(R.id.downloads_usage_available);
|
||||
_usageProgress = findViewById(R.id.downloads_usage_progress);
|
||||
|
@ -132,22 +137,23 @@ class DownloadsFragment : MainFragment() {
|
|||
spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.downloads_sortby_array)).also {
|
||||
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
||||
};
|
||||
spinnerSortBy.setSelection(0);
|
||||
val options = listOf("nameAsc", "nameDesc", "downloadDateAsc", "downloadDateDesc", "releasedAsc", "releasedDesc");
|
||||
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 = "downloadDateAsc"
|
||||
3 -> ordering = "downloadDateDesc"
|
||||
4 -> ordering = "releasedAsc"
|
||||
5 -> ordering = "releasedDesc"
|
||||
else -> ordering = null
|
||||
0 -> ordering.setAndSave("nameAsc")
|
||||
1 -> ordering.setAndSave("nameDesc")
|
||||
2 -> ordering.setAndSave("downloadDateAsc")
|
||||
3 -> ordering.setAndSave("downloadDateDesc")
|
||||
4 -> ordering.setAndSave("releasedAsc")
|
||||
5 -> ordering.setAndSave("releasedDesc")
|
||||
else -> ordering.setAndSave("")
|
||||
}
|
||||
updateContentFilters()
|
||||
}
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||
};
|
||||
spinnerSortBy.setSelection(Math.max(0, options.indexOf(ordering.value)));
|
||||
|
||||
_listDownloaded = findViewById<RecyclerView>(R.id.list_downloaded)
|
||||
.asAnyWithTop(findViewById(R.id.downloads_top)) {
|
||||
|
@ -229,9 +235,9 @@ 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) };
|
||||
if(!ordering.isNullOrEmpty()) {
|
||||
vidsToReturn = when(ordering){
|
||||
vidsToReturn = vids.filter { it.name.contains(_listDownloadSearch.text, true) || it.author.name.contains(_listDownloadSearch.text, true) };
|
||||
if(!ordering.value.isNullOrEmpty()) {
|
||||
vidsToReturn = when(ordering.value){
|
||||
"downloadDateAsc" -> vidsToReturn.sortedBy { it.downloadDate ?: OffsetDateTime.MAX };
|
||||
"downloadDateDesc" -> vidsToReturn.sortedByDescending { it.downloadDate ?: OffsetDateTime.MIN };
|
||||
"nameAsc" -> vidsToReturn.sortedBy { it.name.lowercase() }
|
||||
|
|
|
@ -3,12 +3,15 @@ package com.futo.platformplayer.fragment.mainactivity.main
|
|||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.Display
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.LayoutManager
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
|
@ -20,6 +23,7 @@ import com.futo.platformplayer.constructs.Event1
|
|||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.others.ProgressBar
|
||||
import com.futo.platformplayer.views.others.TagsView
|
||||
|
@ -28,7 +32,9 @@ import com.futo.platformplayer.views.adapters.InsertedViewHolder
|
|||
import com.futo.platformplayer.views.announcements.AnnouncementView
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.OffsetDateTime
|
||||
import kotlin.math.max
|
||||
|
||||
|
@ -68,6 +74,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||
|
||||
private val _scrollListener: RecyclerView.OnScrollListener;
|
||||
private var _automaticNextPageCounter = 0;
|
||||
private val _automaticBackoff = arrayOf(0, 500, 1000, 1000, 2000, 5000, 5000, 5000);
|
||||
|
||||
constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<TViewHolder>, GridLayoutManager, TPager, TResult, TConverted, InsertedViewHolder<TViewHolder>>? = null) : super(inflater.context) {
|
||||
this.fragment = fragment;
|
||||
|
@ -129,6 +136,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||
_toolbarContentView = findViewById(R.id.container_toolbar_content);
|
||||
|
||||
_nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, {
|
||||
|
||||
if (it is IAsyncPager<*>)
|
||||
it.nextPageAsync();
|
||||
else
|
||||
|
@ -182,29 +190,61 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||
|
||||
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
|
||||
val canScroll = if (recyclerData.results.isEmpty()) false else {
|
||||
val height = resources.displayMetrics.heightPixels;
|
||||
|
||||
val layoutManager = recyclerData.layoutManager
|
||||
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
|
||||
|
||||
if (firstVisibleItemPosition != RecyclerView.NO_POSITION) {
|
||||
val firstVisibleView = layoutManager.findViewByPosition(firstVisibleItemPosition)
|
||||
val itemHeight = firstVisibleView?.height ?: 0
|
||||
val occupiedSpace = recyclerData.results.size / recyclerData.layoutManager.spanCount * itemHeight
|
||||
val recyclerViewHeight = _recyclerResults.height
|
||||
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage occupiedSpace=$occupiedSpace recyclerViewHeight=$recyclerViewHeight")
|
||||
occupiedSpace >= recyclerViewHeight
|
||||
val firstVisibleItemView = if(firstVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(firstVisibleItemPosition) else null;
|
||||
val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition();
|
||||
val lastVisibleItemView = if(lastVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(lastVisibleItemPosition) else null;
|
||||
val rows = if(recyclerData.layoutManager is GridLayoutManager) Math.max(1, recyclerData.results.size / recyclerData.layoutManager.spanCount) else 1;
|
||||
val rowsHeight = (firstVisibleItemView?.height ?: 0) * rows;
|
||||
if(lastVisibleItemView != null && lastVisibleItemPosition == (recyclerData.results.size - 1)) {
|
||||
false;
|
||||
}
|
||||
else if (firstVisibleItemView != null && height != null && rowsHeight < height) {
|
||||
false;
|
||||
} else {
|
||||
false
|
||||
true;
|
||||
}
|
||||
}
|
||||
|
||||
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter")
|
||||
if (!canScroll || filteredResults.isEmpty()) {
|
||||
_automaticNextPageCounter++
|
||||
if(_automaticNextPageCounter <= 4)
|
||||
loadNextPage()
|
||||
if(_automaticNextPageCounter < _automaticBackoff.size) {
|
||||
if(_automaticNextPageCounter > 0) {
|
||||
val automaticNextPageCounterSaved = _automaticNextPageCounter;
|
||||
fragment.lifecycleScope.launch(Dispatchers.Default) {
|
||||
val backoff = _automaticBackoff[Math.min(_automaticBackoff.size - 1, _automaticNextPageCounter)];
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
setLoading(true);
|
||||
}
|
||||
delay(backoff.toLong());
|
||||
if(automaticNextPageCounterSaved == _automaticNextPageCounter) {
|
||||
withContext(Dispatchers.Main) {
|
||||
loadNextPage();
|
||||
}
|
||||
}
|
||||
else {
|
||||
withContext(Dispatchers.Main) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
loadNextPage();
|
||||
}
|
||||
} else {
|
||||
Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset");
|
||||
_automaticNextPageCounter = 0;
|
||||
}
|
||||
}
|
||||
fun resetAutomaticNextPageCounter(){
|
||||
_automaticNextPageCounter = 0;
|
||||
}
|
||||
|
||||
protected fun setTextCentered(text: String?) {
|
||||
_textCentered.text = text;
|
||||
|
|
|
@ -5,29 +5,38 @@ import android.os.Bundle
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.allViews
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.UISlideOverlays.Companion.showOrderOverlay
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
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.EmptyPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.api.media.structures.IRefreshPager
|
||||
import com.futo.platformplayer.api.media.structures.IReusablePager
|
||||
import com.futo.platformplayer.api.media.structures.ReusablePager
|
||||
import com.futo.platformplayer.api.media.structures.ReusableRefreshPager
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateHistory
|
||||
import com.futo.platformplayer.states.StateMeta
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.StringArrayStorage
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.NoResultsView
|
||||
import com.futo.platformplayer.views.ToggleBar
|
||||
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewHolder
|
||||
import com.futo.platformplayer.views.announcements.AnnouncementView
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.time.OffsetDateTime
|
||||
|
@ -39,6 +48,12 @@ class HomeFragment : MainFragment() {
|
|||
|
||||
private var _view: HomeView? = null;
|
||||
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null;
|
||||
private var _cachedLastPager: IReusablePager<IPlatformContent>? = null
|
||||
|
||||
private var _toggleRecent = false;
|
||||
private var _toggleWatched = false;
|
||||
private var _togglePluginsDisabled = mutableListOf<String>();
|
||||
|
||||
|
||||
fun reloadFeed() {
|
||||
_view?.reloadFeed()
|
||||
|
@ -64,7 +79,7 @@ class HomeFragment : MainFragment() {
|
|||
}
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val view = HomeView(this, inflater, _cachedRecyclerData);
|
||||
val view = HomeView(this, inflater, _cachedRecyclerData, _cachedLastPager);
|
||||
_view = view;
|
||||
return view;
|
||||
}
|
||||
|
@ -82,6 +97,7 @@ class HomeFragment : MainFragment() {
|
|||
val view = _view;
|
||||
if (view != null) {
|
||||
_cachedRecyclerData = view.recyclerData;
|
||||
_cachedLastPager = view.lastPager;
|
||||
view.cleanup();
|
||||
_view = null;
|
||||
}
|
||||
|
@ -91,6 +107,7 @@ class HomeFragment : MainFragment() {
|
|||
_view?.setPreviewsEnabled(previewsEnabled && Settings.instance.home.previewFeedItems);
|
||||
}
|
||||
|
||||
|
||||
@SuppressLint("ViewConstructor")
|
||||
class HomeView : ContentFeedView<HomeFragment> {
|
||||
override val feedStyle: FeedStyle get() = Settings.instance.home.getHomeFeedStyle();
|
||||
|
@ -100,11 +117,22 @@ class HomeFragment : MainFragment() {
|
|||
private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>;
|
||||
override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar
|
||||
|
||||
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
|
||||
var lastPager: IReusablePager<IPlatformContent>? = null;
|
||||
|
||||
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null, cachedLastPager: IReusablePager<IPlatformContent>? = null) : super(fragment, inflater, cachedRecyclerData) {
|
||||
lastPager = cachedLastPager
|
||||
_taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({ fragment.lifecycleScope }, {
|
||||
StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope)
|
||||
})
|
||||
.success { loadedResult(it); }
|
||||
.success {
|
||||
val wrappedPager = if(it is IRefreshPager)
|
||||
ReusableRefreshPager(it);
|
||||
else
|
||||
ReusablePager(it);
|
||||
lastPager = wrappedPager;
|
||||
resetAutomaticNextPageCounter();
|
||||
loadedResult(wrappedPager.getWindow());
|
||||
}
|
||||
.exception<ScriptCaptchaRequiredException> { }
|
||||
.exception<ScriptExecutionException> {
|
||||
Logger.w(ChannelFragment.TAG, "Plugin failure.", it);
|
||||
|
@ -207,22 +235,94 @@ class HomeFragment : MainFragment() {
|
|||
}
|
||||
|
||||
private val _filterLock = Object();
|
||||
private var _toggleRecent = false;
|
||||
private var _togglesConfig = FragmentedStorage.get<StringArrayStorage>("home_toggles");
|
||||
fun initializeToolbarContent() {
|
||||
//Not stable enough with current viewport paging, doesn't work with less results, and reloads content instead of just re-filtering existing
|
||||
/*
|
||||
_toggleBar = ToggleBar(context).apply {
|
||||
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
|
||||
}
|
||||
synchronized(_filterLock) {
|
||||
_toggleBar?.setToggles(
|
||||
//TODO: loadResults needs to be replaced with an internal reload of the current content
|
||||
ToggleBar.Toggle("Recent", _toggleRecent) { _toggleRecent = it; loadResults(false) }
|
||||
)
|
||||
}
|
||||
if(_toolbarContentView.allViews.any { it is ToggleBar })
|
||||
_toolbarContentView.removeView(_toolbarContentView.allViews.find { it is ToggleBar });
|
||||
|
||||
_toolbarContentView.addView(_toggleBar, 0);
|
||||
*/
|
||||
if(Settings.instance.home.showHomeFilters) {
|
||||
|
||||
if (!_togglesConfig.any()) {
|
||||
_togglesConfig.set("today", "watched", "plugins");
|
||||
_togglesConfig.save();
|
||||
}
|
||||
_toggleBar = ToggleBar(context).apply {
|
||||
layoutParams =
|
||||
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
|
||||
}
|
||||
|
||||
synchronized(_filterLock) {
|
||||
var buttonsPlugins: List<ToggleBar.Toggle> = listOf()
|
||||
buttonsPlugins = (if (_togglesConfig.contains("plugins"))
|
||||
(StatePlatform.instance.getEnabledClients()
|
||||
.filter { it is JSClient && it.enableInHome }
|
||||
.map { plugin ->
|
||||
ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) plugin.name else "", plugin.icon, !fragment._togglePluginsDisabled.contains(plugin.id), { view, active ->
|
||||
var dontSwap = false;
|
||||
if (active) {
|
||||
if (fragment._togglePluginsDisabled.contains(plugin.id))
|
||||
fragment._togglePluginsDisabled.remove(plugin.id);
|
||||
} else {
|
||||
if (!fragment._togglePluginsDisabled.contains(plugin.id)) {
|
||||
val enabledClients = StatePlatform.instance.getEnabledClients();
|
||||
val availableAfterDisable = enabledClients.count { !fragment._togglePluginsDisabled.contains(it.id) && it.id != plugin.id };
|
||||
if(availableAfterDisable > 0)
|
||||
fragment._togglePluginsDisabled.add(plugin.id);
|
||||
else {
|
||||
UIDialogs.appToast("Home needs atleast 1 plugin active");
|
||||
dontSwap = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!dontSwap)
|
||||
reloadForFilters();
|
||||
else {
|
||||
view.setToggle(!active);
|
||||
}
|
||||
}).withTag("plugins")
|
||||
})
|
||||
else listOf())
|
||||
val buttons = (listOf<ToggleBar.Toggle?>(
|
||||
(if (_togglesConfig.contains("today"))
|
||||
ToggleBar.Toggle("Today", fragment._toggleRecent) { view, active ->
|
||||
fragment._toggleRecent = active; reloadForFilters()
|
||||
}
|
||||
.withTag("today") else null),
|
||||
(if (_togglesConfig.contains("watched"))
|
||||
ToggleBar.Toggle("Unwatched", fragment._toggleWatched) { view, active ->
|
||||
fragment._toggleWatched = active; reloadForFilters()
|
||||
}
|
||||
.withTag("watched") else null),
|
||||
).filterNotNull() + buttonsPlugins)
|
||||
.sortedBy { _togglesConfig.indexOf(it.tag ?: "") } ?: listOf()
|
||||
|
||||
val buttonSettings = ToggleBar.Toggle("", R.drawable.ic_settings, true, { view, active ->
|
||||
showOrderOverlay(_overlayContainer,
|
||||
"Visible home filters",
|
||||
listOf(
|
||||
Pair("Plugins", "plugins"),
|
||||
Pair("Today", "today"),
|
||||
Pair("Watched", "watched")
|
||||
),
|
||||
{
|
||||
val newArray = it.map { it.toString() }.toTypedArray();
|
||||
_togglesConfig.set(*(if (newArray.any()) newArray else arrayOf("none")));
|
||||
_togglesConfig.save();
|
||||
initializeToolbarContent();
|
||||
},
|
||||
"Select which toggles you want to see in order. You can also choose to hide filters in the Grayjay Settings"
|
||||
);
|
||||
}).asButton();
|
||||
|
||||
val buttonsOrder = (buttons + listOf(buttonSettings)).toTypedArray();
|
||||
_toggleBar?.setToggles(*buttonsOrder);
|
||||
}
|
||||
|
||||
_toolbarContentView.addView(_toggleBar, 0);
|
||||
}
|
||||
}
|
||||
fun reloadForFilters() {
|
||||
lastPager?.let { loadedResult(it.getWindow()) };
|
||||
}
|
||||
|
||||
override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
|
||||
|
@ -232,7 +332,11 @@ class HomeFragment : MainFragment() {
|
|||
if(StateMeta.instance.isCreatorHidden(it.author.url))
|
||||
return@filter false;
|
||||
|
||||
if(_toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 23) {
|
||||
if(fragment._toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 25)
|
||||
return@filter false;
|
||||
if(fragment._toggleWatched && StateHistory.instance.isHistoryWatched(it.url, 0))
|
||||
return@filter false;
|
||||
if(fragment._togglePluginsDisabled.any() && it.id.pluginId != null && fragment._togglePluginsDisabled.contains(it.id.pluginId)) {
|
||||
return@filter false;
|
||||
}
|
||||
|
||||
|
|
|
@ -326,6 +326,10 @@ class PlaylistFragment : MainFragment() {
|
|||
playlist.videos = ArrayList(playlist.videos.filter { it != video });
|
||||
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
|
||||
}
|
||||
|
||||
override fun onVideoOptions(video: IPlatformVideo) {
|
||||
UISlideOverlays.showVideoOptionsOverlay(video, overlayContainer);
|
||||
}
|
||||
override fun onVideoClicked(video: IPlatformVideo) {
|
||||
val playlist = _playlist;
|
||||
if (playlist != null) {
|
||||
|
|
|
@ -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,15 @@ 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.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.StringStorage
|
||||
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 +74,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 +82,20 @@ class PlaylistsFragment : MainFragment() {
|
|||
private var _layoutWatchlist: ConstraintLayout;
|
||||
private var _slideUpOverlay: SlideUpMenuOverlay? = null;
|
||||
|
||||
private var _listPlaylistsSearch: EditText;
|
||||
|
||||
private var _ordering = FragmentedStorage.get<StringStorage>("playlists_ordering")
|
||||
|
||||
|
||||
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 +123,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 +139,35 @@ 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);
|
||||
};
|
||||
val options = listOf("nameAsc", "nameDesc", "dateEditAsc", "dateEditDesc", "dateCreateAsc", "dateCreateDesc", "datePlayAsc", "datePlayDesc");
|
||||
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
|
||||
when(pos) {
|
||||
0 -> _ordering.setAndSave("nameAsc")
|
||||
1 -> _ordering.setAndSave("nameDesc")
|
||||
2 -> _ordering.setAndSave("dateEditAsc")
|
||||
3 -> _ordering.setAndSave("dateEditDesc")
|
||||
4 -> _ordering.setAndSave("dateCreateAsc")
|
||||
5 -> _ordering.setAndSave("dateCreateDesc")
|
||||
6 -> _ordering.setAndSave("datePlayAsc")
|
||||
7 -> _ordering.setAndSave("datePlayDesc")
|
||||
else -> _ordering.setAndSave("")
|
||||
}
|
||||
updatePlaylistsFiltering()
|
||||
}
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||
};
|
||||
spinnerSortBy.setSelection(Math.max(0, options.indexOf(_ordering.value)));
|
||||
|
||||
|
||||
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 +182,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 +207,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.value.isNullOrEmpty()){
|
||||
playlistsToReturn = when(_ordering.value){
|
||||
"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 +240,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 +248,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;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -256,6 +256,8 @@ class SubscriptionGroupFragment : MainFragment() {
|
|||
val sub = StateSubscriptions.instance.getSubscription(sub) ?: StateSubscriptions.instance.getSubscriptionOther(sub);
|
||||
if(sub != null && sub.channel.thumbnail != null) {
|
||||
g.image = ImageVariable.fromUrl(sub.channel.thumbnail!!);
|
||||
if(g.image != null)
|
||||
g.image!!.subscriptionUrl = sub.channel.url;
|
||||
g.image?.setImageView(_imageGroup);
|
||||
g.image?.setImageView(_imageGroupBackground);
|
||||
break;
|
||||
|
|
|
@ -18,6 +18,7 @@ import com.futo.platformplayer.constructs.TaskHandler
|
|||
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||
import com.futo.platformplayer.exceptions.ChannelException
|
||||
import com.futo.platformplayer.exceptions.RateLimitException
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment.SubscriptionsFeedView.FeedFilterSettings
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.SearchType
|
||||
import com.futo.platformplayer.models.SubscriptionGroup
|
||||
|
@ -56,6 +57,9 @@ class SubscriptionsFeedFragment : MainFragment() {
|
|||
private var _group: SubscriptionGroup? = null;
|
||||
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null;
|
||||
|
||||
private val _filterLock = Object();
|
||||
private val _filterSettings = FragmentedStorage.get<FeedFilterSettings>("subFeedFilter");
|
||||
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack);
|
||||
_view?.onShown();
|
||||
|
@ -184,8 +188,6 @@ class SubscriptionsFeedFragment : MainFragment() {
|
|||
return Json.encodeToString(this);
|
||||
}
|
||||
}
|
||||
private val _filterLock = Object();
|
||||
private val _filterSettings = FragmentedStorage.get<FeedFilterSettings>("subFeedFilter");
|
||||
|
||||
private var _bypassRateLimit = false;
|
||||
private val _lastExceptions: List<Throwable>? = null;
|
||||
|
@ -284,13 +286,18 @@ class SubscriptionsFeedFragment : MainFragment() {
|
|||
fragment.navigate<SubscriptionGroupFragment>(g);
|
||||
};
|
||||
|
||||
synchronized(_filterLock) {
|
||||
synchronized(fragment._filterLock) {
|
||||
_subscriptionBar?.setToggles(
|
||||
SubscriptionBar.Toggle(context.getString(R.string.videos), _filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), it); },
|
||||
SubscriptionBar.Toggle(context.getString(R.string.posts), _filterSettings.allowContentTypes.contains(ContentType.POST)) { toggleFilterContentType(ContentType.POST, it); },
|
||||
SubscriptionBar.Toggle(context.getString(R.string.live), _filterSettings.allowLive) { _filterSettings.allowLive = it; _filterSettings.save(); loadResults(false); },
|
||||
SubscriptionBar.Toggle(context.getString(R.string.planned), _filterSettings.allowPlanned) { _filterSettings.allowPlanned = it; _filterSettings.save(); loadResults(false); },
|
||||
SubscriptionBar.Toggle(context.getString(R.string.watched), _filterSettings.allowWatched) { _filterSettings.allowWatched = it; _filterSettings.save(); loadResults(false); }
|
||||
SubscriptionBar.Toggle(context.getString(R.string.videos), fragment._filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { view, active ->
|
||||
toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), active); },
|
||||
SubscriptionBar.Toggle(context.getString(R.string.posts), fragment._filterSettings.allowContentTypes.contains(ContentType.POST)) { view, active ->
|
||||
toggleFilterContentType(ContentType.POST, active); },
|
||||
SubscriptionBar.Toggle(context.getString(R.string.live), fragment._filterSettings.allowLive) { view, active ->
|
||||
fragment._filterSettings.allowLive = active; fragment._filterSettings.save(); loadResults(false); },
|
||||
SubscriptionBar.Toggle(context.getString(R.string.planned), fragment._filterSettings.allowPlanned) { view, active ->
|
||||
fragment._filterSettings.allowPlanned = active; fragment._filterSettings.save(); loadResults(false); },
|
||||
SubscriptionBar.Toggle(context.getString(R.string.watched), fragment._filterSettings.allowWatched) { view, active ->
|
||||
fragment._filterSettings.allowWatched = active; fragment._filterSettings.save(); loadResults(false); }
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -301,13 +308,13 @@ class SubscriptionsFeedFragment : MainFragment() {
|
|||
toggleFilterContentType(contentType, isTrue);
|
||||
}
|
||||
private fun toggleFilterContentType(contentType: ContentType, isTrue: Boolean) {
|
||||
synchronized(_filterLock) {
|
||||
synchronized(fragment._filterLock) {
|
||||
if(!isTrue) {
|
||||
_filterSettings.allowContentTypes.remove(contentType);
|
||||
} else if(!_filterSettings.allowContentTypes.contains(contentType)) {
|
||||
_filterSettings.allowContentTypes.add(contentType)
|
||||
fragment._filterSettings.allowContentTypes.remove(contentType);
|
||||
} else if(!fragment._filterSettings.allowContentTypes.contains(contentType)) {
|
||||
fragment._filterSettings.allowContentTypes.add(contentType)
|
||||
}
|
||||
_filterSettings.save();
|
||||
fragment._filterSettings.save();
|
||||
};
|
||||
if(Settings.instance.subscriptions.fetchOnTabOpen) { //TODO: Do this different, temporary workaround
|
||||
loadResults(false);
|
||||
|
@ -320,9 +327,9 @@ class SubscriptionsFeedFragment : MainFragment() {
|
|||
val nowSoon = OffsetDateTime.now().plusMinutes(5);
|
||||
val filterGroup = subGroup;
|
||||
return results.filter {
|
||||
val allowedContentType = _filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType);
|
||||
val allowedContentType = fragment._filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType);
|
||||
|
||||
if(it is IPlatformVideo && it.duration > 0 && !_filterSettings.allowWatched && StateHistory.instance.isHistoryWatched(it.url, it.duration))
|
||||
if(it is IPlatformVideo && it.duration > 0 && !fragment._filterSettings.allowWatched && StateHistory.instance.isHistoryWatched(it.url, it.duration))
|
||||
return@filter false;
|
||||
|
||||
//TODO: Check against a sub cache
|
||||
|
@ -331,11 +338,11 @@ class SubscriptionsFeedFragment : MainFragment() {
|
|||
|
||||
|
||||
if(it.datetime?.isAfter(nowSoon) == true) {
|
||||
if(!_filterSettings.allowPlanned)
|
||||
if(!fragment._filterSettings.allowPlanned)
|
||||
return@filter false;
|
||||
}
|
||||
|
||||
if(_filterSettings.allowLive) { //If allowLive, always show live
|
||||
if(fragment._filterSettings.allowLive) { //If allowLive, always show live
|
||||
if(it is IPlatformVideo && it.isLive)
|
||||
return@filter true;
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import androidx.lifecycle.lifecycleScope
|
|||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
|
@ -122,8 +123,14 @@ class SuggestionsFragment : MainFragment {
|
|||
navigate<RemotePlaylistFragment>(it);
|
||||
else if(StatePlatform.instance.hasEnabledChannelClient(it))
|
||||
navigate<ChannelFragment>(it);
|
||||
else
|
||||
navigate<VideoDetailFragment>(it);
|
||||
else {
|
||||
val url = it;
|
||||
activity?.let {
|
||||
close()
|
||||
if(it is MainActivity)
|
||||
it.navigate(it.getFragment<VideoDetailFragment>(), url);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl));
|
||||
|
|
|
@ -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);
|
||||
|
@ -683,9 +693,17 @@ class VideoDetailView : ConstraintLayout {
|
|||
_container_content_description.onClose.subscribe { switchContentView(_container_content_main); };
|
||||
_container_content_liveChat.onClose.subscribe { switchContentView(_container_content_main); };
|
||||
_container_content_queue.onClose.subscribe { switchContentView(_container_content_main); };
|
||||
_container_content_queue.onOptions.subscribe {
|
||||
UISlideOverlays.showVideoOptionsOverlay(it, _overlayContainer);
|
||||
}
|
||||
_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 +870,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 +899,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 +1381,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 +1425,10 @@ class VideoDetailView : ConstraintLayout {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
updateMoreButtons();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1863,7 +1910,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 +2648,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 +3127,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;
|
||||
|
@ -76,6 +104,7 @@ abstract class VideoListEditorView : LinearLayout {
|
|||
|
||||
videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged);
|
||||
videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved);
|
||||
videoListEditorView.onVideoOptions.subscribe(::onVideoOptions);
|
||||
videoListEditorView.onVideoClicked.subscribe(::onVideoClicked);
|
||||
|
||||
_videoListEditorView = videoListEditorView;
|
||||
|
@ -94,6 +123,7 @@ abstract class VideoListEditorView : LinearLayout {
|
|||
open fun onShuffleClick() { }
|
||||
open fun onEditClick() { }
|
||||
open fun onVideoRemoved(video: IPlatformVideo) {}
|
||||
open fun onVideoOptions(video: IPlatformVideo) {}
|
||||
open fun onVideoOrderChanged(videos : List<IPlatformVideo>) {}
|
||||
open fun onVideoClicked(video: IPlatformVideo) {
|
||||
|
||||
|
@ -171,9 +201,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;
|
||||
|
|
|
@ -103,6 +103,9 @@ class WatchLaterFragment : MainFragment() {
|
|||
StatePlaylists.instance.removeFromWatchLater(video, true);
|
||||
}
|
||||
}
|
||||
override fun onVideoOptions(video: IPlatformVideo) {
|
||||
UISlideOverlays.showVideoOptionsOverlay(video, overlayContainer);
|
||||
}
|
||||
|
||||
override fun onVideoClicked(video: IPlatformVideo) {
|
||||
val watchLater = StatePlaylists.instance.getWatchLater();
|
||||
|
|
|
@ -9,6 +9,7 @@ import androidx.media3.datasource.ResolvingDataSource
|
|||
import androidx.media3.exoplayer.dash.DashMediaSource
|
||||
import androidx.media3.exoplayer.dash.manifest.DashManifestParser
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
|
@ -85,12 +86,17 @@ class VideoHelper {
|
|||
|
||||
return selectBestAudioSource((desc as VideoUnMuxedSourceDescriptor).audioSources.toList(), prefContainers, prefLanguage, targetBitrate);
|
||||
}
|
||||
fun selectBestAudioSource(altSources : Iterable<IAudioSource>, prefContainers : Array<String>, preferredLanguage: String? = null, targetBitrate: Long? = null) : IAudioSource? {
|
||||
fun selectBestAudioSource(sources : Iterable<IAudioSource>, prefContainers : Array<String>, preferredLanguage: String? = null, targetBitrate: Long? = null) : IAudioSource? {
|
||||
val hasPriority = sources.any { it.priority };
|
||||
var altSources = if(hasPriority) sources.filter { it.priority } else sources;
|
||||
val hasOriginal = altSources.any { it.original };
|
||||
if(hasOriginal && Settings.instance.playback.preferOriginalAudio)
|
||||
altSources = altSources.filter { it.original };
|
||||
val languageToFilter = if(preferredLanguage != null && altSources.any { it.language == preferredLanguage }) {
|
||||
preferredLanguage
|
||||
} else {
|
||||
if(altSources.any { it.language == Language.ENGLISH })
|
||||
Language.ENGLISH
|
||||
Language.ENGLISH;
|
||||
else
|
||||
Language.UNKNOWN;
|
||||
}
|
||||
|
@ -208,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(),
|
||||
|
|
|
@ -7,6 +7,8 @@ import android.widget.ImageView
|
|||
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
|
||||
|
@ -18,7 +20,8 @@ data class ImageVariable(
|
|||
@Transient
|
||||
@Contextual
|
||||
private val bitmap: Bitmap? = null,
|
||||
val presetName: String? = null) {
|
||||
val presetName: String? = null,
|
||||
var subscriptionUrl: String? = null) {
|
||||
|
||||
@SuppressLint("DiscouragedApi")
|
||||
fun setImageView(imageView: ImageView, fallbackResId: Int = -1) {
|
||||
|
@ -33,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()) {
|
||||
|
@ -63,7 +72,13 @@ data class ImageVariable(
|
|||
return ImageVariable(null, null, null, str);
|
||||
}
|
||||
fun fromFile(file: File): ImageVariable {
|
||||
return ImageVariable.fromBitmap(BitmapFactory.decodeFile(file.absolutePath));
|
||||
try {
|
||||
return ImageVariable.fromBitmap(BitmapFactory.decodeFile(file.absolutePath));
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e("ImageVariable", "Unsupported image format? " + ex.message, ex);
|
||||
return fromResource(R.drawable.ic_error_pred);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -119,7 +119,7 @@ class HLS {
|
|||
return if (source is IHLSManifestSource) {
|
||||
listOf()
|
||||
} else if (source is IHLSManifestAudioSource) {
|
||||
listOf(HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, url))
|
||||
listOf(HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, url))
|
||||
} else {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
@ -340,7 +340,7 @@ class HLS {
|
|||
|
||||
val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
|
||||
return@mapNotNull when (it.type) {
|
||||
"AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, it.uri)
|
||||
"AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, false, it.uri)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -19,10 +19,10 @@ import kotlinx.serialization.json.jsonPrimitive
|
|||
class PlatformContentSerializer : JsonContentPolymorphicSerializer<SerializedPlatformContent>(SerializedPlatformContent::class) {
|
||||
|
||||
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<SerializedPlatformContent> {
|
||||
val obj = element.jsonObject["contentType"];
|
||||
val obj = element.jsonObject["contentType"] ?: element.jsonObject["ContentType"];
|
||||
|
||||
//TODO: Remove this temporary fallback..at some point
|
||||
if(obj == null && element.jsonObject["isLive"]?.jsonPrimitive?.booleanOrNull != null)
|
||||
if(obj == null && (element.jsonObject["isLive"]?.jsonPrimitive?.booleanOrNull ?: element.jsonObject["IsLive"]?.jsonPrimitive?.booleanOrNull) != null)
|
||||
return SerializedPlatformVideo.serializer();
|
||||
|
||||
if(obj?.jsonPrimitive?.isString != false) {
|
||||
|
|
|
@ -184,7 +184,7 @@ class StatePlaylists {
|
|||
wasNew = true;
|
||||
_watchlistStore.saveAsync(video);
|
||||
if(orderPosition == -1)
|
||||
_watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values) .toTypedArray());
|
||||
_watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values).toTypedArray());
|
||||
else {
|
||||
val existing = _watchlistOrderStore.getAllValues().toMutableList();
|
||||
existing.add(orderPosition, video.url);
|
||||
|
@ -230,17 +230,20 @@ class StatePlaylists {
|
|||
}
|
||||
}
|
||||
|
||||
public fun getWatchLaterSyncPacket(orderOnly: Boolean = false): SyncWatchLaterPackage{
|
||||
return SyncWatchLaterPackage(
|
||||
if (orderOnly) listOf() else getWatchLater(),
|
||||
if (orderOnly) mapOf() else _watchLaterAdds.all(),
|
||||
if (orderOnly) mapOf() else _watchLaterRemovals.all(),
|
||||
getWatchLaterLastReorderTime().toEpochSecond(),
|
||||
_watchlistOrderStore.values.toList()
|
||||
)
|
||||
}
|
||||
private fun broadcastWatchLater(orderOnly: Boolean = false) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
StateSync.instance.broadcastJsonData(
|
||||
GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage(
|
||||
if (orderOnly) listOf() else getWatchLater(),
|
||||
if (orderOnly) mapOf() else _watchLaterAdds.all(),
|
||||
if (orderOnly) mapOf() else _watchLaterRemovals.all(),
|
||||
getWatchLaterLastReorderTime().toEpochSecond(),
|
||||
_watchlistOrderStore.values.toList()
|
||||
)
|
||||
GJSyncOpcodes.syncWatchLater, getWatchLaterSyncPacket(orderOnly)
|
||||
);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to broadcast watch later", e)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -445,106 +445,92 @@ class StateSync {
|
|||
deviceRemoved.emit(remotePublicKey)
|
||||
}
|
||||
|
||||
|
||||
private fun handleSyncSubscriptionPackage(origin: SyncSession, pack: SyncSubscriptionsPackage) {
|
||||
val added = mutableListOf<Subscription>()
|
||||
for (sub in pack.subscriptions) {
|
||||
if (!StateSubscriptions.instance.isSubscribed(sub.channel)) {
|
||||
val removalTime = StateSubscriptions.instance.getSubscriptionRemovalTime(sub.channel.url)
|
||||
if (sub.creationTime > removalTime) {
|
||||
val newSub = StateSubscriptions.instance.addSubscription(sub.channel, sub.creationTime)
|
||||
added.add(newSub)
|
||||
for(sub in pack.subscriptions) {
|
||||
if(!StateSubscriptions.instance.isSubscribed(sub.channel)) {
|
||||
val removalTime = StateSubscriptions.instance.getSubscriptionRemovalTime(sub.channel.url);
|
||||
if(sub.creationTime > removalTime) {
|
||||
val newSub = StateSubscriptions.instance.addSubscription(sub.channel, sub.creationTime);
|
||||
added.add(newSub);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (added.size > 3)
|
||||
UIDialogs.appToast("${added.size} Subscriptions from ${origin.remotePublicKey.take(8)}")
|
||||
else if (added.size > 0)
|
||||
UIDialogs.appToast("Subscriptions from ${origin.remotePublicKey.take(8)}:\n${added.joinToString("\n") { it.channel.name }}")
|
||||
if(added.size > 3)
|
||||
UIDialogs.appToast("${added.size} Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}");
|
||||
else if(added.size > 0)
|
||||
UIDialogs.appToast("Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}:\n" +
|
||||
added.map { it.channel.name }.joinToString("\n"));
|
||||
|
||||
if (pack.subscriptionRemovals.isNotEmpty()) {
|
||||
val removed = StateSubscriptions.instance.applySubscriptionRemovals(pack.subscriptionRemovals)
|
||||
if (removed.size > 3)
|
||||
UIDialogs.appToast("Removed ${removed.size} Subscriptions from ${origin.remotePublicKey.take(8)}")
|
||||
else if (removed.size > 0)
|
||||
UIDialogs.appToast("Subscriptions removed from ${origin.remotePublicKey.take(8)}:\n${removed.joinToString("\n") { it.channel.name }}")
|
||||
|
||||
if(pack.subscriptions.isNotEmpty()) {
|
||||
for (subRemoved in pack.subscriptionRemovals) {
|
||||
val removed = StateSubscriptions.instance.applySubscriptionRemovals(pack.subscriptionRemovals);
|
||||
if(removed.size > 3) {
|
||||
UIDialogs.appToast("Removed ${removed.size} Subscriptions from ${origin.remotePublicKey.substring(0, 8.coerceAtMost(origin.remotePublicKey.length))}");
|
||||
} else if(removed.isNotEmpty()) {
|
||||
UIDialogs.appToast("Subscriptions removed from ${origin.remotePublicKey.substring(0, 8.coerceAtMost(origin.remotePublicKey.length))}:\n" + removed.map { it.channel.name }.joinToString("\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleData(it: SyncSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
|
||||
val remotePublicKey = it.remotePublicKey
|
||||
private fun handleData(session: SyncSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
|
||||
val remotePublicKey = session.remotePublicKey
|
||||
when (subOpcode) {
|
||||
GJSyncOpcodes.sendToDevices -> {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
val context = StateApp.instance.contextOrNull
|
||||
if (context is MainActivity) {
|
||||
val dataBody = ByteArray(data.remaining())
|
||||
data.get(dataBody)
|
||||
val json = String(dataBody, Charsets.UTF_8)
|
||||
val obj = Json.decodeFromString<SendToDevicePackage>(json)
|
||||
UIDialogs.appToast("Received url from device [${it.remotePublicKey}]:\n${obj.url}")
|
||||
context.handleUrl(obj.url, obj.position)
|
||||
val context = StateApp.instance.contextOrNull;
|
||||
if (context != null && context is MainActivity) {
|
||||
val dataBody = ByteArray(data.remaining());
|
||||
val remainder = data.remaining();
|
||||
data.get(dataBody, 0, remainder);
|
||||
val json = String(dataBody, Charsets.UTF_8);
|
||||
val obj = Json.decodeFromString<SendToDevicePackage>(json);
|
||||
UIDialogs.appToast("Received url from device [${session.remotePublicKey}]:\n{${obj.url}");
|
||||
context.handleUrl(obj.url, obj.position);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
GJSyncOpcodes.syncStateExchange -> {
|
||||
val dataBody = ByteArray(data.remaining())
|
||||
data.get(dataBody)
|
||||
val json = String(dataBody, Charsets.UTF_8)
|
||||
val syncSessionData = Serializer.json.decodeFromString<SyncSessionData>(json)
|
||||
Logger.i(TAG, "Received SyncSessionData from $remotePublicKey")
|
||||
val dataBody = ByteArray(data.remaining());
|
||||
data.get(dataBody);
|
||||
val json = String(dataBody, Charsets.UTF_8);
|
||||
val syncSessionData = Serializer.json.decodeFromString<SyncSessionData>(json);
|
||||
|
||||
it.sendData(
|
||||
GJSyncOpcodes.syncSubscriptions,
|
||||
StateSubscriptions.instance.getSyncSubscriptionsPackageString()
|
||||
)
|
||||
it.sendData(
|
||||
GJSyncOpcodes.syncSubscriptionGroups,
|
||||
StateSubscriptionGroups.instance.getSyncSubscriptionGroupsPackageString()
|
||||
)
|
||||
it.sendData(
|
||||
GJSyncOpcodes.syncPlaylists,
|
||||
StatePlaylists.instance.getSyncPlaylistsPackageString()
|
||||
)
|
||||
Logger.i(TAG, "Received SyncSessionData from $remotePublicKey");
|
||||
|
||||
val recentHistory =
|
||||
StateHistory.instance.getRecentHistory(syncSessionData.lastHistory)
|
||||
if (recentHistory.isNotEmpty())
|
||||
it.sendJsonData(
|
||||
GJSyncOpcodes.syncHistory,
|
||||
recentHistory
|
||||
)
|
||||
|
||||
session.sendData(GJSyncOpcodes.syncSubscriptions, StateSubscriptions.instance.getSyncSubscriptionsPackageString());
|
||||
session.sendData(GJSyncOpcodes.syncSubscriptionGroups, StateSubscriptionGroups.instance.getSyncSubscriptionGroupsPackageString());
|
||||
session.sendData(GJSyncOpcodes.syncPlaylists, StatePlaylists.instance.getSyncPlaylistsPackageString())
|
||||
|
||||
session.sendData(GJSyncOpcodes.syncWatchLater, Json.encodeToString(StatePlaylists.instance.getWatchLaterSyncPacket(false)));
|
||||
|
||||
val recentHistory = StateHistory.instance.getRecentHistory(syncSessionData.lastHistory);
|
||||
if(recentHistory.isNotEmpty())
|
||||
session.sendJsonData(GJSyncOpcodes.syncHistory, recentHistory);
|
||||
}
|
||||
|
||||
GJSyncOpcodes.syncExport -> {
|
||||
val dataBody = ByteArray(data.remaining())
|
||||
val bytesStr = ByteArrayInputStream(
|
||||
data.array(),
|
||||
data.position(),
|
||||
data.remaining()
|
||||
)
|
||||
val dataBody = ByteArray(data.remaining());
|
||||
val bytesStr = ByteArrayInputStream(data.array(), data.position(), data.remaining());
|
||||
bytesStr.use { bytesStrBytes ->
|
||||
val exportStruct =
|
||||
StateBackup.ExportStructure.fromZipBytes(
|
||||
bytesStrBytes
|
||||
)
|
||||
val exportStruct = StateBackup.ExportStructure.fromZipBytes(bytesStrBytes);
|
||||
for (store in exportStruct.stores) {
|
||||
if (store.key.equals("subscriptions", true)) {
|
||||
val subStore =
|
||||
StateSubscriptions.instance.getUnderlyingSubscriptionsStore()
|
||||
StateApp.instance.scopeOrNull?.launch(
|
||||
Dispatchers.IO
|
||||
) {
|
||||
StateSubscriptions.instance.getUnderlyingSubscriptionsStore();
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
val pack = SyncSubscriptionsPackage(
|
||||
store.value.map {
|
||||
subStore.fromReconstruction(
|
||||
it,
|
||||
exportStruct.cache
|
||||
)
|
||||
subStore.fromReconstruction(it, exportStruct.cache)
|
||||
},
|
||||
StateSubscriptions.instance.getSubscriptionRemovals()
|
||||
)
|
||||
handleSyncSubscriptionPackage(it, pack)
|
||||
);
|
||||
handleSyncSubscriptionPackage(session, pack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -552,231 +538,137 @@ class StateSync {
|
|||
}
|
||||
|
||||
GJSyncOpcodes.syncSubscriptions -> {
|
||||
val dataBody = ByteArray(data.remaining())
|
||||
data.get(dataBody)
|
||||
val json = String(dataBody, Charsets.UTF_8)
|
||||
val subPackage =
|
||||
Serializer.json.decodeFromString<SyncSubscriptionsPackage>(
|
||||
json
|
||||
)
|
||||
handleSyncSubscriptionPackage(it, subPackage)
|
||||
val dataBody = ByteArray(data.remaining());
|
||||
data.get(dataBody);
|
||||
val json = String(dataBody, Charsets.UTF_8);
|
||||
val subPackage = Serializer.json.decodeFromString<SyncSubscriptionsPackage>(json);
|
||||
handleSyncSubscriptionPackage(session, subPackage);
|
||||
|
||||
val newestSub =
|
||||
subPackage.subscriptions.maxOf { it.creationTime }
|
||||
val sesData =
|
||||
instance.getSyncSessionData(remotePublicKey)
|
||||
if (newestSub > sesData.lastSubscription) {
|
||||
sesData.lastSubscription = newestSub
|
||||
instance.saveSyncSessionData(sesData)
|
||||
val newestSub = subPackage.subscriptions.maxOf { it.creationTime };
|
||||
|
||||
val sesData = getSyncSessionData(remotePublicKey);
|
||||
if(newestSub > sesData.lastSubscription) {
|
||||
sesData.lastSubscription = newestSub;
|
||||
saveSyncSessionData(sesData);
|
||||
}
|
||||
}
|
||||
|
||||
GJSyncOpcodes.syncSubscriptionGroups -> {
|
||||
val dataBody = ByteArray(data.remaining())
|
||||
data.get(dataBody)
|
||||
val json = String(dataBody, Charsets.UTF_8)
|
||||
val pack =
|
||||
Serializer.json.decodeFromString<SyncSubscriptionGroupsPackage>(
|
||||
json
|
||||
)
|
||||
val dataBody = ByteArray(data.remaining());
|
||||
data.get(dataBody);
|
||||
val json = String(dataBody, Charsets.UTF_8);
|
||||
val pack = Serializer.json.decodeFromString<SyncSubscriptionGroupsPackage>(json);
|
||||
|
||||
var lastSubgroupChange = OffsetDateTime.MIN
|
||||
for (group in pack.groups) {
|
||||
if (group.lastChange > lastSubgroupChange)
|
||||
lastSubgroupChange = group.lastChange
|
||||
var lastSubgroupChange = OffsetDateTime.MIN;
|
||||
for(group in pack.groups){
|
||||
if(group.lastChange > lastSubgroupChange)
|
||||
lastSubgroupChange = group.lastChange;
|
||||
|
||||
val existing =
|
||||
StateSubscriptionGroups.instance.getSubscriptionGroup(
|
||||
group.id
|
||||
)
|
||||
if (existing == null)
|
||||
StateSubscriptionGroups.instance.updateSubscriptionGroup(
|
||||
group,
|
||||
false,
|
||||
true
|
||||
)
|
||||
else if (existing.lastChange < group.lastChange) {
|
||||
existing.name = group.name
|
||||
existing.urls = group.urls
|
||||
existing.image = group.image
|
||||
existing.priority = group.priority
|
||||
existing.lastChange = group.lastChange
|
||||
StateSubscriptionGroups.instance.updateSubscriptionGroup(
|
||||
existing,
|
||||
false,
|
||||
true
|
||||
)
|
||||
val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id);
|
||||
|
||||
if(existing == null)
|
||||
StateSubscriptionGroups.instance.updateSubscriptionGroup(group, false, true);
|
||||
else if(existing.lastChange < group.lastChange) {
|
||||
existing.name = group.name;
|
||||
existing.urls = group.urls;
|
||||
existing.image = group.image;
|
||||
existing.priority = group.priority;
|
||||
existing.lastChange = group.lastChange;
|
||||
StateSubscriptionGroups.instance.updateSubscriptionGroup(existing, false, true);
|
||||
}
|
||||
}
|
||||
for (removal in pack.groupRemovals) {
|
||||
val creation =
|
||||
StateSubscriptionGroups.instance.getSubscriptionGroup(
|
||||
removal.key
|
||||
)
|
||||
val removalTime = OffsetDateTime.ofInstant(
|
||||
Instant.ofEpochSecond(removal.value), ZoneOffset.UTC
|
||||
)
|
||||
if (creation != null && creation.creationTime < removalTime)
|
||||
StateSubscriptionGroups.instance.deleteSubscriptionGroup(
|
||||
removal.key,
|
||||
false
|
||||
)
|
||||
for(removal in pack.groupRemovals) {
|
||||
val creation = StateSubscriptionGroups.instance.getSubscriptionGroup(removal.key);
|
||||
val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC);
|
||||
if(creation != null && creation.creationTime < removalTime)
|
||||
StateSubscriptionGroups.instance.deleteSubscriptionGroup(removal.key, false);
|
||||
}
|
||||
}
|
||||
|
||||
GJSyncOpcodes.syncPlaylists -> {
|
||||
val dataBody = ByteArray(data.remaining())
|
||||
data.get(dataBody)
|
||||
val json = String(dataBody, Charsets.UTF_8)
|
||||
val pack =
|
||||
Serializer.json.decodeFromString<SyncPlaylistsPackage>(
|
||||
json
|
||||
)
|
||||
val dataBody = ByteArray(data.remaining());
|
||||
data.get(dataBody);
|
||||
val json = String(dataBody, Charsets.UTF_8);
|
||||
val pack = Serializer.json.decodeFromString<SyncPlaylistsPackage>(json);
|
||||
|
||||
for (playlist in pack.playlists) {
|
||||
val existing =
|
||||
StatePlaylists.instance.getPlaylist(playlist.id)
|
||||
if (existing == null)
|
||||
StatePlaylists.instance.createOrUpdatePlaylist(
|
||||
playlist,
|
||||
false
|
||||
)
|
||||
else if (existing.dateUpdate.toLocalDateTime() < playlist.dateUpdate.toLocalDateTime()) {
|
||||
existing.dateUpdate = playlist.dateUpdate
|
||||
existing.name = playlist.name
|
||||
existing.videos = playlist.videos
|
||||
existing.dateCreation = playlist.dateCreation
|
||||
existing.datePlayed = playlist.datePlayed
|
||||
StatePlaylists.instance.createOrUpdatePlaylist(
|
||||
existing,
|
||||
false
|
||||
)
|
||||
for(playlist in pack.playlists) {
|
||||
val existing = StatePlaylists.instance.getPlaylist(playlist.id);
|
||||
|
||||
if(existing == null)
|
||||
StatePlaylists.instance.createOrUpdatePlaylist(playlist, false);
|
||||
else if(existing.dateUpdate.toLocalDateTime() < playlist.dateUpdate.toLocalDateTime()) {
|
||||
existing.dateUpdate = playlist.dateUpdate;
|
||||
existing.name = playlist.name;
|
||||
existing.videos = playlist.videos;
|
||||
existing.dateCreation = playlist.dateCreation;
|
||||
existing.datePlayed = playlist.datePlayed;
|
||||
StatePlaylists.instance.createOrUpdatePlaylist(existing, false);
|
||||
}
|
||||
}
|
||||
for (removal in pack.playlistRemovals) {
|
||||
val creation =
|
||||
StatePlaylists.instance.getPlaylist(removal.key)
|
||||
val removalTime = OffsetDateTime.ofInstant(
|
||||
Instant.ofEpochSecond(removal.value), ZoneOffset.UTC
|
||||
)
|
||||
if (creation != null && creation.dateCreation < removalTime)
|
||||
StatePlaylists.instance.removePlaylist(
|
||||
creation,
|
||||
false
|
||||
)
|
||||
for(removal in pack.playlistRemovals) {
|
||||
val creation = StatePlaylists.instance.getPlaylist(removal.key);
|
||||
val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC);
|
||||
if(creation != null && creation.dateCreation < removalTime)
|
||||
StatePlaylists.instance.removePlaylist(creation, false);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
GJSyncOpcodes.syncWatchLater -> {
|
||||
val dataBody = ByteArray(data.remaining())
|
||||
data.get(dataBody)
|
||||
val json = String(dataBody, Charsets.UTF_8)
|
||||
val pack =
|
||||
Serializer.json.decodeFromString<SyncWatchLaterPackage>(
|
||||
json
|
||||
)
|
||||
val dataBody = ByteArray(data.remaining());
|
||||
data.get(dataBody);
|
||||
val json = String(dataBody, Charsets.UTF_8);
|
||||
val pack = Serializer.json.decodeFromString<SyncWatchLaterPackage>(json);
|
||||
|
||||
Logger.i(
|
||||
TAG,
|
||||
"SyncWatchLater received ${pack.videos.size} (${pack.videoAdds.size}, ${pack.videoRemovals.size})"
|
||||
)
|
||||
Logger.i(TAG, "SyncWatchLater received ${pack.videos.size} (${pack.videoAdds?.size}, ${pack.videoRemovals?.size})");
|
||||
|
||||
val allExisting = StatePlaylists.instance.getWatchLater()
|
||||
for (video in pack.videos) {
|
||||
val existing =
|
||||
allExisting.firstOrNull { it.url == video.url }
|
||||
val time = pack.videoAdds.get(video.url)?.let {
|
||||
OffsetDateTime.ofInstant(
|
||||
Instant.ofEpochSecond(it), ZoneOffset.UTC
|
||||
)
|
||||
} ?: OffsetDateTime.MIN
|
||||
if (existing == null) {
|
||||
StatePlaylists.instance.addToWatchLater(
|
||||
video,
|
||||
false
|
||||
)
|
||||
if (time > OffsetDateTime.MIN)
|
||||
StatePlaylists.instance.setWatchLaterAddTime(
|
||||
video.url,
|
||||
time
|
||||
)
|
||||
val allExisting = StatePlaylists.instance.getWatchLater();
|
||||
for(video in pack.videos) {
|
||||
val existing = allExisting.firstOrNull { it.url == video.url };
|
||||
val time = if(pack.videoAdds != null && pack.videoAdds.containsKey(video.url)) OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.videoAdds[video.url] ?: 0), ZoneOffset.UTC) else OffsetDateTime.MIN;
|
||||
|
||||
if(existing == null) {
|
||||
StatePlaylists.instance.addToWatchLater(video, false);
|
||||
if(time > OffsetDateTime.MIN)
|
||||
StatePlaylists.instance.setWatchLaterAddTime(video.url, time);
|
||||
}
|
||||
}
|
||||
for (removal in pack.videoRemovals) {
|
||||
val watchLater =
|
||||
allExisting.firstOrNull { it.url == removal.key }
|
||||
?: continue
|
||||
val creation =
|
||||
StatePlaylists.instance.getWatchLaterRemovalTime(
|
||||
watchLater.url
|
||||
) ?: OffsetDateTime.MIN
|
||||
val removalTime = OffsetDateTime.ofInstant(
|
||||
Instant.ofEpochSecond(removal.value), ZoneOffset.UTC
|
||||
)
|
||||
if (creation < removalTime)
|
||||
StatePlaylists.instance.removeFromWatchLater(
|
||||
watchLater,
|
||||
false,
|
||||
removalTime
|
||||
)
|
||||
for(removal in pack.videoRemovals) {
|
||||
val watchLater = allExisting.firstOrNull { it.url == removal.key } ?: continue;
|
||||
val creation = StatePlaylists.instance.getWatchLaterRemovalTime(watchLater.url) ?: OffsetDateTime.MIN;
|
||||
val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value), ZoneOffset.UTC);
|
||||
if(creation < removalTime)
|
||||
StatePlaylists.instance.removeFromWatchLater(watchLater, false, removalTime);
|
||||
}
|
||||
|
||||
val packReorderTime = OffsetDateTime.ofInstant(
|
||||
Instant.ofEpochSecond(pack.reorderTime),
|
||||
ZoneOffset.UTC
|
||||
)
|
||||
val localReorderTime =
|
||||
StatePlaylists.instance.getWatchLaterLastReorderTime()
|
||||
if (localReorderTime < packReorderTime && pack.ordering != null) {
|
||||
StatePlaylists.instance.updateWatchLaterOrdering(
|
||||
smartMerge(
|
||||
pack.ordering!!,
|
||||
StatePlaylists.instance.getWatchLaterOrdering()
|
||||
),
|
||||
true
|
||||
)
|
||||
val packReorderTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.reorderTime), ZoneOffset.UTC);
|
||||
val localReorderTime = StatePlaylists.instance.getWatchLaterLastReorderTime();
|
||||
if(localReorderTime < packReorderTime && pack.ordering != null) {
|
||||
StatePlaylists.instance.updateWatchLaterOrdering(smartMerge(pack.ordering!!, StatePlaylists.instance.getWatchLaterOrdering()), true);
|
||||
}
|
||||
}
|
||||
|
||||
GJSyncOpcodes.syncHistory -> {
|
||||
val dataBody = ByteArray(data.remaining())
|
||||
data.get(dataBody)
|
||||
val json = String(dataBody, Charsets.UTF_8)
|
||||
val history =
|
||||
Serializer.json.decodeFromString<List<HistoryVideo>>(
|
||||
json
|
||||
)
|
||||
Logger.i(
|
||||
TAG,
|
||||
"SyncHistory received ${history.size} videos from $remotePublicKey"
|
||||
)
|
||||
val dataBody = ByteArray(data.remaining());
|
||||
data.get(dataBody);
|
||||
val json = String(dataBody, Charsets.UTF_8);
|
||||
val history = Serializer.json.decodeFromString<List<HistoryVideo>>(json);
|
||||
Logger.i(TAG, "SyncHistory received ${history.size} videos from ${remotePublicKey}");
|
||||
|
||||
var lastHistory = OffsetDateTime.MIN
|
||||
for (video in history) {
|
||||
val hist = StateHistory.instance.getHistoryByVideo(
|
||||
video.video,
|
||||
true,
|
||||
video.date
|
||||
)
|
||||
if (hist != null)
|
||||
StateHistory.instance.updateHistoryPosition(
|
||||
video.video,
|
||||
hist,
|
||||
true,
|
||||
video.position,
|
||||
video.date
|
||||
)
|
||||
if (lastHistory < video.date)
|
||||
lastHistory = video.date
|
||||
var lastHistory = OffsetDateTime.MIN;
|
||||
for(video in history){
|
||||
val hist = StateHistory.instance.getHistoryByVideo(video.video, true, video.date);
|
||||
if(hist != null)
|
||||
StateHistory.instance.updateHistoryPosition(video.video, hist, true, video.position, video.date)
|
||||
if(lastHistory < video.date)
|
||||
lastHistory = video.date;
|
||||
}
|
||||
|
||||
if (lastHistory != OffsetDateTime.MIN && history.size > 1) {
|
||||
val sesData = instance.getSyncSessionData(
|
||||
remotePublicKey
|
||||
)
|
||||
if(lastHistory != OffsetDateTime.MIN && history.size > 1) {
|
||||
val sesData = getSyncSessionData(remotePublicKey);
|
||||
if (lastHistory > sesData.lastHistory) {
|
||||
sesData.lastHistory = lastHistory
|
||||
instance.saveSyncSessionData(sesData)
|
||||
sesData.lastHistory = lastHistory;
|
||||
saveSyncSessionData(sesData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,4 +41,19 @@ class StringArrayStorage : FragmentedStorageFileJson() {
|
|||
return values.toList();
|
||||
}
|
||||
}
|
||||
fun any(): Boolean {
|
||||
synchronized(values) {
|
||||
return values.any();
|
||||
}
|
||||
}
|
||||
fun contains(v: String): Boolean {
|
||||
synchronized(values) {
|
||||
return values.contains(v);
|
||||
}
|
||||
}
|
||||
fun indexOf(v: String): Int {
|
||||
synchronized(values){
|
||||
return values.indexOf(v);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,22 +1,28 @@
|
|||
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.debug.Stopwatch
|
||||
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
|
||||
import com.futo.platformplayer.getNowDiffMiliseconds
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
|
@ -24,7 +30,12 @@ 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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.ForkJoinPool
|
||||
|
@ -35,7 +46,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 +57,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,11 +82,46 @@ abstract class SubscriptionsTaskFetchAlgorithm(
|
|||
|
||||
val exs: ArrayList<Throwable> = arrayListOf();
|
||||
|
||||
var contract: ExchangeContract? = null;
|
||||
var providedTasks: MutableList<SubscriptionTask>? = null;
|
||||
|
||||
try {
|
||||
val contractingTime = measureTimeMillis {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if(contract != null)
|
||||
Logger.i(TAG, "Subscription Exchange contract received in ${contractingTime}ms");
|
||||
else if(contractingTime > 100)
|
||||
Logger.i(TAG, "Subscription Exchange contract failed to received in${contractingTime}ms");
|
||||
|
||||
}
|
||||
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);
|
||||
|
||||
val taskResults = arrayListOf<SubscriptionTaskResult>();
|
||||
var resolveCount = 0;
|
||||
var resolveTime = 0L;
|
||||
val timeTotal = measureTimeMillis {
|
||||
for(task in forkTasks) {
|
||||
try {
|
||||
|
@ -103,14 +150,82 @@ abstract class SubscriptionsTaskFetchAlgorithm(
|
|||
}
|
||||
};
|
||||
}
|
||||
|
||||
//Resolve Subscription Exchange
|
||||
if(contract != null) {
|
||||
fun resolve() {
|
||||
try {
|
||||
resolveTime = measureTimeMillis {
|
||||
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 resolveRequestStart = OffsetDateTime.now();
|
||||
|
||||
val resolve = subsExchangeClient?.resolveContract(
|
||||
contract!!,
|
||||
*resolves
|
||||
);
|
||||
|
||||
Logger.i(TAG, "Subscription Exchange contract resolved request in ${resolveRequestStart.getNowDiffMiliseconds()}ms");
|
||||
|
||||
if (resolve != null) {
|
||||
resolveCount = resolves.size;
|
||||
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")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Logger.i(TAG, "Subscription Exchange contract resolved in ${resolveTime}ms");
|
||||
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
//TODO: fetch remainder after all?
|
||||
Logger.e(TAG, "Failed to resolve Subscription Exchange contract due to: " + ex.message, ex);
|
||||
}
|
||||
}
|
||||
if(providedTasks?.size ?: 0 == 0)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
resolve();
|
||||
}
|
||||
else
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
|
||||
Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms");
|
||||
if(resolveCount > 0) {
|
||||
val selfFetchTime = timeTotal - resolveTime;
|
||||
val selfFetchCount = tasks.count { !it.fromPeek && !it.fromCache };
|
||||
if(selfFetchCount > 0) {
|
||||
val selfResolvePercentage = resolveCount.toDouble() / selfFetchCount;
|
||||
val estimateSelfFetchTime = selfFetchTime + selfFetchTime * selfResolvePercentage;
|
||||
val selfFetchDelta = timeTotal - estimateSelfFetchTime;
|
||||
if(selfFetchDelta > 0)
|
||||
UIDialogs.appToast("Subscription Exchange lost ${selfFetchDelta}ms (out of ${timeTotal}ms)", true);
|
||||
else
|
||||
UIDialogs.appToast("Subscription Exchange saved ${(selfFetchDelta * -1).toInt()}ms (out of ${timeTotal}ms)", true);
|
||||
}
|
||||
}
|
||||
Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms")
|
||||
|
||||
//Cache pagers grouped by channel
|
||||
val groupedPagers = taskResults.groupBy { it.task.sub.channel.url }
|
||||
.map { entry ->
|
||||
val sub = if(!entry.value.isEmpty()) entry.value[0].task.sub else null;
|
||||
val liveTasks = entry.value.filter { !it.task.fromCache };
|
||||
val liveTasks = entry.value.filter { !it.task.fromCache && it.pager != null };
|
||||
val cachedTasks = entry.value.filter { it.task.fromCache };
|
||||
val livePager = if(liveTasks.isNotEmpty()) StateCache.cachePagerResults(scope, MultiChronoContentPager(liveTasks.map { it.pager!! }, true).apply { this.initialize() }) {
|
||||
onNewCacheHit.emit(sub!!, it);
|
||||
|
@ -173,6 +288,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,24 @@
|
|||
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 com.futo.platformplayer.serializers.OffsetDateTimeStringSerializer
|
||||
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,169 @@
|
|||
import com.futo.platformplayer.api.media.Serializer
|
||||
import com.futo.platformplayer.getNowDiffMiliseconds
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithm.Companion.TAG
|
||||
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 com.futo.platformplayer.toGzip
|
||||
import com.futo.platformplayer.toHumanBytesSize
|
||||
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
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
|
||||
class SubsExchangeClient(private val server: String, private val privateKey: String, private val contractTimeout: Int = 1000) {
|
||||
|
||||
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).toByteArray(Charsets.UTF_8), "application/json", contractTimeout)
|
||||
return Json.decodeFromString(data)
|
||||
}
|
||||
suspend fun requestContractAsync(vararg channels: ChannelRequest): ExchangeContract {
|
||||
val data = postAsync("/api/Channel/Contract", Json.encodeToString(channels).toByteArray(Charsets.UTF_8), "application/json")
|
||||
return Json.decodeFromString(data)
|
||||
}
|
||||
|
||||
// Endpoint: Resolve
|
||||
fun resolveContract(contract: ExchangeContract, vararg resolves: ChannelResolve): Array<ChannelResult> {
|
||||
val contractResolve = convertResolves(*resolves)
|
||||
val contractResolveJson = Serializer.json.encodeToString(contractResolve);
|
||||
val contractResolveTimeStart = OffsetDateTime.now();
|
||||
val result = post("/api/Channel/Resolve?contractId=${contract.id}", contractResolveJson.toByteArray(Charsets.UTF_8), "application/json", 0, true)
|
||||
val contractResolveTime = contractResolveTimeStart.getNowDiffMiliseconds();
|
||||
Logger.v("SubsExchangeClient", "Subscription Exchange Resolve Request [${contractResolveTime}ms]:" + result);
|
||||
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).toByteArray(Charsets.UTF_8), "application/json", true)
|
||||
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: ByteArray, contentType: String, timeout: Int = 0, gzip: Boolean = false): String {
|
||||
val url = URL("${server.trim('/')}$query")
|
||||
with(url.openConnection() as HttpURLConnection) {
|
||||
if(timeout > 0)
|
||||
this.connectTimeout = timeout
|
||||
requestMethod = "POST"
|
||||
setRequestProperty("Content-Type", contentType)
|
||||
doOutput = true
|
||||
|
||||
|
||||
if(gzip) {
|
||||
val gzipData = body.toGzip();
|
||||
setRequestProperty("Content-Encoding", "gzip");
|
||||
outputStream.write(gzipData);
|
||||
Logger.i("SubsExchangeClient", "SubsExchange using gzip (${body.size.toHumanBytesSize()} => ${gzipData.size.toHumanBytesSize()}");
|
||||
}
|
||||
else
|
||||
outputStream.write(body);
|
||||
|
||||
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: ByteArray, contentType: String, gzip: Boolean = false): String {
|
||||
return withContext(Dispatchers.IO) {
|
||||
post(query, body, contentType, 0, gzip)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -169,6 +169,9 @@ class SyncSession : IAuthorizable {
|
|||
} catch (ex: Exception) {
|
||||
Logger.w(TAG, "Failed to handle sync package $opcode: ${ex.message}", ex)
|
||||
}
|
||||
catch(ex: Exception) {
|
||||
Logger.w(TAG, "Failed to handle sync package ${opcode}: ${ex.message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T> sendJsonData(subOpcode: UByte, data: T) {
|
||||
|
|
|
@ -12,6 +12,7 @@ import com.futo.platformplayer.Settings
|
|||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.models.SubscriptionGroup
|
||||
import com.futo.platformplayer.states.StateSubscriptionGroups
|
||||
|
@ -46,8 +47,13 @@ class ToggleBar : LinearLayout {
|
|||
_tagsContainer.removeAllViews();
|
||||
for(button in buttons) {
|
||||
_tagsContainer.addView(ToggleTagView(context).apply {
|
||||
this.setInfo(button.name, button.isActive);
|
||||
this.onClick.subscribe { button.action(it); };
|
||||
if(button.icon > 0)
|
||||
this.setInfo(button.icon, button.name, button.isActive, button.isButton);
|
||||
else if(button.iconVariable != null)
|
||||
this.setInfo(button.iconVariable, button.name, button.isActive, button.isButton);
|
||||
else
|
||||
this.setInfo(button.name, button.isActive, button.isButton);
|
||||
this.onClick.subscribe({ view, enabled -> button.action(view, enabled); });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -55,20 +61,42 @@ class ToggleBar : LinearLayout {
|
|||
class Toggle {
|
||||
val name: String;
|
||||
val icon: Int;
|
||||
val action: (Boolean)->Unit;
|
||||
val iconVariable: ImageVariable?;
|
||||
val action: (ToggleTagView, Boolean)->Unit;
|
||||
val isActive: Boolean;
|
||||
var isButton: Boolean = false
|
||||
private set;
|
||||
var tag: String? = null;
|
||||
|
||||
constructor(name: String, icon: Int, isActive: Boolean = false, action: (Boolean)->Unit) {
|
||||
constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
|
||||
this.name = name;
|
||||
this.icon = icon;
|
||||
this.icon = 0;
|
||||
this.iconVariable = icon;
|
||||
this.action = action;
|
||||
this.isActive = isActive;
|
||||
}
|
||||
constructor(name: String, isActive: Boolean = false, action: (Boolean)->Unit) {
|
||||
constructor(name: String, icon: Int, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
|
||||
this.name = name;
|
||||
this.icon = 0;
|
||||
this.icon = icon;
|
||||
this.iconVariable = null;
|
||||
this.action = action;
|
||||
this.isActive = isActive;
|
||||
}
|
||||
constructor(name: String, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
|
||||
this.name = name;
|
||||
this.icon = 0;
|
||||
this.iconVariable = null;
|
||||
this.action = action;
|
||||
this.isActive = isActive;
|
||||
}
|
||||
|
||||
fun asButton(): Toggle{
|
||||
isButton = true;
|
||||
return this;
|
||||
}
|
||||
fun withTag(str: String): Toggle {
|
||||
tag = str;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -14,6 +14,7 @@ class VideoListEditorAdapter : RecyclerView.Adapter<VideoListEditorViewHolder> {
|
|||
|
||||
val onClick = Event1<IPlatformVideo>();
|
||||
val onRemove = Event1<IPlatformVideo>();
|
||||
val onOptions = Event1<IPlatformVideo>();
|
||||
var canEdit = false
|
||||
private set;
|
||||
|
||||
|
@ -28,6 +29,7 @@ class VideoListEditorAdapter : RecyclerView.Adapter<VideoListEditorViewHolder> {
|
|||
val holder = VideoListEditorViewHolder(view, _touchHelper);
|
||||
|
||||
holder.onRemove.subscribe { v -> onRemove.emit(v); };
|
||||
holder.onOptions.subscribe { v -> onOptions.emit(v); };
|
||||
holder.onClick.subscribe { v -> onClick.emit(v); };
|
||||
|
||||
return holder;
|
||||
|
|
|
@ -32,6 +32,7 @@ class VideoListEditorViewHolder : ViewHolder {
|
|||
private val _containerDuration: LinearLayout;
|
||||
private val _containerLive: LinearLayout;
|
||||
private val _imageRemove: ImageButton;
|
||||
private val _imageOptions: ImageButton;
|
||||
private val _imageDragDrop: ImageButton;
|
||||
private val _platformIndicator: PlatformIndicator;
|
||||
private val _layoutDownloaded: FrameLayout;
|
||||
|
@ -41,6 +42,7 @@ class VideoListEditorViewHolder : ViewHolder {
|
|||
|
||||
val onClick = Event1<IPlatformVideo>();
|
||||
val onRemove = Event1<IPlatformVideo>();
|
||||
val onOptions = Event1<IPlatformVideo>();
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
constructor(view: View, touchHelper: ItemTouchHelper? = null) : super(view) {
|
||||
|
@ -54,6 +56,7 @@ class VideoListEditorViewHolder : ViewHolder {
|
|||
_containerDuration = view.findViewById(R.id.thumbnail_duration_container);
|
||||
_containerLive = view.findViewById(R.id.thumbnail_live_container);
|
||||
_imageRemove = view.findViewById(R.id.image_trash);
|
||||
_imageOptions = view.findViewById(R.id.image_settings);
|
||||
_imageDragDrop = view.findViewById<ImageButton>(R.id.image_drag_drop);
|
||||
_platformIndicator = view.findViewById(R.id.thumbnail_platform);
|
||||
_layoutDownloaded = view.findViewById(R.id.layout_downloaded);
|
||||
|
@ -74,6 +77,10 @@ class VideoListEditorViewHolder : ViewHolder {
|
|||
val v = video ?: return@setOnClickListener;
|
||||
onRemove.emit(v);
|
||||
};
|
||||
_imageOptions?.setOnClickListener {
|
||||
val v = video ?: return@setOnClickListener;
|
||||
onOptions.emit(v);
|
||||
}
|
||||
}
|
||||
|
||||
fun bind(v: IPlatformVideo, canEdit: Boolean) {
|
||||
|
|
|
@ -8,6 +8,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
|
@ -22,6 +23,7 @@ class VideoListEditorView : FrameLayout {
|
|||
|
||||
val onVideoOrderChanged = Event1<List<IPlatformVideo>>()
|
||||
val onVideoRemoved = Event1<IPlatformVideo>();
|
||||
val onVideoOptions = Event1<IPlatformVideo>();
|
||||
val onVideoClicked = Event1<IPlatformVideo>();
|
||||
val isEmpty get() = _videos.isEmpty();
|
||||
|
||||
|
@ -54,6 +56,9 @@ class VideoListEditorView : FrameLayout {
|
|||
}
|
||||
};
|
||||
|
||||
adapterVideos.onOptions.subscribe { v ->
|
||||
onVideoOptions?.emit(v);
|
||||
}
|
||||
adapterVideos.onRemove.subscribe { v ->
|
||||
val executeDelete = {
|
||||
synchronized(_videos) {
|
||||
|
|
|
@ -4,27 +4,42 @@ import android.content.Context
|
|||
import android.graphics.Color
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.images.GlideHelper
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
import com.futo.platformplayer.views.ToggleBar
|
||||
|
||||
class ToggleTagView : LinearLayout {
|
||||
private val _root: FrameLayout;
|
||||
private val _textTag: TextView;
|
||||
private var _text: String = "";
|
||||
private var _image: ImageView;
|
||||
|
||||
var isActive: Boolean = false
|
||||
private set;
|
||||
var isButton: Boolean = false
|
||||
private set;
|
||||
|
||||
var onClick = Event1<Boolean>();
|
||||
var onClick = Event2<ToggleTagView, Boolean>();
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
|
||||
LayoutInflater.from(context).inflate(R.layout.view_toggle_tag, this, true);
|
||||
_root = findViewById(R.id.root);
|
||||
_textTag = findViewById(R.id.text_tag);
|
||||
_root.setOnClickListener { setToggle(!isActive); onClick.emit(isActive); }
|
||||
_image = findViewById(R.id.image_tag);
|
||||
_root.setOnClickListener {
|
||||
if(!isButton)
|
||||
setToggle(!isActive);
|
||||
onClick.emit(this, isActive);
|
||||
}
|
||||
}
|
||||
|
||||
fun setToggle(isActive: Boolean) {
|
||||
|
@ -39,9 +54,48 @@ class ToggleTagView : LinearLayout {
|
|||
}
|
||||
}
|
||||
|
||||
fun setInfo(text: String, isActive: Boolean) {
|
||||
fun setInfo(toggle: ToggleBar.Toggle){
|
||||
_text = toggle.name;
|
||||
_textTag.text = toggle.name;
|
||||
setToggle(toggle.isActive);
|
||||
if(toggle.iconVariable != null) {
|
||||
toggle.iconVariable.setImageView(_image, R.drawable.ic_error_pred);
|
||||
_image.visibility = View.GONE;
|
||||
}
|
||||
else if(toggle.icon > 0) {
|
||||
_image.setImageResource(toggle.icon);
|
||||
_image.visibility = View.GONE;
|
||||
}
|
||||
else
|
||||
_image.visibility = View.VISIBLE;
|
||||
_textTag.visibility = if(!toggle.name.isNullOrEmpty()) View.VISIBLE else View.GONE;
|
||||
this.isButton = isButton;
|
||||
}
|
||||
|
||||
fun setInfo(imageResource: Int, text: String, isActive: Boolean, isButton: Boolean = false) {
|
||||
_text = text;
|
||||
_textTag.text = text;
|
||||
setToggle(isActive);
|
||||
_image.setImageResource(imageResource);
|
||||
_image.visibility = View.VISIBLE;
|
||||
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
|
||||
this.isButton = isButton;
|
||||
}
|
||||
fun setInfo(image: ImageVariable, text: String, isActive: Boolean, isButton: Boolean = false) {
|
||||
_text = text;
|
||||
_textTag.text = text;
|
||||
setToggle(isActive);
|
||||
image.setImageView(_image, R.drawable.ic_error_pred);
|
||||
_image.visibility = View.VISIBLE;
|
||||
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
|
||||
this.isButton = isButton;
|
||||
}
|
||||
fun setInfo(text: String, isActive: Boolean, isButton: Boolean = false) {
|
||||
_image.visibility = View.GONE;
|
||||
_text = text;
|
||||
_textTag.text = text;
|
||||
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
|
||||
setToggle(isActive);
|
||||
this.isButton = isButton;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
};
|
||||
};
|
||||
|
|
|
@ -8,7 +8,9 @@ import android.widget.LinearLayout
|
|||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.views.lists.VideoListEditorView
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||
|
@ -23,6 +25,7 @@ class QueueEditorOverlay : LinearLayout {
|
|||
private val _overlayContainer: FrameLayout;
|
||||
|
||||
|
||||
val onOptions = Event1<IPlatformVideo>();
|
||||
val onClose = Event0();
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
|
||||
|
@ -35,6 +38,9 @@ class QueueEditorOverlay : LinearLayout {
|
|||
|
||||
_topbar.onClose.subscribe(this, onClose::emit);
|
||||
_editor.onVideoOrderChanged.subscribe { StatePlayer.instance.setQueueWithExisting(it) }
|
||||
_editor.onVideoOptions.subscribe { v ->
|
||||
onOptions?.emit(v);
|
||||
}
|
||||
_editor.onVideoRemoved.subscribe { v ->
|
||||
StatePlayer.instance.removeFromQueue(v);
|
||||
_topbar.setInfo(context.getString(R.string.queue), "${StatePlayer.instance.queueSize} " + context.getString(R.string.videos));
|
||||
|
|
|
@ -113,6 +113,13 @@ class SlideUpMenuOverlay : RelativeLayout {
|
|||
_textOK.visibility = View.VISIBLE;
|
||||
}
|
||||
}
|
||||
fun getSlideUpItemByTag(itemTag: Any?): SlideUpMenuItem? {
|
||||
for(view in groupItems){
|
||||
if(view is SlideUpMenuItem && view.itemTag == itemTag)
|
||||
return view;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fun selectOption(groupTag: Any?, itemTag: Any?, multiSelect: Boolean = false, toggle: Boolean = false): Boolean {
|
||||
var didSelect = false;
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -158,7 +158,7 @@ class SubscriptionBar : LinearLayout {
|
|||
for(button in buttons) {
|
||||
_tagsContainer.addView(ToggleTagView(context).apply {
|
||||
this.setInfo(button.name, button.isActive);
|
||||
this.onClick.subscribe { button.action(it); };
|
||||
this.onClick.subscribe({ view, value -> button.action(view, value); });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -166,16 +166,16 @@ class SubscriptionBar : LinearLayout {
|
|||
class Toggle {
|
||||
val name: String;
|
||||
val icon: Int;
|
||||
val action: (Boolean)->Unit;
|
||||
val action: (ToggleTagView, Boolean)->Unit;
|
||||
val isActive: Boolean;
|
||||
|
||||
constructor(name: String, icon: Int, isActive: Boolean = false, action: (Boolean)->Unit) {
|
||||
constructor(name: String, icon: Int, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
|
||||
this.name = name;
|
||||
this.icon = icon;
|
||||
this.action = action;
|
||||
this.isActive = isActive;
|
||||
}
|
||||
constructor(name: String, isActive: Boolean = false, action: (Boolean)->Unit) {
|
||||
constructor(name: String, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
|
||||
this.name = name;
|
||||
this.icon = 0;
|
||||
this.action = action;
|
||||
|
|
|
@ -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>
|
|
@ -1,5 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
|
@ -9,80 +9,90 @@
|
|||
android:paddingEnd="20dp"
|
||||
android:background="@color/black">
|
||||
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingTop="20dp"
|
||||
android:paddingBottom="15dp">
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/button_back"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:contentDescription="@string/cd_button_back"
|
||||
android:paddingRight="20dp"
|
||||
app:srcCompat="@drawable/ic_back_thin_white_16dp" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1">
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingTop="20dp"
|
||||
android:paddingBottom="15dp">
|
||||
|
||||
<TextView
|
||||
<ImageButton
|
||||
android:id="@+id/button_back"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:contentDescription="@string/cd_button_back"
|
||||
android:paddingRight="20dp"
|
||||
app:srcCompat="@drawable/ic_back_thin_white_16dp" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/add_source"
|
||||
android:textSize="24dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_extra_light" />
|
||||
</FrameLayout>
|
||||
android:layout_weight="1">
|
||||
|
||||
<Space
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="match_parent" />
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/add_source"
|
||||
android:textSize="24dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_extra_light" />
|
||||
</FrameLayout>
|
||||
|
||||
<Space
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
<com.futo.platformplayer.views.buttons.BigButton
|
||||
android:id="@+id/option_qr"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:layout_marginBottom="5dp"
|
||||
app:buttonText="@string/install_by_qr"
|
||||
app:buttonSubText="@string/install_a_plugin_by_scanning_a_qr_code"
|
||||
app:buttonIcon="@drawable/ic_qr" />
|
||||
<com.futo.platformplayer.views.buttons.BigButton
|
||||
android:id="@+id/option_browse"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:layout_marginBottom="5dp"
|
||||
app:buttonText="Browse Online Sources"
|
||||
app:buttonSubText="Install a plugin by browsing official plugins"
|
||||
app:buttonIcon="@drawable/ic_explore" />
|
||||
<com.futo.platformplayer.views.buttons.BigButton
|
||||
android:id="@+id/option_url"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:layout_marginBottom="5dp"
|
||||
app:buttonText="@string/install_by_url"
|
||||
app:buttonSubText="@string/enter_url_explain"
|
||||
app:buttonIcon="@drawable/ic_link" />
|
||||
<com.futo.platformplayer.views.buttons.BigButton
|
||||
android:id="@+id/option_plugins"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:alpha="0.5"
|
||||
android:layout_marginTop="5dp"
|
||||
android:layout_marginBottom="20dp"
|
||||
app:buttonText="Install by Store"
|
||||
app:buttonSubText="Browse plugins published through Polycentric."
|
||||
app:buttonIcon="@drawable/ic_sources" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
<com.futo.platformplayer.views.buttons.BigButton
|
||||
android:id="@+id/option_qr"
|
||||
<FrameLayout
|
||||
android:id="@+id/overlay_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:layout_marginBottom="5dp"
|
||||
app:buttonText="@string/install_by_qr"
|
||||
app:buttonSubText="@string/install_a_plugin_by_scanning_a_qr_code"
|
||||
app:buttonIcon="@drawable/ic_qr" />
|
||||
<com.futo.platformplayer.views.buttons.BigButton
|
||||
android:id="@+id/option_browse"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:layout_marginBottom="5dp"
|
||||
app:buttonText="Browse Online Sources"
|
||||
app:buttonSubText="Install a plugin by browsing official plugins"
|
||||
app:buttonIcon="@drawable/ic_explore" />
|
||||
<com.futo.platformplayer.views.buttons.BigButton
|
||||
android:id="@+id/option_url"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:alpha="0.5"
|
||||
android:layout_marginTop="5dp"
|
||||
android:layout_marginBottom="5dp"
|
||||
app:buttonText="@string/install_by_url"
|
||||
app:buttonSubText="@string/enter_url_explain"
|
||||
app:buttonIcon="@drawable/ic_link" />
|
||||
<com.futo.platformplayer.views.buttons.BigButton
|
||||
android:id="@+id/option_plugins"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:alpha="0.5"
|
||||
android:layout_marginTop="5dp"
|
||||
android:layout_marginBottom="20dp"
|
||||
app:buttonText="Install by Store"
|
||||
app:buttonSubText="Browse plugins published through Polycentric."
|
||||
app:buttonIcon="@drawable/ic_sources" />
|
||||
</LinearLayout>
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="gone" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -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>
|
|
@ -135,7 +135,7 @@
|
|||
android:ellipsize="end"
|
||||
app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintRight_toLeftOf="@id/image_trash"
|
||||
app:layout_constraintRight_toLeftOf="@id/buttons"
|
||||
app:layout_constraintBottom_toTopOf="@id/text_author"
|
||||
android:layout_marginStart="10dp" />
|
||||
|
||||
|
@ -152,7 +152,7 @@
|
|||
android:ellipsize="end"
|
||||
app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_video_name"
|
||||
app:layout_constraintRight_toLeftOf="@id/image_trash"
|
||||
app:layout_constraintRight_toLeftOf="@id/buttons"
|
||||
app:layout_constraintBottom_toTopOf="@id/text_video_metadata"
|
||||
android:layout_marginStart="10dp" />
|
||||
|
||||
|
@ -169,19 +169,35 @@
|
|||
android:ellipsize="end"
|
||||
app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_author"
|
||||
app:layout_constraintRight_toLeftOf="@id/image_trash"
|
||||
app:layout_constraintRight_toLeftOf="@id/buttons"
|
||||
android:layout_marginStart="10dp" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/image_trash"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:contentDescription="@string/cd_button_delete"
|
||||
app:srcCompat="@drawable/ic_trash_18dp"
|
||||
android:scaleType="fitCenter"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp"
|
||||
<LinearLayout
|
||||
android:id="@+id/buttons"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/layout_video_thumbnail"
|
||||
app:layout_constraintBottom_toBottomOf="@id/layout_video_thumbnail" />
|
||||
app:layout_constraintBottom_toBottomOf="@id/layout_video_thumbnail" >
|
||||
<ImageButton
|
||||
android:id="@+id/image_trash"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:contentDescription="@string/cd_button_delete"
|
||||
app:srcCompat="@drawable/ic_trash_18dp"
|
||||
android:scaleType="fitCenter"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp"/>
|
||||
<ImageButton
|
||||
android:id="@+id/image_settings"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:contentDescription="@string/cd_button_settings"
|
||||
app:srcCompat="@drawable/ic_settings"
|
||||
android:scaleType="fitCenter"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp" />
|
||||
|
||||
</LinearLayout>
|
||||
</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>
|
|
@ -3,14 +3,14 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<ScrollView
|
||||
<HorizontalScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:scrollbars="horizontal">
|
||||
<LinearLayout
|
||||
android:id="@+id/container_tags"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal" />
|
||||
</ScrollView>
|
||||
</HorizontalScrollView>
|
||||
</LinearLayout>
|
|
@ -3,23 +3,37 @@
|
|||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="32dp"
|
||||
android:paddingStart="15dp"
|
||||
android:paddingEnd="15dp"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:background="@drawable/background_pill"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:layout_marginTop="17dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:id="@+id/root">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_tag"
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/white"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center"
|
||||
android:textSize="11dp"
|
||||
android:fontFamily="@font/inter_light"
|
||||
tools:text="Tag text" />
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal">
|
||||
<ImageView
|
||||
android:id="@+id/image_tag"
|
||||
android:visibility="gone"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginLeft="2.5dp"
|
||||
android:layout_marginRight="2.5dp" />
|
||||
<TextView
|
||||
android:id="@+id/text_tag"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="2.5dp"
|
||||
android:layout_marginRight="2.5dp"
|
||||
android:textColor="@color/white"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center"
|
||||
android:textSize="11dp"
|
||||
android:fontFamily="@font/inter_light"
|
||||
tools:text="Tag text" />
|
||||
</LinearLayout>
|
||||
|
||||
</FrameLayout>
|
|
@ -412,9 +412,15 @@
|
|||
<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>
|
||||
<string name="show_home_filters">Show Home Filters</string>
|
||||
<string name="show_home_filters_description">If the home filters should be shown above home</string>
|
||||
<string name="show_home_filters_plugin_names">Home filter Plugin Names</string>
|
||||
<string name="show_home_filters_plugin_names_description">If home filters should show full plugin names or just icons</string>
|
||||
<string name="log_level">Log Level</string>
|
||||
<string name="logging">Logging</string>
|
||||
<string name="sync_grayjay">Sync Grayjay</string>
|
||||
|
@ -447,6 +453,8 @@
|
|||
<string name="preferred_preview_quality">Preferred Preview Quality</string>
|
||||
<string name="preferred_preview_quality_description">Default quality while previewing a video in a feed</string>
|
||||
<string name="primary_language">Primary Language</string>
|
||||
<string name="prefer_original_audio">Prefer Original Audio</string>
|
||||
<string name="prefer_original_audio_description">Use original audio instead of preferred language when it is known</string>
|
||||
<string name="default_comment_section">Default Comment Section</string>
|
||||
<string name="hide_recommendations">Hide Recommendations</string>
|
||||
<string name="hide_recommendations_description">Fully hide the recommendations tab.</string>
|
||||
|
@ -661,6 +669,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>
|
||||
|
@ -968,6 +977,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>
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit 2046944c18f48c15dfbea82f3f89d7ba6dce5e14
|
||||
Subproject commit 3a0efd1fc4db63c15334a190ab69a8fb4498ae23
|
|
@ -1 +1 @@
|
|||
Subproject commit f2f83344ebc905b36c0689bfef407bb95e6d9af0
|
||||
Subproject commit 215cd9bd70d3cc68e25441f7696dcbe5beee2709
|
|
@ -1 +1 @@
|
|||
Subproject commit ae47f2eaacaf2879405435358965c47eb3d48096
|
||||
Subproject commit f8234d6af8573414d07fd364bc136aa67ad0e379
|
|
@ -1 +1 @@
|
|||
Subproject commit 0d05e35cfc81acfa78594c91c381b79694aaf86d
|
||||
Subproject commit b61095ec200284a686edb8f3b2a595599ad8b5ed
|
1
app/src/stable/assets/sources/tedtalks
Submodule
1
app/src/stable/assets/sources/tedtalks
Submodule
|
@ -0,0 +1 @@
|
|||
Subproject commit 4e490737a02491b52611af321582af8bead7d506
|
|
@ -1 +1 @@
|
|||
Subproject commit 857c147b3a3d3e9d0a79c47f1bd5813e08ed2daf
|
||||
Subproject commit 6f1266a038d11998fef429ae0eac0798b3280d75
|
|
@ -13,7 +13,8 @@
|
|||
"4e365633-6d3f-4267-8941-fdc36631d813": "sources/spotify/build/SpotifyConfig.json",
|
||||
"9c87e8db-e75d-48f4-afe5-2d203d4b95c5": "sources/dailymotion/build/DailymotionConfig.json",
|
||||
"e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json",
|
||||
"89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json"
|
||||
"89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json",
|
||||
"8d029a7f-5507-4e36-8bd8-c19a3b77d383": "sources/tedtalks/TedTalksConfig.json"
|
||||
},
|
||||
"SOURCES_EMBEDDED_DEFAULT": [
|
||||
"35ae969a-a7db-11ed-afa1-0242ac120002"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit 2046944c18f48c15dfbea82f3f89d7ba6dce5e14
|
||||
Subproject commit 3a0efd1fc4db63c15334a190ab69a8fb4498ae23
|
|
@ -1 +1 @@
|
|||
Subproject commit f2f83344ebc905b36c0689bfef407bb95e6d9af0
|
||||
Subproject commit 215cd9bd70d3cc68e25441f7696dcbe5beee2709
|
|
@ -1 +1 @@
|
|||
Subproject commit ae47f2eaacaf2879405435358965c47eb3d48096
|
||||
Subproject commit f8234d6af8573414d07fd364bc136aa67ad0e379
|
|
@ -1 +1 @@
|
|||
Subproject commit 0d05e35cfc81acfa78594c91c381b79694aaf86d
|
||||
Subproject commit b61095ec200284a686edb8f3b2a595599ad8b5ed
|
1
app/src/unstable/assets/sources/tedtalks
Submodule
1
app/src/unstable/assets/sources/tedtalks
Submodule
|
@ -0,0 +1 @@
|
|||
Subproject commit 4e490737a02491b52611af321582af8bead7d506
|
|
@ -1 +1 @@
|
|||
Subproject commit 857c147b3a3d3e9d0a79c47f1bd5813e08ed2daf
|
||||
Subproject commit 6f1266a038d11998fef429ae0eac0798b3280d75
|
|
@ -13,7 +13,8 @@
|
|||
"4e365633-6d3f-4267-8941-fdc36631d813": "sources/spotify/build/SpotifyConfig.json",
|
||||
"9c87e8db-e75d-48f4-afe5-2d203d4b95c5": "sources/dailymotion/build/DailymotionConfig.json",
|
||||
"e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json",
|
||||
"89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json"
|
||||
"89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json",
|
||||
"8d029a7f-5507-4e36-8bd8-c19a3b77d383": "sources/tedtalks/TedTalksConfig.json"
|
||||
},
|
||||
"SOURCES_EMBEDDED_DEFAULT": [
|
||||
"35ae969a-a7db-11ed-afa1-0242ac120002"
|
||||
|
|
Loading…
Add table
Reference in a new issue