Merge branch 'master' into shorts-tab

This commit is contained in:
Kai 2025-04-08 17:58:55 -05:00
commit 830d3a9022
No known key found for this signature in database
101 changed files with 2254 additions and 289 deletions

6
.gitmodules vendored
View File

@ -88,3 +88,9 @@
[submodule "app/src/unstable/assets/sources/apple-podcasts"] [submodule "app/src/unstable/assets/sources/apple-podcasts"]
path = app/src/unstable/assets/sources/apple-podcasts path = app/src/unstable/assets/sources/apple-podcasts
url = ../plugins/apple-podcasts.git 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

View File

@ -216,9 +216,14 @@ private fun ByteArray.toInetAddress(): InetAddress {
return InetAddress.getByAddress(this); return InetAddress.getByAddress(this);
} }
fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? { fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket? {
val timeout = 2000 val timeout = 2000
val addresses = if(!Settings.instance.casting.allowIpv6) attemptAddresses.filterIsInstance<Inet4Address>() else attemptAddresses;
if(addresses.isEmpty())
throw IllegalStateException("No valid addresses found (ipv6: ${(if(Settings.instance.casting.allowIpv6) "enabled" else "disabled")})");
if (addresses.isEmpty()) { if (addresses.isEmpty()) {
return null; return null;
} }

View File

@ -205,7 +205,7 @@ class Settings : FragmentedStorageFileJson() {
var home = HomeSettings(); var home = HomeSettings();
@Serializable @Serializable
class HomeSettings { 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) @DropdownFieldOptionsId(R.array.feed_style)
var homeFeedStyle: Int = 1; var homeFeedStyle: Int = 1;
@ -216,6 +216,11 @@ class Settings : FragmentedStorageFileJson() {
return FeedStyle.THUMBNAIL; 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) @FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
var previewFeedItems: Boolean = true; 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) @FormField(R.string.show_subscription_group, FieldForm.TOGGLE, R.string.show_subscription_group_description, 5)
var showSubscriptionGroups: Boolean = true; 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) @FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
var previewFeedItems: Boolean = true; var previewFeedItems: Boolean = true;
@ -356,7 +364,7 @@ class Settings : FragmentedStorageFileJson() {
var playback = PlaybackSettings(); var playback = PlaybackSettings();
@Serializable @Serializable
class PlaybackSettings { 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) @DropdownFieldOptionsId(R.array.audio_languages)
var primaryLanguage: Int = 0; var primaryLanguage: Int = 0;
@ -380,6 +388,8 @@ class Settings : FragmentedStorageFileJson() {
else -> null 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]; //= context.resources.getStringArray(R.array.audio_languages)[primaryLanguage];
@ -573,10 +583,15 @@ class Settings : FragmentedStorageFileJson() {
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var keepScreenOn: Boolean = true; var keepScreenOn: Boolean = true;
@FormField(R.string.always_proxy_requests, FieldForm.TOGGLE, R.string.always_proxy_requests_description, 1) @FormField(R.string.always_proxy_requests, FieldForm.TOGGLE, R.string.always_proxy_requests_description, 3)
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var alwaysProxyRequests: Boolean = false; var alwaysProxyRequests: Boolean = false;
@FormField(R.string.allow_ipv6, FieldForm.TOGGLE, R.string.allow_ipv6_description, 4)
@Serializable(with = FlexibleBooleanSerializer::class)
var allowIpv6: Boolean = false;
/*TODO: Should we have a different casting quality? /*TODO: Should we have a different casting quality?
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3) @FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
@DropdownFieldOptionsId(R.array.preferred_quality_array) @DropdownFieldOptionsId(R.array.preferred_quality_array)

View File

@ -5,6 +5,7 @@ import android.app.AlertDialog
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.Animatable
import android.net.Uri import android.net.Uri
import android.text.Layout import android.text.Layout
import android.text.method.ScrollingMovementMethod import android.text.method.ScrollingMovementMethod
@ -199,16 +200,21 @@ class UIDialogs {
dialog.show(); dialog.show();
} }
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action) { fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
return showDialog(context, icon, false, text, textDetails, code, defaultCloseAction, *actions);
}
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
val builder = AlertDialog.Builder(context); val builder = AlertDialog.Builder(context);
val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null); val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null);
builder.setView(view); builder.setView(view);
builder.setCancelable(defaultCloseAction > -2);
val dialog = builder.create(); val dialog = builder.create();
registerDialogOpened(dialog); registerDialogOpened(dialog);
view.findViewById<ImageView>(R.id.dialog_icon).apply { view.findViewById<ImageView>(R.id.dialog_icon).apply {
this.setImageResource(icon); this.setImageResource(icon);
if(animated)
this.drawable.assume<Animatable, Unit> { it.start() };
} }
view.findViewById<TextView>(R.id.dialog_text).apply { view.findViewById<TextView>(R.id.dialog_text).apply {
this.text = text; this.text = text;
@ -275,6 +281,7 @@ class UIDialogs {
registerDialogClosed(dialog); registerDialogClosed(dialog);
} }
dialog.show(); dialog.show();
return dialog;
} }
fun showGeneralErrorDialog(context: Context, msg: String, ex: Throwable? = null, button: String = "Ok", onOk: (()->Unit)? = null) { fun showGeneralErrorDialog(context: Context, msg: String, ex: Throwable? = null, button: String = "Ok", onOk: (()->Unit)? = null) {

View File

@ -402,7 +402,7 @@ class UISlideOverlays {
UIDialogs.toast(container.context, "Variant video HLS playlist download started") UIDialogs.toast(container.context, "Variant video HLS playlist download started")
slideUpMenuOverlay.hide() slideUpMenuOverlay.hide()
} else if (source is IHLSManifestAudioSource) { } 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") UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
slideUpMenuOverlay.hide() slideUpMenuOverlay.hide()
} else { } else {
@ -1148,7 +1148,7 @@ class UISlideOverlays {
container.context.getString(R.string.decide_which_buttons_should_be_pinned), container.context.getString(R.string.decide_which_buttons_should_be_pinned),
tag = "", tag = "",
call = { 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 val selected = it
.map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } } .map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } }
.filter { it != null } .filter { it != null }
@ -1156,7 +1156,7 @@ class UISlideOverlays {
.toList(); .toList();
onPinnedbuttons?.invoke(selected + (visible + hidden).filter { !selected.contains(it) }); onPinnedbuttons?.invoke(selected + (visible + hidden).filter { !selected.contains(it) });
} });
}, },
invokeParent = false 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() }; 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, description: String? = null) {
fun showOrderOverlay(container: ViewGroup, title: String, options: List<Pair<String, Any>>, onOrdered: (List<Any>)->Unit) {
val selection: MutableList<Any> = mutableListOf(); val selection: MutableList<Any> = mutableListOf();
var overlay: SlideUpMenuOverlay? = null; var overlay: SlideUpMenuOverlay? = null;
overlay = SlideUpMenuOverlay(container.context, container, title, container.context.getString(R.string.save), true, 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, container.context,
R.drawable.ic_move_up, R.drawable.ic_move_up,
it.first, it.first,
"", "",
tag = it.second, tag = it.second,
call = { call = {
val overlayItem = overlay?.getSlideUpItemByTag(it.second);
if(overlay!!.selectOption(null, it.second, true, true)) { if(overlay!!.selectOption(null, it.second, true, true)) {
if(!selection.contains(it.second)) if(!selection.contains(it.second)) {
selection.add(it.second); selection.add(it.second);
} else if(overlayItem != null) {
overlayItem.setSubText(selection.indexOf(it.second).toString());
}
}
} else {
selection.remove(it.second); selection.remove(it.second);
if(overlayItem != null) {
overlayItem.setSubText("");
}
}
}, },
invokeParent = false invokeParent = false
) )
}); }));
overlay.onOK.subscribe { overlay.onOK.subscribe {
onOrdered.invoke(selection); onOrdered.invoke(selection);
overlay.hide(); overlay.hide();

View File

@ -27,14 +27,18 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.PlatformVideoWithTime import com.futo.platformplayer.models.PlatformVideoWithTime
import com.futo.platformplayer.others.PlatformLinkMovementMethod import com.futo.platformplayer.others.PlatformLinkMovementMethod
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import java.time.OffsetDateTime
import java.util.* import java.util.*
import java.util.concurrent.ThreadLocalRandom import java.util.concurrent.ThreadLocalRandom
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz "; private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
fun getRandomString(sizeOfRandomString: Int): String { fun getRandomString(sizeOfRandomString: Int): String {
@ -279,3 +283,34 @@ fun ByteBuffer.toUtf8String(): String {
get(remainingBytes) get(remainingBytes)
return String(remainingBytes, Charsets.UTF_8) return String(remainingBytes, Charsets.UTF_8)
} }
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()
}

View File

@ -10,11 +10,13 @@ import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.buttons.BigButton
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput
import com.google.zxing.integration.android.IntentIntegrator import com.google.zxing.integration.android.IntentIntegrator
class AddSourceOptionsActivity : AppCompatActivity() { class AddSourceOptionsActivity : AppCompatActivity() {
lateinit var _buttonBack: ImageButton; lateinit var _buttonBack: ImageButton;
lateinit var _overlayContainer: FrameLayout;
lateinit var _buttonQR: BigButton; lateinit var _buttonQR: BigButton;
lateinit var _buttonBrowse: BigButton; lateinit var _buttonBrowse: BigButton;
lateinit var _buttonURL: BigButton; lateinit var _buttonURL: BigButton;
@ -54,6 +56,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
setContentView(R.layout.activity_add_source_options); setContentView(R.layout.activity_add_source_options);
setNavigationBarColorAndIcons(); setNavigationBarColorAndIcons();
_overlayContainer = findViewById(R.id.overlay_container);
_buttonBack = findViewById(R.id.button_back); _buttonBack = findViewById(R.id.button_back);
_buttonQR = findViewById(R.id.option_qr); _buttonQR = findViewById(R.id.option_qr);
@ -81,7 +84,25 @@ class AddSourceOptionsActivity : AppCompatActivity() {
} }
_buttonURL.onClick.subscribe { _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)
} }
} }
} }

View File

@ -113,7 +113,7 @@ class LoginActivity : AppCompatActivity() {
companion object { companion object {
private val TAG = "LoginActivity"; 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; private var _callback: ((SourceAuth?) -> Unit)? = null;

View File

@ -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.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
import java.time.OffsetDateTime import java.time.OffsetDateTime
interface IPlatformContent { interface IPlatformContent {

View File

@ -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.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.downloads.VideoLocal import com.futo.platformplayer.downloads.VideoLocal
class LocalVideoMuxedSourceDescriptor( class DownloadedVideoMuxedSourceDescriptor(
private val video: VideoLocal private val video: VideoLocal
) : VideoMuxedSourceDescriptor() { ) : VideoMuxedSourceDescriptor() {
override val videoSources: Array<IVideoSource> get() = video.videoSource.toTypedArray(); override val videoSources: Array<IVideoSource> get() = video.videoSource.toTypedArray();

View File

@ -13,7 +13,8 @@ class AudioUrlSource(
override val codec: String = "", override val codec: String = "",
override val language: String = Language.UNKNOWN, override val language: String = Language.UNKNOWN,
override val duration: Long? = null, override val duration: Long? = null,
override var priority: Boolean = false override var priority: Boolean = false,
override var original: Boolean = false
) : IAudioUrlSource, IStreamMetaDataSource{ ) : IAudioUrlSource, IStreamMetaDataSource{
override var streamMetaData: StreamMetaData? = null; override var streamMetaData: StreamMetaData? = null;
@ -36,7 +37,9 @@ class AudioUrlSource(
source.container, source.container,
source.codec, source.codec,
source.language, source.language,
source.duration source.duration,
source.priority,
source.original
); );
ret.streamMetaData = streamData; ret.streamMetaData = streamData;

View File

@ -27,6 +27,7 @@ class HLSVariantAudioUrlSource(
override val language: String, override val language: String,
override val duration: Long?, override val duration: Long?,
override val priority: Boolean, override val priority: Boolean,
override val original: Boolean,
val url: String val url: String
) : IAudioUrlSource { ) : IAudioUrlSource {
override fun getAudioUrl(): String { override fun getAudioUrl(): String {

View File

@ -8,4 +8,5 @@ interface IAudioSource {
val language : String; val language : String;
val duration : Long?; val duration : Long?;
val priority: Boolean; val priority: Boolean;
val original: Boolean;
} }

View File

@ -15,6 +15,7 @@ class LocalAudioSource : IAudioSource, IStreamMetaDataSource {
override val duration: Long? = null; override val duration: Long? = null;
override var priority: Boolean = false; override var priority: Boolean = false;
override val original: Boolean = false;
val filePath : String; val filePath : String;
val fileSize: Long; val fileSize: Long;

View File

@ -10,15 +10,18 @@ import com.futo.polycentric.core.combineHashCodes
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNames
import java.time.OffsetDateTime import java.time.OffsetDateTime
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
open class SerializedPlatformVideo( open class SerializedPlatformVideo(
override val contentType: ContentType = ContentType.MEDIA,
override val id: PlatformID, override val id: PlatformID,
override val name: String, override val name: String,
override val thumbnails: Thumbnails, override val thumbnails: Thumbnails,
override val author: PlatformAuthorLink, override val author: PlatformAuthorLink,
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
@JsonNames("datetime", "dateTime")
override val datetime: OffsetDateTime? = null, override val datetime: OffsetDateTime? = null,
override val url: String, override val url: String,
override val shareUrl: String = "", override val shareUrl: String = "",
@ -27,7 +30,6 @@ open class SerializedPlatformVideo(
override val viewCount: Long, override val viewCount: Long,
override val isShort: Boolean = false override val isShort: Boolean = false
) : IPlatformVideo, SerializedPlatformContent { ) : IPlatformVideo, SerializedPlatformContent {
override val contentType: ContentType = ContentType.MEDIA;
override val isLive: Boolean = false; override val isLive: Boolean = false;
@ -44,6 +46,7 @@ open class SerializedPlatformVideo(
companion object { companion object {
fun fromVideo(video: IPlatformVideo) : SerializedPlatformVideo { fun fromVideo(video: IPlatformVideo) : SerializedPlatformVideo {
return SerializedPlatformVideo( return SerializedPlatformVideo(
ContentType.MEDIA,
video.id, video.id,
video.name, video.name,
video.thumbnails, video.thumbnails,

View File

@ -21,6 +21,8 @@ open class JSAudioUrlSource : IAudioUrlSource, JSSource {
override var priority: Boolean = false; override var priority: Boolean = false;
override var original: Boolean = false;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_AUDIOURL, plugin, obj) { constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_AUDIOURL, plugin, obj) {
val contextName = "AudioUrlSource"; val contextName = "AudioUrlSource";
val config = plugin.config; val config = plugin.config;
@ -35,6 +37,7 @@ open class JSAudioUrlSource : IAudioUrlSource, JSSource {
name = _obj.getOrDefault(config, "name", contextName, "${container} ${bitrate}") ?: "${container} ${bitrate}"; name = _obj.getOrDefault(config, "name", contextName, "${container} ${bitrate}") ?: "${container} ${bitrate}";
priority = if(_obj.has("priority")) obj.getOrThrow(config, "priority", contextName) else false; 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 { override fun getAudioUrl() : String {

View File

@ -23,6 +23,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
override val bitrate: Int; override val bitrate: Int;
override val duration: Long; override val duration: Long;
override val priority: Boolean; override val priority: Boolean;
override var original: Boolean = false;
override val language: String; override val language: String;
@ -45,6 +46,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0; duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false; priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN; 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"); hasGenerate = _obj.has("generate");
} }

View File

@ -21,6 +21,7 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
override val language: String; override val language: String;
override var priority: Boolean = false; override var priority: Boolean = false;
override var original: Boolean = false;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) { constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) {
val contextName = "HLSAudioSource"; val contextName = "HLSAudioSource";
@ -32,6 +33,7 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
language = _obj.getOrThrow(config, "language", contextName); language = _obj.getOrThrow(config, "language", contextName);
priority = obj.getOrNull(config, "priority", contextName) ?: false; priority = obj.getOrNull(config, "priority", contextName) ?: false;
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
} }

View File

@ -0,0 +1,5 @@
package com.futo.platformplayer.api.media.platforms.local
class LocalClient {
//TODO
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}

View File

@ -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) * 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 * 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 onPagerChanged: Event1<IPager<T>>;
val onPagerError: Event1<Throwable>; val onPagerError: Event1<Throwable>;

View File

@ -1,5 +1,7 @@
package com.futo.platformplayer.api.media.structures 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 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. * 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 * This allows multiple Windows to exist of the same pager, without messing with position, or duplicate requests
*/ */
class ReusablePager<T>: INestedPager<T>, IPager<T> { open class ReusablePager<T>: INestedPager<T>, IReusablePager<T> {
private val _pager: IPager<T>; protected var _pager: IPager<T>;
val previousResults = arrayListOf<T>(); val previousResults = arrayListOf<T>();
constructor(subPager: IPager<T>) { constructor(subPager: IPager<T>) {
@ -44,7 +46,7 @@ class ReusablePager<T>: INestedPager<T>, IPager<T> {
return previousResults; return previousResults;
} }
fun getWindow(): Window<T> { override fun getWindow(): Window<T> {
return Window(this); return Window(this);
} }
@ -95,4 +97,118 @@ class ReusablePager<T>: INestedPager<T>, IPager<T> {
return ReusablePager(this); 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>;
} }

View File

@ -3,6 +3,7 @@ package com.futo.platformplayer.casting
import android.os.Looper import android.os.Looper
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.casting.models.FCastDecryptedMessage import com.futo.platformplayer.casting.models.FCastDecryptedMessage
import com.futo.platformplayer.casting.models.FCastEncryptedMessage import com.futo.platformplayer.casting.models.FCastEncryptedMessage
@ -32,6 +33,7 @@ import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.math.BigInteger import java.math.BigInteger
import java.net.Inet4Address
import java.net.InetAddress import java.net.InetAddress
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Socket import java.net.Socket

View File

@ -1,5 +1,6 @@
package com.futo.platformplayer.casting package com.futo.platformplayer.casting
import android.app.AlertDialog
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
@ -9,6 +10,7 @@ import android.util.Log
import android.util.Xml import android.util.Xml
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
@ -239,6 +241,9 @@ class StateCasting {
Logger.i(TAG, "CastingService stopped.") Logger.i(TAG, "CastingService stopped.")
} }
private val _castingDialogLock = Any();
private var _currentDialog: AlertDialog? = null;
@Synchronized @Synchronized
fun connectDevice(device: CastingDevice) { fun connectDevice(device: CastingDevice) {
if (activeDevice == device) if (activeDevice == device)
@ -272,10 +277,39 @@ class StateCasting {
invokeInMainScopeIfRequired { invokeInMainScopeIfRequired {
StateApp.withContext(false) { context -> StateApp.withContext(false) { context ->
context.let { context.let {
Logger.i(TAG, "Casting state changed to ${castConnectionState}");
when (castConnectionState) { when (castConnectionState) {
CastConnectionState.CONNECTED -> UIDialogs.toast(it, "Connected to device") CastConnectionState.CONNECTED -> {
CastConnectionState.CONNECTING -> UIDialogs.toast(it, "Connecting to device...") Logger.i(TAG, "Casting connected to [${device.name}]");
CastConnectionState.DISCONNECTED -> UIDialogs.toast(it, "Disconnected from device") UIDialogs.appToast("Connected to device")
synchronized(_castingDialogLock) {
if(_currentDialog != null) {
_currentDialog?.hide();
_currentDialog = null;
}
}
}
CastConnectionState.CONNECTING -> {
Logger.i(TAG, "Casting connecting to [${device.name}]");
UIDialogs.toast(it, "Connecting to device...")
synchronized(_castingDialogLock) {
if(_currentDialog == null) {
_currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true, "Connecting to [${device.name}]", "Make sure you are on the same network", null, -2,
UIDialogs.Action("Disconnect", {
device.stop();
}));
}
}
}
CastConnectionState.DISCONNECTED -> {
UIDialogs.toast(it, "Disconnected from device")
synchronized(_castingDialogLock) {
if(_currentDialog != null) {
_currentDialog?.hide();
_currentDialog = null;
}
}
}
} }
} }
}; };

View File

@ -73,11 +73,11 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
}; };
_rememberedAdapter.onConnect.subscribe { _ -> _rememberedAdapter.onConnect.subscribe { _ ->
dismiss() dismiss()
UIDialogs.showCastingDialog(context) //UIDialogs.showCastingDialog(context)
} }
_adapter.onConnect.subscribe { _ -> _adapter.onConnect.subscribe { _ ->
dismiss() dismiss()
UIDialogs.showCastingDialog(context) //UIDialogs.showCastingDialog(context)
} }
_recyclerRememberedDevices.adapter = _rememberedAdapter; _recyclerRememberedDevices.adapter = _rememberedAdapter;
_recyclerRememberedDevices.layoutManager = LinearLayoutManager(context); _recyclerRememberedDevices.layoutManager = LinearLayoutManager(context);

View File

@ -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.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.ratings.IRating 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.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.LocalVideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource 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.IHLSManifestSource
@ -57,7 +57,7 @@ class VideoLocal: IPlatformVideoDetails, IStoreItem {
override val video: IVideoSourceDescriptor get() = if(audioSource.isNotEmpty()) override val video: IVideoSourceDescriptor get() = if(audioSource.isNotEmpty())
LocalVideoUnMuxedSourceDescriptor(this) LocalVideoUnMuxedSourceDescriptor(this)
else else
LocalVideoMuxedSourceDescriptor(this); DownloadedVideoMuxedSourceDescriptor(this);
override val preview: IVideoSourceDescriptor? get() = videoSerialized.preview; override val preview: IVideoSourceDescriptor? get() = videoSerialized.preview;
override val live: IVideoSource? get() = videoSerialized.live; override val live: IVideoSource? get() = videoSerialized.live;

View File

@ -9,6 +9,7 @@ import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays 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.ResultCapabilities
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
@ -160,8 +161,14 @@ class ContentSearchResultsFragment : MainFragment() {
navigate<RemotePlaylistFragment>(it); navigate<RemotePlaylistFragment>(it);
else if(StatePlatform.instance.hasEnabledChannelClient(it)) else if(StatePlatform.instance.hasEnabledChannelClient(it))
navigate<ChannelFragment>(it); navigate<ChannelFragment>(it);
else else {
navigate<VideoDetailFragment>(it); val url = it;
activity?.let {
close()
if(it is MainActivity)
it.navigate(it.getFragment<VideoDetailFragment>(), url);
}
}
} }
else else
setQuery(it, true); setQuery(it, true);

View File

@ -21,6 +21,8 @@ import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists 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.toHumanBytesSize
import com.futo.platformplayer.toHumanDuration import com.futo.platformplayer.toHumanDuration
import com.futo.platformplayer.views.AnyInsertedAdapterView import com.futo.platformplayer.views.AnyInsertedAdapterView
@ -103,12 +105,15 @@ class DownloadsFragment : MainFragment() {
private val _listDownloaded: AnyInsertedAdapterView<VideoLocal, VideoDownloadViewHolder>; private val _listDownloaded: AnyInsertedAdapterView<VideoLocal, VideoDownloadViewHolder>;
private var lastDownloads: List<VideoLocal>? = null; 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()) { constructor(frag: DownloadsFragment, inflater: LayoutInflater): super(frag.requireContext()) {
inflater.inflate(R.layout.fragment_downloads, this); inflater.inflate(R.layout.fragment_downloads, this);
_frag = frag; _frag = frag;
if(ordering.value.isNullOrBlank())
ordering.value = "nameAsc";
_usageUsed = findViewById(R.id.downloads_usage_used); _usageUsed = findViewById(R.id.downloads_usage_used);
_usageAvailable = findViewById(R.id.downloads_usage_available); _usageAvailable = findViewById(R.id.downloads_usage_available);
_usageProgress = findViewById(R.id.downloads_usage_progress); _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 { spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.downloads_sortby_array)).also {
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
}; };
spinnerSortBy.setSelection(0); val options = listOf("nameAsc", "nameDesc", "downloadDateAsc", "downloadDateDesc", "releasedAsc", "releasedDesc");
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
when(pos) { when(pos) {
0 -> ordering = "nameAsc" 0 -> ordering.setAndSave("nameAsc")
1 -> ordering = "nameDesc" 1 -> ordering.setAndSave("nameDesc")
2 -> ordering = "downloadDateAsc" 2 -> ordering.setAndSave("downloadDateAsc")
3 -> ordering = "downloadDateDesc" 3 -> ordering.setAndSave("downloadDateDesc")
4 -> ordering = "releasedAsc" 4 -> ordering.setAndSave("releasedAsc")
5 -> ordering = "releasedDesc" 5 -> ordering.setAndSave("releasedDesc")
else -> ordering = null else -> ordering.setAndSave("")
} }
updateContentFilters() updateContentFilters()
} }
override fun onNothingSelected(parent: AdapterView<*>?) = Unit override fun onNothingSelected(parent: AdapterView<*>?) = Unit
}; };
spinnerSortBy.setSelection(Math.max(0, options.indexOf(ordering.value)));
_listDownloaded = findViewById<RecyclerView>(R.id.list_downloaded) _listDownloaded = findViewById<RecyclerView>(R.id.list_downloaded)
.asAnyWithTop(findViewById(R.id.downloads_top)) { .asAnyWithTop(findViewById(R.id.downloads_top)) {
@ -229,9 +235,9 @@ class DownloadsFragment : MainFragment() {
fun filterDownloads(vids: List<VideoLocal>): List<VideoLocal>{ fun filterDownloads(vids: List<VideoLocal>): List<VideoLocal>{
var vidsToReturn = vids; var vidsToReturn = vids;
if(!_listDownloadSearch.text.isNullOrEmpty()) if(!_listDownloadSearch.text.isNullOrEmpty())
vidsToReturn = vids.filter { it.name.contains(_listDownloadSearch.text, true) }; vidsToReturn = vids.filter { it.name.contains(_listDownloadSearch.text, true) || it.author.name.contains(_listDownloadSearch.text, true) };
if(!ordering.isNullOrEmpty()) { if(!ordering.value.isNullOrEmpty()) {
vidsToReturn = when(ordering){ vidsToReturn = when(ordering.value){
"downloadDateAsc" -> vidsToReturn.sortedBy { it.downloadDate ?: OffsetDateTime.MAX }; "downloadDateAsc" -> vidsToReturn.sortedBy { it.downloadDate ?: OffsetDateTime.MAX };
"downloadDateDesc" -> vidsToReturn.sortedByDescending { it.downloadDate ?: OffsetDateTime.MIN }; "downloadDateDesc" -> vidsToReturn.sortedByDescending { it.downloadDate ?: OffsetDateTime.MIN };
"nameAsc" -> vidsToReturn.sortedBy { it.name.lowercase() } "nameAsc" -> vidsToReturn.sortedBy { it.name.lowercase() }

View File

@ -3,12 +3,15 @@ package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Color import android.graphics.Color
import android.util.DisplayMetrics
import android.view.Display
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.* import android.widget.*
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.LayoutManager import androidx.recyclerview.widget.RecyclerView.LayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout 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.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.others.ProgressBar import com.futo.platformplayer.views.others.ProgressBar
import com.futo.platformplayer.views.others.TagsView 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 com.futo.platformplayer.views.announcements.AnnouncementView
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.OffsetDateTime import java.time.OffsetDateTime
import kotlin.math.max import kotlin.math.max
@ -68,6 +74,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
private val _scrollListener: RecyclerView.OnScrollListener; private val _scrollListener: RecyclerView.OnScrollListener;
private var _automaticNextPageCounter = 0; 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) { constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<TViewHolder>, GridLayoutManager, TPager, TResult, TConverted, InsertedViewHolder<TViewHolder>>? = null) : super(inflater.context) {
this.fragment = fragment; this.fragment = fragment;
@ -129,6 +136,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
_toolbarContentView = findViewById(R.id.container_toolbar_content); _toolbarContentView = findViewById(R.id.container_toolbar_content);
_nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, { _nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, {
if (it is IAsyncPager<*>) if (it is IAsyncPager<*>)
it.nextPageAsync(); it.nextPageAsync();
else else
@ -182,29 +190,61 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) { private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
val canScroll = if (recyclerData.results.isEmpty()) false else { val canScroll = if (recyclerData.results.isEmpty()) false else {
val height = resources.displayMetrics.heightPixels;
val layoutManager = recyclerData.layoutManager val layoutManager = recyclerData.layoutManager
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition() val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
val firstVisibleItemView = if(firstVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(firstVisibleItemPosition) else null;
if (firstVisibleItemPosition != RecyclerView.NO_POSITION) { val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition();
val firstVisibleView = layoutManager.findViewByPosition(firstVisibleItemPosition) val lastVisibleItemView = if(lastVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(lastVisibleItemPosition) else null;
val itemHeight = firstVisibleView?.height ?: 0 val rows = if(recyclerData.layoutManager is GridLayoutManager) Math.max(1, recyclerData.results.size / recyclerData.layoutManager.spanCount) else 1;
val occupiedSpace = recyclerData.results.size / recyclerData.layoutManager.spanCount * itemHeight val rowsHeight = (firstVisibleItemView?.height ?: 0) * rows;
val recyclerViewHeight = _recyclerResults.height if(lastVisibleItemView != null && lastVisibleItemPosition == (recyclerData.results.size - 1)) {
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage occupiedSpace=$occupiedSpace recyclerViewHeight=$recyclerViewHeight") false;
occupiedSpace >= recyclerViewHeight }
else if (firstVisibleItemView != null && height != null && rowsHeight < height) {
false;
} else { } else {
false true;
} }
} }
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter") Logger.i(TAG, "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter")
if (!canScroll || filteredResults.isEmpty()) { if (!canScroll || filteredResults.isEmpty()) {
_automaticNextPageCounter++ _automaticNextPageCounter++
if(_automaticNextPageCounter <= 4) if(_automaticNextPageCounter < _automaticBackoff.size) {
loadNextPage() 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 { } else {
Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset");
_automaticNextPageCounter = 0; _automaticNextPageCounter = 0;
} }
} }
fun resetAutomaticNextPageCounter(){
_automaticNextPageCounter = 0;
}
protected fun setTextCentered(text: String?) { protected fun setTextCentered(text: String?) {
_textCentered.text = text; _textCentered.text = text;

View File

@ -5,29 +5,38 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.allViews
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.UISlideOverlays.Companion.showOrderOverlay
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager 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.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptExecutionException import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlatform 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.FeedStyle
import com.futo.platformplayer.views.NoResultsView import com.futo.platformplayer.views.NoResultsView
import com.futo.platformplayer.views.ToggleBar import com.futo.platformplayer.views.ToggleBar
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.InsertedViewHolder import com.futo.platformplayer.views.adapters.InsertedViewHolder
import com.futo.platformplayer.views.announcements.AnnouncementView
import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.buttons.BigButton
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import java.time.OffsetDateTime import java.time.OffsetDateTime
@ -39,6 +48,12 @@ class HomeFragment : MainFragment() {
private var _view: HomeView? = null; private var _view: HomeView? = null;
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = 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() { fun reloadFeed() {
_view?.reloadFeed() _view?.reloadFeed()
@ -64,7 +79,7 @@ class HomeFragment : MainFragment() {
} }
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { 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; _view = view;
return view; return view;
} }
@ -82,6 +97,7 @@ class HomeFragment : MainFragment() {
val view = _view; val view = _view;
if (view != null) { if (view != null) {
_cachedRecyclerData = view.recyclerData; _cachedRecyclerData = view.recyclerData;
_cachedLastPager = view.lastPager;
view.cleanup(); view.cleanup();
_view = null; _view = null;
} }
@ -91,6 +107,7 @@ class HomeFragment : MainFragment() {
_view?.setPreviewsEnabled(previewsEnabled && Settings.instance.home.previewFeedItems); _view?.setPreviewsEnabled(previewsEnabled && Settings.instance.home.previewFeedItems);
} }
@SuppressLint("ViewConstructor") @SuppressLint("ViewConstructor")
class HomeView : ContentFeedView<HomeFragment> { class HomeView : ContentFeedView<HomeFragment> {
override val feedStyle: FeedStyle get() = Settings.instance.home.getHomeFeedStyle(); override val feedStyle: FeedStyle get() = Settings.instance.home.getHomeFeedStyle();
@ -100,11 +117,22 @@ class HomeFragment : MainFragment() {
private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>; private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>;
override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar 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 }, { _taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({ fragment.lifecycleScope }, {
StatePlatform.instance.getHomeRefresh(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<ScriptCaptchaRequiredException> { }
.exception<ScriptExecutionException> { .exception<ScriptExecutionException> {
Logger.w(ChannelFragment.TAG, "Plugin failure.", it); Logger.w(ChannelFragment.TAG, "Plugin failure.", it);
@ -207,22 +235,94 @@ class HomeFragment : MainFragment() {
} }
private val _filterLock = Object(); private val _filterLock = Object();
private var _toggleRecent = false; private var _togglesConfig = FragmentedStorage.get<StringArrayStorage>("home_toggles");
fun initializeToolbarContent() { fun initializeToolbarContent() {
//Not stable enough with current viewport paging, doesn't work with less results, and reloads content instead of just re-filtering existing if(_toolbarContentView.allViews.any { it is ToggleBar })
/* _toolbarContentView.removeView(_toolbarContentView.allViews.find { it is ToggleBar });
_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) }
)
}
_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> { override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
@ -232,7 +332,11 @@ class HomeFragment : MainFragment() {
if(StateMeta.instance.isCreatorHidden(it.author.url)) if(StateMeta.instance.isCreatorHidden(it.author.url))
return@filter false; 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; return@filter false;
} }

View File

@ -326,6 +326,10 @@ class PlaylistFragment : MainFragment() {
playlist.videos = ArrayList(playlist.videos.filter { it != video }); playlist.videos = ArrayList(playlist.videos.filter { it != video });
StatePlaylists.instance.createOrUpdatePlaylist(playlist); StatePlaylists.instance.createOrUpdatePlaylist(playlist);
} }
override fun onVideoOptions(video: IPlatformVideo) {
UISlideOverlays.showVideoOptionsOverlay(video, overlayContainer);
}
override fun onVideoClicked(video: IPlatformVideo) { override fun onVideoClicked(video: IPlatformVideo) {
val playlist = _playlist; val playlist = _playlist;
if (playlist != null) { if (playlist != null) {

View File

@ -6,12 +6,17 @@ import android.util.TypedValue
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.Spinner
import android.widget.TextView import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView 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.models.Playlist
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists 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.adapters.*
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.OffsetDateTime
class PlaylistsFragment : MainFragment() { class PlaylistsFragment : MainFragment() {
@ -65,6 +74,7 @@ class PlaylistsFragment : MainFragment() {
private val _fragment: PlaylistsFragment; private val _fragment: PlaylistsFragment;
var watchLater: ArrayList<IPlatformVideo> = arrayListOf(); var watchLater: ArrayList<IPlatformVideo> = arrayListOf();
var allPlaylists: ArrayList<Playlist> = arrayListOf();
var playlists: ArrayList<Playlist> = arrayListOf(); var playlists: ArrayList<Playlist> = arrayListOf();
private var _appBar: AppBarLayout; private var _appBar: AppBarLayout;
private var _adapterWatchLater: VideoListHorizontalAdapter; private var _adapterWatchLater: VideoListHorizontalAdapter;
@ -72,12 +82,20 @@ class PlaylistsFragment : MainFragment() {
private var _layoutWatchlist: ConstraintLayout; private var _layoutWatchlist: ConstraintLayout;
private var _slideUpOverlay: SlideUpMenuOverlay? = null; 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) { constructor(fragment: PlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) {
_fragment = fragment; _fragment = fragment;
inflater.inflate(R.layout.fragment_playlists, this); inflater.inflate(R.layout.fragment_playlists, this);
_listPlaylistsSearch = findViewById(R.id.playlists_search);
watchLater = ArrayList(); watchLater = ArrayList();
playlists = ArrayList(); playlists = ArrayList();
allPlaylists = ArrayList();
val recyclerWatchLater = findViewById<RecyclerView>(R.id.recycler_watch_later); val recyclerWatchLater = findViewById<RecyclerView>(R.id.recycler_watch_later);
@ -105,6 +123,7 @@ class PlaylistsFragment : MainFragment() {
buttonCreatePlaylist.setOnClickListener { buttonCreatePlaylist.setOnClickListener {
_slideUpOverlay = UISlideOverlays.showCreatePlaylistOverlay(findViewById<FrameLayout>(R.id.overlay_create_playlist)) { _slideUpOverlay = UISlideOverlays.showCreatePlaylistOverlay(findViewById<FrameLayout>(R.id.overlay_create_playlist)) {
val playlist = Playlist(it, arrayListOf()); val playlist = Playlist(it, arrayListOf());
allPlaylists.add(0, playlist);
playlists.add(0, playlist); playlists.add(0, playlist);
StatePlaylists.instance.createOrUpdatePlaylist(playlist); StatePlaylists.instance.createOrUpdatePlaylist(playlist);
@ -120,6 +139,35 @@ class PlaylistsFragment : MainFragment() {
_appBar = findViewById(R.id.app_bar); _appBar = findViewById(R.id.app_bar);
_layoutWatchlist = findViewById(R.id.layout_watchlist); _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)); }; findViewById<TextView>(R.id.text_view_all).setOnClickListener { _fragment.navigate<WatchLaterFragment>(context.getString(R.string.watch_later)); };
StatePlaylists.instance.onWatchLaterChanged.subscribe(this) { StatePlaylists.instance.onWatchLaterChanged.subscribe(this) {
fragment.lifecycleScope.launch(Dispatchers.Main) { fragment.lifecycleScope.launch(Dispatchers.Main) {
@ -134,10 +182,12 @@ class PlaylistsFragment : MainFragment() {
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
fun onShown() { fun onShown() {
allPlaylists.clear();
playlists.clear() playlists.clear()
playlists.addAll( allPlaylists.addAll(
StatePlaylists.instance.getPlaylists().sortedByDescending { maxOf(it.datePlayed, it.dateUpdate, it.dateCreation) } StatePlaylists.instance.getPlaylists().sortedByDescending { maxOf(it.datePlayed, it.dateUpdate, it.dateCreation) }
); );
playlists.addAll(filterPlaylists(allPlaylists));
_adapterPlaylist.notifyDataSetChanged(); _adapterPlaylist.notifyDataSetChanged();
updateWatchLater(); updateWatchLater();
@ -157,6 +207,32 @@ class PlaylistsFragment : MainFragment() {
return false; 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() { private fun updateWatchLater() {
val watchList = StatePlaylists.instance.getWatchLater(); val watchList = StatePlaylists.instance.getWatchLater();
if (watchList.isNotEmpty()) { if (watchList.isNotEmpty()) {
@ -164,7 +240,7 @@ class PlaylistsFragment : MainFragment() {
_appBar.let { appBar -> _appBar.let { appBar ->
val layoutParams: CoordinatorLayout.LayoutParams = appBar.layoutParams as CoordinatorLayout.LayoutParams; 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; appBar.layoutParams = layoutParams;
} }
} else { } else {
@ -172,7 +248,7 @@ class PlaylistsFragment : MainFragment() {
_appBar.let { appBar -> _appBar.let { appBar ->
val layoutParams: CoordinatorLayout.LayoutParams = appBar.layoutParams as CoordinatorLayout.LayoutParams; 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; appBar.layoutParams = layoutParams;
}; };
} }

View File

@ -256,6 +256,8 @@ class SubscriptionGroupFragment : MainFragment() {
val sub = StateSubscriptions.instance.getSubscription(sub) ?: StateSubscriptions.instance.getSubscriptionOther(sub); val sub = StateSubscriptions.instance.getSubscription(sub) ?: StateSubscriptions.instance.getSubscriptionOther(sub);
if(sub != null && sub.channel.thumbnail != null) { if(sub != null && sub.channel.thumbnail != null) {
g.image = ImageVariable.fromUrl(sub.channel.thumbnail!!); g.image = ImageVariable.fromUrl(sub.channel.thumbnail!!);
if(g.image != null)
g.image!!.subscriptionUrl = sub.channel.url;
g.image?.setImageView(_imageGroup); g.image?.setImageView(_imageGroup);
g.image?.setImageView(_imageGroupBackground); g.image?.setImageView(_imageGroupBackground);
break; break;

View File

@ -18,6 +18,7 @@ import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.exceptions.ChannelException import com.futo.platformplayer.exceptions.ChannelException
import com.futo.platformplayer.exceptions.RateLimitException 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.logging.Logger
import com.futo.platformplayer.models.SearchType import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.models.SubscriptionGroup
@ -56,6 +57,9 @@ class SubscriptionsFeedFragment : MainFragment() {
private var _group: SubscriptionGroup? = null; private var _group: SubscriptionGroup? = null;
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = 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) { override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack); super.onShownWithView(parameter, isBack);
_view?.onShown(); _view?.onShown();
@ -184,8 +188,6 @@ class SubscriptionsFeedFragment : MainFragment() {
return Json.encodeToString(this); return Json.encodeToString(this);
} }
} }
private val _filterLock = Object();
private val _filterSettings = FragmentedStorage.get<FeedFilterSettings>("subFeedFilter");
private var _bypassRateLimit = false; private var _bypassRateLimit = false;
private val _lastExceptions: List<Throwable>? = null; private val _lastExceptions: List<Throwable>? = null;
@ -284,13 +286,18 @@ class SubscriptionsFeedFragment : MainFragment() {
fragment.navigate<SubscriptionGroupFragment>(g); fragment.navigate<SubscriptionGroupFragment>(g);
}; };
synchronized(_filterLock) { synchronized(fragment._filterLock) {
_subscriptionBar?.setToggles( _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.videos), fragment._filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { view, active ->
SubscriptionBar.Toggle(context.getString(R.string.posts), _filterSettings.allowContentTypes.contains(ContentType.POST)) { toggleFilterContentType(ContentType.POST, it); }, toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), active); },
SubscriptionBar.Toggle(context.getString(R.string.live), _filterSettings.allowLive) { _filterSettings.allowLive = it; _filterSettings.save(); loadResults(false); }, SubscriptionBar.Toggle(context.getString(R.string.posts), fragment._filterSettings.allowContentTypes.contains(ContentType.POST)) { view, active ->
SubscriptionBar.Toggle(context.getString(R.string.planned), _filterSettings.allowPlanned) { _filterSettings.allowPlanned = it; _filterSettings.save(); loadResults(false); }, toggleFilterContentType(ContentType.POST, active); },
SubscriptionBar.Toggle(context.getString(R.string.watched), _filterSettings.allowWatched) { _filterSettings.allowWatched = it; _filterSettings.save(); loadResults(false); } 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); toggleFilterContentType(contentType, isTrue);
} }
private fun toggleFilterContentType(contentType: ContentType, isTrue: Boolean) { private fun toggleFilterContentType(contentType: ContentType, isTrue: Boolean) {
synchronized(_filterLock) { synchronized(fragment._filterLock) {
if(!isTrue) { if(!isTrue) {
_filterSettings.allowContentTypes.remove(contentType); fragment._filterSettings.allowContentTypes.remove(contentType);
} else if(!_filterSettings.allowContentTypes.contains(contentType)) { } else if(!fragment._filterSettings.allowContentTypes.contains(contentType)) {
_filterSettings.allowContentTypes.add(contentType) fragment._filterSettings.allowContentTypes.add(contentType)
} }
_filterSettings.save(); fragment._filterSettings.save();
}; };
if(Settings.instance.subscriptions.fetchOnTabOpen) { //TODO: Do this different, temporary workaround if(Settings.instance.subscriptions.fetchOnTabOpen) { //TODO: Do this different, temporary workaround
loadResults(false); loadResults(false);
@ -320,9 +327,9 @@ class SubscriptionsFeedFragment : MainFragment() {
val nowSoon = OffsetDateTime.now().plusMinutes(5); val nowSoon = OffsetDateTime.now().plusMinutes(5);
val filterGroup = subGroup; val filterGroup = subGroup;
return results.filter { 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; return@filter false;
//TODO: Check against a sub cache //TODO: Check against a sub cache
@ -331,11 +338,11 @@ class SubscriptionsFeedFragment : MainFragment() {
if(it.datetime?.isAfter(nowSoon) == true) { if(it.datetime?.isAfter(nowSoon) == true) {
if(!_filterSettings.allowPlanned) if(!fragment._filterSettings.allowPlanned)
return@filter false; 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) if(it is IPlatformVideo && it.isLive)
return@filter true; return@filter true;
} }

View File

@ -9,6 +9,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@ -122,8 +123,14 @@ class SuggestionsFragment : MainFragment {
navigate<RemotePlaylistFragment>(it); navigate<RemotePlaylistFragment>(it);
else if(StatePlatform.instance.hasEnabledChannelClient(it)) else if(StatePlatform.instance.hasEnabledChannelClient(it))
navigate<ChannelFragment>(it); navigate<ChannelFragment>(it);
else else {
navigate<VideoDetailFragment>(it); val url = it;
activity?.let {
close()
if(it is MainActivity)
it.navigate(it.getFragment<VideoDetailFragment>(), url);
}
}
} }
else else
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl)); navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl));

View File

@ -132,6 +132,7 @@ import com.futo.platformplayer.views.behavior.TouchInterceptFrameLayout
import com.futo.platformplayer.views.casting.CastView import com.futo.platformplayer.views.casting.CastView
import com.futo.platformplayer.views.comments.AddCommentView import com.futo.platformplayer.views.comments.AddCommentView
import com.futo.platformplayer.views.others.CreatorThumbnail 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.DescriptionOverlay
import com.futo.platformplayer.views.overlays.LiveChatOverlay import com.futo.platformplayer.views.overlays.LiveChatOverlay
import com.futo.platformplayer.views.overlays.QueueEditorOverlay 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.RoundButton
import com.futo.platformplayer.views.pills.RoundButtonGroup import com.futo.platformplayer.views.pills.RoundButtonGroup
import com.futo.platformplayer.views.platform.PlatformIndicator 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.segments.CommentsList
import com.futo.platformplayer.views.subscriptions.SubscribeButton import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.platformplayer.views.video.FutoVideoPlayer import com.futo.platformplayer.views.video.FutoVideoPlayer
@ -195,6 +197,8 @@ class VideoDetailView : ConstraintLayout {
private var _liveChat: LiveChatManager? = null; private var _liveChat: LiveChatManager? = null;
private var _videoResumePositionMilliseconds : Long = 0L; private var _videoResumePositionMilliseconds : Long = 0L;
private var _chapters: List<IChapter>? = null;
private val _player: FutoVideoPlayer; private val _player: FutoVideoPlayer;
private val _cast: CastView; private val _cast: CastView;
private val _playerProgress: PlayerControlView; private val _playerProgress: PlayerControlView;
@ -263,6 +267,7 @@ class VideoDetailView : ConstraintLayout {
private val _container_content_liveChat: LiveChatOverlay; private val _container_content_liveChat: LiveChatOverlay;
private val _container_content_browser: WebviewOverlay; private val _container_content_browser: WebviewOverlay;
private val _container_content_support: SupportOverlay; private val _container_content_support: SupportOverlay;
private val _container_content_chapters: ChaptersOverlay;
private var _container_content_current: View; private var _container_content_current: View;
@ -374,6 +379,7 @@ class VideoDetailView : ConstraintLayout {
_container_content_liveChat = findViewById(R.id.videodetail_container_livechat); _container_content_liveChat = findViewById(R.id.videodetail_container_livechat);
_container_content_support = findViewById(R.id.videodetail_container_support); _container_content_support = findViewById(R.id.videodetail_container_support);
_container_content_browser = findViewById(R.id.videodetail_container_webview) _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); _addCommentView = findViewById(R.id.add_comment_view);
_commentsList = findViewById(R.id.comments_list); _commentsList = findViewById(R.id.comments_list);
@ -398,6 +404,10 @@ class VideoDetailView : ConstraintLayout {
_monetization = findViewById(R.id.monetization); _monetization = findViewById(R.id.monetization);
_player.attachPlayer(); _player.attachPlayer();
_player.onChapterClicked.subscribe {
showChaptersUI();
};
_buttonSubscribe.onSubscribed.subscribe { _buttonSubscribe.onSubscribed.subscribe {
_slideUpOverlay = UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer); _slideUpOverlay = UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
@ -683,9 +693,17 @@ class VideoDetailView : ConstraintLayout {
_container_content_description.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_description.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_liveChat.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.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_replies.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_support.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_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 { _description_viewMore.setOnClickListener {
switchContentView(_container_content_description); switchContentView(_container_content_description);
@ -852,6 +870,22 @@ class VideoDetailView : ConstraintLayout {
_cast.stopAllGestures(); _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() { fun updateMoreButtons() {
val isLimitedVersion = video?.url != null && StatePlatform.instance.getContentClientOrNull(video!!.url)?.let { val isLimitedVersion = video?.url != null && StatePlatform.instance.getContentClientOrNull(video!!.url)?.let {
if (it is JSClient) 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) if(video?.isLive ?: false)
RoundButton(context, R.drawable.ic_chat, context.getString(R.string.live_chat), TAG_LIVECHAT) { RoundButton(context, R.drawable.ic_chat, context.getString(R.string.live_chat), TAG_LIVECHAT) {
video?.let { video?.let {
@ -1340,10 +1381,12 @@ class VideoDetailView : ConstraintLayout {
val chapters = null ?: StatePlatform.instance.getContentChapters(video.url); val chapters = null ?: StatePlatform.instance.getContentChapters(video.url);
_player.setChapters(chapters); _player.setChapters(chapters);
_cast.setChapters(chapters); _cast.setChapters(chapters);
_chapters = _player.getChapters();
} catch (ex: Throwable) { } catch (ex: Throwable) {
Logger.e(TAG, "Failed to get chapters", ex); Logger.e(TAG, "Failed to get chapters", ex);
_player.setChapters(null); _player.setChapters(null);
_cast.setChapters(null); _cast.setChapters(null);
_chapters = null;
/*withContext(Dispatchers.Main) { /*withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to get chapters\n" + ex.message); 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; else null;
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
video = newDetails; video = newDetails;
_player.setSource(newVideoSource, newAudioSource, true, true); _player.setSource(newVideoSource, newAudioSource, true, true, true);
} }
} }
} catch (e: Throwable) { } catch (e: Throwable) {
@ -2601,7 +2648,10 @@ class VideoDetailView : ConstraintLayout {
} }
onChannelClicked.subscribe { onChannelClicked.subscribe {
fragment.navigate<ChannelFragment>(it) if(it.url.isNotBlank())
fragment.navigate<ChannelFragment>(it)
else
UIDialogs.appToast("No author url present");
} }
onAddToWatchLaterClicked.subscribe(this) { onAddToWatchLaterClicked.subscribe(this) {
@ -3077,6 +3127,7 @@ class VideoDetailView : ConstraintLayout {
const val TAG_SHARE = "share"; const val TAG_SHARE = "share";
const val TAG_OVERLAY = "overlay"; const val TAG_OVERLAY = "overlay";
const val TAG_LIVECHAT = "livechat"; const val TAG_LIVECHAT = "livechat";
const val TAG_CHAPTERS = "chapters";
const val TAG_OPEN = "open"; const val TAG_OPEN = "open";
const val TAG_SEND_TO_DEVICE = "send_to_device"; const val TAG_SEND_TO_DEVICE = "send_to_device";
const val TAG_MORE = "MORE"; const val TAG_MORE = "MORE";

View File

@ -9,6 +9,7 @@ import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.core.view.isVisible
import androidx.core.view.setPadding import androidx.core.view.setPadding
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.futo.platformplayer.R 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.states.StatePlaylists
import com.futo.platformplayer.toHumanDuration import com.futo.platformplayer.toHumanDuration
import com.futo.platformplayer.toHumanTime import com.futo.platformplayer.toHumanTime
import com.futo.platformplayer.views.SearchView
import com.futo.platformplayer.views.lists.VideoListEditorView import com.futo.platformplayer.views.lists.VideoListEditorView
abstract class VideoListEditorView : LinearLayout { abstract class VideoListEditorView : LinearLayout {
@ -37,9 +39,15 @@ abstract class VideoListEditorView : LinearLayout {
protected var _buttonExport: ImageButton; protected var _buttonExport: ImageButton;
private var _buttonShare: ImageButton; private var _buttonShare: ImageButton;
private var _buttonEdit: ImageButton; private var _buttonEdit: ImageButton;
private var _buttonSearch: ImageButton;
private var _search: SearchView;
private var _onShare: (()->Unit)? = null; private var _onShare: (()->Unit)? = null;
private var _loadedVideos: List<IPlatformVideo>? = null;
private var _loadedVideosCanEdit: Boolean = false;
constructor(inflater: LayoutInflater) : super(inflater.context) { constructor(inflater: LayoutInflater) : super(inflater.context) {
inflater.inflate(R.layout.fragment_video_list_editor, this); inflater.inflate(R.layout.fragment_video_list_editor, this);
@ -57,6 +65,26 @@ abstract class VideoListEditorView : LinearLayout {
_buttonDownload.visibility = View.GONE; _buttonDownload.visibility = View.GONE;
_buttonExport = findViewById(R.id.button_export); _buttonExport = findViewById(R.id.button_export);
_buttonExport.visibility = View.GONE; _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); _buttonShare = findViewById(R.id.button_share);
val onShare = _onShare; val onShare = _onShare;
@ -76,6 +104,7 @@ abstract class VideoListEditorView : LinearLayout {
videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged); videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged);
videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved); videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved);
videoListEditorView.onVideoOptions.subscribe(::onVideoOptions);
videoListEditorView.onVideoClicked.subscribe(::onVideoClicked); videoListEditorView.onVideoClicked.subscribe(::onVideoClicked);
_videoListEditorView = videoListEditorView; _videoListEditorView = videoListEditorView;
@ -94,6 +123,7 @@ abstract class VideoListEditorView : LinearLayout {
open fun onShuffleClick() { } open fun onShuffleClick() { }
open fun onEditClick() { } open fun onEditClick() { }
open fun onVideoRemoved(video: IPlatformVideo) {} open fun onVideoRemoved(video: IPlatformVideo) {}
open fun onVideoOptions(video: IPlatformVideo) {}
open fun onVideoOrderChanged(videos : List<IPlatformVideo>) {} open fun onVideoOrderChanged(videos : List<IPlatformVideo>) {}
open fun onVideoClicked(video: IPlatformVideo) { open fun onVideoClicked(video: IPlatformVideo) {
@ -171,9 +201,22 @@ abstract class VideoListEditorView : LinearLayout {
.load(R.drawable.placeholder_video_thumbnail) .load(R.drawable.placeholder_video_thumbnail)
.into(_imagePlaylistThumbnail) .into(_imagePlaylistThumbnail)
} }
_loadedVideos = videos;
_loadedVideosCanEdit = canEdit;
_videoListEditorView.setVideos(videos, 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) { protected fun setButtonDownloadVisible(isVisible: Boolean) {
_buttonDownload.visibility = if (isVisible) View.VISIBLE else View.GONE; _buttonDownload.visibility = if (isVisible) View.VISIBLE else View.GONE;

View File

@ -103,6 +103,9 @@ class WatchLaterFragment : MainFragment() {
StatePlaylists.instance.removeFromWatchLater(video, true); StatePlaylists.instance.removeFromWatchLater(video, true);
} }
} }
override fun onVideoOptions(video: IPlatformVideo) {
UISlideOverlays.showVideoOptionsOverlay(video, overlayContainer);
}
override fun onVideoClicked(video: IPlatformVideo) { override fun onVideoClicked(video: IPlatformVideo) {
val watchLater = StatePlaylists.instance.getWatchLater(); val watchLater = StatePlaylists.instance.getWatchLater();

View File

@ -9,6 +9,7 @@ import androidx.media3.datasource.ResolvingDataSource
import androidx.media3.exoplayer.dash.DashMediaSource import androidx.media3.exoplayer.dash.DashMediaSource
import androidx.media3.exoplayer.dash.manifest.DashManifestParser import androidx.media3.exoplayer.dash.manifest.DashManifestParser
import androidx.media3.exoplayer.source.MediaSource 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.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource 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); 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 }) { val languageToFilter = if(preferredLanguage != null && altSources.any { it.language == preferredLanguage }) {
preferredLanguage preferredLanguage
} else { } else {
if(altSources.any { it.language == Language.ENGLISH }) if(altSources.any { it.language == Language.ENGLISH })
Language.ENGLISH Language.ENGLISH;
else else
Language.UNKNOWN; Language.UNKNOWN;
} }
@ -208,5 +214,38 @@ class VideoHelper {
} }
else return 0; 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;
}
}
} }
} }

View File

@ -3,6 +3,7 @@ package com.futo.platformplayer.models
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnails 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.SerializedPlatformVideo
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import java.time.LocalDateTime import java.time.LocalDateTime
@ -46,6 +47,7 @@ class HistoryVideo {
val name = str.substring(indexNext + 3); val name = str.substring(indexNext + 3);
val video = resolve?.invoke(url) ?: SerializedPlatformVideo( val video = resolve?.invoke(url) ?: SerializedPlatformVideo(
ContentType.MEDIA,
id = PlatformID.asUrlID(url), id = PlatformID.asUrlID(url),
name = name, name = name,
thumbnails = Thumbnails(), thumbnails = Thumbnails(),

View File

@ -7,6 +7,8 @@ import android.widget.ImageView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.futo.platformplayer.PresetImages import com.futo.platformplayer.PresetImages
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateSubscriptions
import kotlinx.serialization.Contextual import kotlinx.serialization.Contextual
import kotlinx.serialization.Transient import kotlinx.serialization.Transient
import java.io.File import java.io.File
@ -18,7 +20,8 @@ data class ImageVariable(
@Transient @Transient
@Contextual @Contextual
private val bitmap: Bitmap? = null, private val bitmap: Bitmap? = null,
val presetName: String? = null) { val presetName: String? = null,
var subscriptionUrl: String? = null) {
@SuppressLint("DiscouragedApi") @SuppressLint("DiscouragedApi")
fun setImageView(imageView: ImageView, fallbackResId: Int = -1) { fun setImageView(imageView: ImageView, fallbackResId: Int = -1) {
@ -33,6 +36,12 @@ data class ImageVariable(
} else if(!url.isNullOrEmpty()) { } else if(!url.isNullOrEmpty()) {
Glide.with(imageView) Glide.with(imageView)
.load(url) .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) .placeholder(R.drawable.placeholder_channel_thumbnail)
.into(imageView); .into(imageView);
} else if(!presetName.isNullOrEmpty()) { } else if(!presetName.isNullOrEmpty()) {
@ -63,7 +72,13 @@ data class ImageVariable(
return ImageVariable(null, null, null, str); return ImageVariable(null, null, null, str);
} }
fun fromFile(file: File): ImageVariable { 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);
}
} }
} }
} }

View File

@ -119,7 +119,7 @@ class HLS {
return if (source is IHLSManifestSource) { return if (source is IHLSManifestSource) {
listOf() listOf()
} else if (source is IHLSManifestAudioSource) { } 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 { } else {
throw NotImplementedError() throw NotImplementedError()
} }
@ -340,7 +340,7 @@ class HLS {
val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ") val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
return@mapNotNull when (it.type) { 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 else -> null
} }
} }

View File

@ -39,4 +39,16 @@ class OffsetDateTimeSerializer : KSerializer<OffsetDateTime> {
return OffsetDateTime.MIN; return OffsetDateTime.MIN;
return OffsetDateTime.of(LocalDateTime.ofEpochSecond(epochSecond, 0, ZoneOffset.UTC), ZoneOffset.UTC); 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);
}
} }

View File

@ -19,10 +19,10 @@ import kotlinx.serialization.json.jsonPrimitive
class PlatformContentSerializer : JsonContentPolymorphicSerializer<SerializedPlatformContent>(SerializedPlatformContent::class) { class PlatformContentSerializer : JsonContentPolymorphicSerializer<SerializedPlatformContent>(SerializedPlatformContent::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<SerializedPlatformContent> { 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 //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(); return SerializedPlatformVideo.serializer();
if(obj?.jsonPrimitive?.isString != false) { if(obj?.jsonPrimitive?.isString != false) {

View File

@ -184,7 +184,7 @@ class StatePlaylists {
wasNew = true; wasNew = true;
_watchlistStore.saveAsync(video); _watchlistStore.saveAsync(video);
if(orderPosition == -1) if(orderPosition == -1)
_watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values) .toTypedArray()); _watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values).toTypedArray());
else { else {
val existing = _watchlistOrderStore.getAllValues().toMutableList(); val existing = _watchlistOrderStore.getAllValues().toMutableList();
existing.add(orderPosition, video.url); 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) { private fun broadcastWatchLater(orderOnly: Boolean = false) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
StateSync.instance.broadcastJsonData( StateSync.instance.broadcastJsonData(
GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage( GJSyncOpcodes.syncWatchLater, getWatchLaterSyncPacket(orderOnly)
if (orderOnly) listOf() else getWatchLater(),
if (orderOnly) mapOf() else _watchLaterAdds.all(),
if (orderOnly) mapOf() else _watchLaterRemovals.all(),
getWatchLaterLastReorderTime().toEpochSecond(),
_watchlistOrderStore.values.toList()
)
); );
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to broadcast watch later", e) Logger.w(TAG, "Failed to broadcast watch later", e)

View File

@ -1,5 +1,6 @@
package com.futo.platformplayer.states package com.futo.platformplayer.states
import SubsExchangeClient
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel 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.resolveChannelUrl
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringDateMapStorage import com.futo.platformplayer.stores.StringDateMapStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.stores.StringStringMapStorage import com.futo.platformplayer.stores.StringStringMapStorage
import com.futo.platformplayer.stores.SubscriptionStorage import com.futo.platformplayer.stores.SubscriptionStorage
import com.futo.platformplayer.stores.v2.ReconstructStore import com.futo.platformplayer.stores.v2.ReconstructStore
@ -67,10 +69,24 @@ class StateSubscriptions {
val onSubscriptionsChanged = Event2<List<Subscription>, Boolean>(); val onSubscriptionsChanged = Event2<List<Subscription>, Boolean>();
private val _subsExchangeServer = "https://exchange.grayjay.app/";
private val _subscriptionKey = FragmentedStorage.get<StringStorage>("sub_exchange_key");
init { init {
global.onUpdateProgress.subscribe { progress, total -> global.onUpdateProgress.subscribe { progress, total ->
onFeedProgress.emit(null, 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 { 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>> { 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) if(onNewCacheHit != null)
algo.onNewCacheHit.subscribe(onNewCacheHit) algo.onNewCacheHit.subscribe(onNewCacheHit)

View File

@ -41,4 +41,19 @@ class StringArrayStorage : FragmentedStorageFileJson() {
return values.toList(); 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);
}
}
} }

View File

@ -1,5 +1,6 @@
package com.futo.platformplayer.subscription package com.futo.platformplayer.subscription
import SubsExchangeClient
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
@ -15,8 +16,9 @@ class SmartSubscriptionAlgorithm(
scope: CoroutineScope, scope: CoroutineScope,
allowFailure: Boolean = false, allowFailure: Boolean = false,
withCacheFallback: Boolean = true, withCacheFallback: Boolean = true,
threadPool: ForkJoinPool? = null threadPool: ForkJoinPool? = null,
): SubscriptionsTaskFetchAlgorithm(scope, allowFailure, withCacheFallback, threadPool) { subsExchangeClient: SubsExchangeClient? = null
): SubscriptionsTaskFetchAlgorithm(scope, allowFailure, withCacheFallback, threadPool, subsExchangeClient) {
override fun getSubscriptionTasks(subs: Map<Subscription, List<String>>): List<SubscriptionTask> { override fun getSubscriptionTasks(subs: Map<Subscription, List<String>>): List<SubscriptionTask> {
val allTasks: List<SubscriptionTask> = subs.flatMap { entry -> val allTasks: List<SubscriptionTask> = subs.flatMap { entry ->
val sub = entry.key; val sub = entry.key;

View File

@ -1,5 +1,6 @@
package com.futo.platformplayer.subscription package com.futo.platformplayer.subscription
import SubsExchangeClient
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
@ -33,11 +34,11 @@ abstract class SubscriptionFetchAlgorithm(
companion object { companion object {
public val TAG = "SubscriptionAlgorithm"; 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) { return when(algo) {
SubscriptionFetchAlgorithms.CACHE -> CachedSubscriptionAlgorithm(scope, allowFailure, withCacheFallback, pool, 50); SubscriptionFetchAlgorithms.CACHE -> CachedSubscriptionAlgorithm(scope, allowFailure, withCacheFallback, pool, 50);
SubscriptionFetchAlgorithms.SIMPLE -> SimpleSubscriptionAlgorithm(scope, allowFailure, withCacheFallback, pool); SubscriptionFetchAlgorithms.SIMPLE -> SimpleSubscriptionAlgorithm(scope, allowFailure, withCacheFallback, pool);
SubscriptionFetchAlgorithms.SMART -> SmartSubscriptionAlgorithm(scope, allowFailure, withCacheFallback, pool); SubscriptionFetchAlgorithms.SMART -> SmartSubscriptionAlgorithm(scope, allowFailure, withCacheFallback, pool, withExchangeClient);
} }
} }
} }

View File

@ -1,22 +1,28 @@
package com.futo.platformplayer.subscription package com.futo.platformplayer.subscription
import SubsExchangeClient
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.contents.IPlatformContent 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.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.DedupContentPager import com.futo.platformplayer.api.media.structures.DedupContentPager
import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager 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.PluginException
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptCriticalException import com.futo.platformplayer.engine.exceptions.ScriptCriticalException
import com.futo.platformplayer.engine.exceptions.ScriptException
import com.futo.platformplayer.exceptions.ChannelException import com.futo.platformplayer.exceptions.ChannelException
import com.futo.platformplayer.findNonRuntimeException import com.futo.platformplayer.findNonRuntimeException
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment
import com.futo.platformplayer.getNowDiffMiliseconds
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.states.StateApp 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.StatePlatform
import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.states.StateSubscriptions 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.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
import java.util.concurrent.ForkJoinPool import java.util.concurrent.ForkJoinPool
@ -35,7 +46,8 @@ abstract class SubscriptionsTaskFetchAlgorithm(
scope: CoroutineScope, scope: CoroutineScope,
allowFailure: Boolean = false, allowFailure: Boolean = false,
withCacheFallback: Boolean = true, withCacheFallback: Boolean = true,
_threadPool: ForkJoinPool? = null _threadPool: ForkJoinPool? = null,
private val subsExchangeClient: SubsExchangeClient? = null
) : SubscriptionFetchAlgorithm(scope, allowFailure, withCacheFallback, _threadPool) { ) : SubscriptionFetchAlgorithm(scope, allowFailure, withCacheFallback, _threadPool) {
@ -45,7 +57,7 @@ abstract class SubscriptionsTaskFetchAlgorithm(
} }
override fun getSubscriptions(subs: Map<Subscription, List<String>>): Result { override fun getSubscriptions(subs: Map<Subscription, List<String>>): Result {
val tasks = getSubscriptionTasks(subs); var tasks = getSubscriptionTasks(subs).toMutableList()
val tasksGrouped = tasks.groupBy { it.client } val tasksGrouped = tasks.groupBy { it.client }
@ -70,11 +82,46 @@ abstract class SubscriptionsTaskFetchAlgorithm(
val exs: ArrayList<Throwable> = arrayListOf(); 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 failedPlugins = mutableListOf<String>();
val cachedChannels = mutableListOf<String>() val cachedChannels = mutableListOf<String>()
val forkTasks = executeSubscriptionTasks(tasks, failedPlugins, cachedChannels); val forkTasks = executeSubscriptionTasks(tasks, failedPlugins, cachedChannels);
val taskResults = arrayListOf<SubscriptionTaskResult>(); val taskResults = arrayListOf<SubscriptionTaskResult>();
var resolveCount = 0;
var resolveTime = 0L;
val timeTotal = measureTimeMillis { val timeTotal = measureTimeMillis {
for(task in forkTasks) { for(task in forkTasks) {
try { 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 //Cache pagers grouped by channel
val groupedPagers = taskResults.groupBy { it.task.sub.channel.url } val groupedPagers = taskResults.groupBy { it.task.sub.channel.url }
.map { entry -> .map { entry ->
val sub = if(!entry.value.isEmpty()) entry.value[0].task.sub else null; 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 cachedTasks = entry.value.filter { it.task.fromCache };
val livePager = if(liveTasks.isNotEmpty()) StateCache.cachePagerResults(scope, MultiChronoContentPager(liveTasks.map { it.pager!! }, true).apply { this.initialize() }) { val livePager = if(liveTasks.isNotEmpty()) StateCache.cachePagerResults(scope, MultiChronoContentPager(liveTasks.map { it.pager!! }, true).apply { this.initialize() }) {
onNewCacheHit.emit(sub!!, it); onNewCacheHit.emit(sub!!, it);
@ -173,6 +288,8 @@ abstract class SubscriptionsTaskFetchAlgorithm(
Logger.e(StateSubscriptions.TAG, "Subscription peek [${task.sub.channel.name}] failed", ex); Logger.e(StateSubscriptions.TAG, "Subscription peek [${task.sub.channel.name}] failed", ex);
} }
} }
//Intercepts task.fromCache & task.fromPeek
synchronized(cachedChannels) { synchronized(cachedChannels) {
if(task.fromCache || task.fromPeek) { if(task.fromCache || task.fromPeek) {
finished++; finished++;

View File

@ -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
);

View File

@ -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
)

View File

@ -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
)

View File

@ -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
)

View File

@ -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
)

View File

@ -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)
}
}
}

View File

@ -232,6 +232,8 @@ class SyncSession : IAuthorizable {
sendData(GJSyncOpcodes.syncSubscriptionGroups, StateSubscriptionGroups.instance.getSyncSubscriptionGroupsPackageString()); sendData(GJSyncOpcodes.syncSubscriptionGroups, StateSubscriptionGroups.instance.getSyncSubscriptionGroupsPackageString());
sendData(GJSyncOpcodes.syncPlaylists, StatePlaylists.instance.getSyncPlaylistsPackageString()) sendData(GJSyncOpcodes.syncPlaylists, StatePlaylists.instance.getSyncPlaylistsPackageString())
sendData(GJSyncOpcodes.syncWatchLater, Json.encodeToString(StatePlaylists.instance.getWatchLaterSyncPacket(false)));
val recentHistory = StateHistory.instance.getRecentHistory(syncSessionData.lastHistory); val recentHistory = StateHistory.instance.getRecentHistory(syncSessionData.lastHistory);
if(recentHistory.size > 0) if(recentHistory.size > 0)
sendJsonData(GJSyncOpcodes.syncHistory, recentHistory); sendJsonData(GJSyncOpcodes.syncHistory, recentHistory);

View File

@ -12,6 +12,7 @@ import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.models.channels.SerializedChannel import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.states.StateSubscriptionGroups import com.futo.platformplayer.states.StateSubscriptionGroups
@ -46,8 +47,13 @@ class ToggleBar : LinearLayout {
_tagsContainer.removeAllViews(); _tagsContainer.removeAllViews();
for(button in buttons) { for(button in buttons) {
_tagsContainer.addView(ToggleTagView(context).apply { _tagsContainer.addView(ToggleTagView(context).apply {
this.setInfo(button.name, button.isActive); if(button.icon > 0)
this.onClick.subscribe { button.action(it); }; 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 { class Toggle {
val name: String; val name: String;
val icon: Int; val icon: Int;
val action: (Boolean)->Unit; val iconVariable: ImageVariable?;
val action: (ToggleTagView, Boolean)->Unit;
val isActive: Boolean; 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.name = name;
this.icon = icon; this.icon = 0;
this.iconVariable = icon;
this.action = action; this.action = action;
this.isActive = isActive; 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.name = name;
this.icon = 0; this.icon = icon;
this.iconVariable = null;
this.action = action; this.action = action;
this.isActive = isActive; 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;
}
} }
} }

View File

@ -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";
}
}

View File

@ -14,6 +14,7 @@ class VideoListEditorAdapter : RecyclerView.Adapter<VideoListEditorViewHolder> {
val onClick = Event1<IPlatformVideo>(); val onClick = Event1<IPlatformVideo>();
val onRemove = Event1<IPlatformVideo>(); val onRemove = Event1<IPlatformVideo>();
val onOptions = Event1<IPlatformVideo>();
var canEdit = false var canEdit = false
private set; private set;
@ -28,6 +29,7 @@ class VideoListEditorAdapter : RecyclerView.Adapter<VideoListEditorViewHolder> {
val holder = VideoListEditorViewHolder(view, _touchHelper); val holder = VideoListEditorViewHolder(view, _touchHelper);
holder.onRemove.subscribe { v -> onRemove.emit(v); }; holder.onRemove.subscribe { v -> onRemove.emit(v); };
holder.onOptions.subscribe { v -> onOptions.emit(v); };
holder.onClick.subscribe { v -> onClick.emit(v); }; holder.onClick.subscribe { v -> onClick.emit(v); };
return holder; return holder;

View File

@ -32,6 +32,7 @@ class VideoListEditorViewHolder : ViewHolder {
private val _containerDuration: LinearLayout; private val _containerDuration: LinearLayout;
private val _containerLive: LinearLayout; private val _containerLive: LinearLayout;
private val _imageRemove: ImageButton; private val _imageRemove: ImageButton;
private val _imageOptions: ImageButton;
private val _imageDragDrop: ImageButton; private val _imageDragDrop: ImageButton;
private val _platformIndicator: PlatformIndicator; private val _platformIndicator: PlatformIndicator;
private val _layoutDownloaded: FrameLayout; private val _layoutDownloaded: FrameLayout;
@ -41,6 +42,7 @@ class VideoListEditorViewHolder : ViewHolder {
val onClick = Event1<IPlatformVideo>(); val onClick = Event1<IPlatformVideo>();
val onRemove = Event1<IPlatformVideo>(); val onRemove = Event1<IPlatformVideo>();
val onOptions = Event1<IPlatformVideo>();
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
constructor(view: View, touchHelper: ItemTouchHelper? = null) : super(view) { constructor(view: View, touchHelper: ItemTouchHelper? = null) : super(view) {
@ -54,6 +56,7 @@ class VideoListEditorViewHolder : ViewHolder {
_containerDuration = view.findViewById(R.id.thumbnail_duration_container); _containerDuration = view.findViewById(R.id.thumbnail_duration_container);
_containerLive = view.findViewById(R.id.thumbnail_live_container); _containerLive = view.findViewById(R.id.thumbnail_live_container);
_imageRemove = view.findViewById(R.id.image_trash); _imageRemove = view.findViewById(R.id.image_trash);
_imageOptions = view.findViewById(R.id.image_settings);
_imageDragDrop = view.findViewById<ImageButton>(R.id.image_drag_drop); _imageDragDrop = view.findViewById<ImageButton>(R.id.image_drag_drop);
_platformIndicator = view.findViewById(R.id.thumbnail_platform); _platformIndicator = view.findViewById(R.id.thumbnail_platform);
_layoutDownloaded = view.findViewById(R.id.layout_downloaded); _layoutDownloaded = view.findViewById(R.id.layout_downloaded);
@ -74,6 +77,10 @@ class VideoListEditorViewHolder : ViewHolder {
val v = video ?: return@setOnClickListener; val v = video ?: return@setOnClickListener;
onRemove.emit(v); onRemove.emit(v);
}; };
_imageOptions?.setOnClickListener {
val v = video ?: return@setOnClickListener;
onOptions.emit(v);
}
} }
fun bind(v: IPlatformVideo, canEdit: Boolean) { fun bind(v: IPlatformVideo, canEdit: Boolean) {

View File

@ -8,6 +8,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
@ -22,6 +23,7 @@ class VideoListEditorView : FrameLayout {
val onVideoOrderChanged = Event1<List<IPlatformVideo>>() val onVideoOrderChanged = Event1<List<IPlatformVideo>>()
val onVideoRemoved = Event1<IPlatformVideo>(); val onVideoRemoved = Event1<IPlatformVideo>();
val onVideoOptions = Event1<IPlatformVideo>();
val onVideoClicked = Event1<IPlatformVideo>(); val onVideoClicked = Event1<IPlatformVideo>();
val isEmpty get() = _videos.isEmpty(); val isEmpty get() = _videos.isEmpty();
@ -54,6 +56,9 @@ class VideoListEditorView : FrameLayout {
} }
}; };
adapterVideos.onOptions.subscribe { v ->
onVideoOptions?.emit(v);
}
adapterVideos.onRemove.subscribe { v -> adapterVideos.onRemove.subscribe { v ->
val executeDelete = { val executeDelete = {
synchronized(_videos) { synchronized(_videos) {

View File

@ -4,27 +4,42 @@ import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import com.bumptech.glide.Glide
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event1 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 { class ToggleTagView : LinearLayout {
private val _root: FrameLayout; private val _root: FrameLayout;
private val _textTag: TextView; private val _textTag: TextView;
private var _text: String = ""; private var _text: String = "";
private var _image: ImageView;
var isActive: Boolean = false var isActive: Boolean = false
private set; 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) { constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
LayoutInflater.from(context).inflate(R.layout.view_toggle_tag, this, true); LayoutInflater.from(context).inflate(R.layout.view_toggle_tag, this, true);
_root = findViewById(R.id.root); _root = findViewById(R.id.root);
_textTag = findViewById(R.id.text_tag); _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) { 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; _text = text;
_textTag.text = text; _textTag.text = text;
setToggle(isActive); 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;
} }
} }

View File

@ -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"
}
}

View File

@ -98,7 +98,11 @@ class ImageVariableOverlay: ConstraintLayout {
UIDialogs.toast(context, "No thumbnail found"); UIDialogs.toast(context, "No thumbnail found");
return@subscribe; 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(); updateSelected();
}; };
}; };

View File

@ -8,7 +8,9 @@ import android.widget.LinearLayout
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UISlideOverlays 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.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.views.lists.VideoListEditorView import com.futo.platformplayer.views.lists.VideoListEditorView
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
@ -23,6 +25,7 @@ class QueueEditorOverlay : LinearLayout {
private val _overlayContainer: FrameLayout; private val _overlayContainer: FrameLayout;
val onOptions = Event1<IPlatformVideo>();
val onClose = Event0(); val onClose = Event0();
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
@ -35,6 +38,9 @@ class QueueEditorOverlay : LinearLayout {
_topbar.onClose.subscribe(this, onClose::emit); _topbar.onClose.subscribe(this, onClose::emit);
_editor.onVideoOrderChanged.subscribe { StatePlayer.instance.setQueueWithExisting(it) } _editor.onVideoOrderChanged.subscribe { StatePlayer.instance.setQueueWithExisting(it) }
_editor.onVideoOptions.subscribe { v ->
onOptions?.emit(v);
}
_editor.onVideoRemoved.subscribe { v -> _editor.onVideoRemoved.subscribe { v ->
StatePlayer.instance.removeFromQueue(v); StatePlayer.instance.removeFromQueue(v);
_topbar.setInfo(context.getString(R.string.queue), "${StatePlayer.instance.queueSize} " + context.getString(R.string.videos)); _topbar.setInfo(context.getString(R.string.queue), "${StatePlayer.instance.queueSize} " + context.getString(R.string.videos));

View File

@ -113,6 +113,13 @@ class SlideUpMenuOverlay : RelativeLayout {
_textOK.visibility = View.VISIBLE; _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 { fun selectOption(groupTag: Any?, itemTag: Any?, multiSelect: Boolean = false, toggle: Boolean = false): Boolean {
var didSelect = false; var didSelect = false;

View File

@ -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";
}
}

View File

@ -158,7 +158,7 @@ class SubscriptionBar : LinearLayout {
for(button in buttons) { for(button in buttons) {
_tagsContainer.addView(ToggleTagView(context).apply { _tagsContainer.addView(ToggleTagView(context).apply {
this.setInfo(button.name, button.isActive); 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 { class Toggle {
val name: String; val name: String;
val icon: Int; val icon: Int;
val action: (Boolean)->Unit; val action: (ToggleTagView, Boolean)->Unit;
val isActive: Boolean; 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.name = name;
this.icon = icon; this.icon = icon;
this.action = action; this.action = action;
this.isActive = isActive; 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.name = name;
this.icon = 0; this.icon = 0;
this.action = action; this.action = action;

View File

@ -145,6 +145,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
val onVideoClicked = Event0(); val onVideoClicked = Event0();
val onTimeBarChanged = Event2<Long, Long>(); val onTimeBarChanged = Event2<Long, Long>();
val onChapterClicked = Event1<IChapter>();
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
constructor(context: Context, attrs: AttributeSet? = null) : super(PLAYER_STATE_NAME, context, attrs) { constructor(context: Context, attrs: AttributeSet? = null) : super(PLAYER_STATE_NAME, context, attrs) {
LayoutInflater.from(context).inflate(R.layout.video_view, this, true); 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_duration_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_duration);
_control_pause_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_pause); _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 val castVisibility = if (Settings.instance.casting.enabled) View.VISIBLE else View.GONE
_control_cast.visibility = castVisibility _control_cast.visibility = castVisibility
_control_cast_fullscreen.visibility = castVisibility _control_cast_fullscreen.visibility = castVisibility

View 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>

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?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:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -9,80 +9,90 @@
android:paddingEnd="20dp" android:paddingEnd="20dp"
android:background="@color/black"> android:background="@color/black">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:orientation="horizontal" android:orientation="vertical">
android:gravity="center_vertical"
android:paddingTop="20dp"
android:paddingBottom="15dp">
<ImageButton <LinearLayout
android:id="@+id/button_back" android:layout_width="match_parent"
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: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_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:layout_height="wrap_content"
android:text="@string/add_source" android:layout_weight="1">
android:textSize="24dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_extra_light" />
</FrameLayout>
<Space <TextView
android:layout_width="20dp" android:layout_width="wrap_content"
android:layout_height="match_parent" /> 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> </LinearLayout>
<FrameLayout
<com.futo.platformplayer.views.buttons.BigButton android:id="@+id/overlay_container"
android:id="@+id/option_qr"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_marginTop="20dp" android:visibility="gone" />
android:layout_marginBottom="5dp" </androidx.constraintlayout.widget.ConstraintLayout>
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>

View File

@ -168,7 +168,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="10dp" android:layout_marginTop="10dp"
android:background="@drawable/background_button_round" android:background="@drawable/background_button_round"
android:hint="Seach.." /> android:hint="Search.." />
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@ -13,7 +13,7 @@
<com.google.android.material.appbar.AppBarLayout <com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar" android:id="@+id/app_bar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="230dp" android:layout_height="315dp"
android:background="@color/transparent" android:background="@color/transparent"
app:elevation="0dp"> app:elevation="0dp">
@ -87,7 +87,7 @@
<androidx.appcompat.widget.Toolbar <androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar" android:id="@+id/toolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="25dp" android:layout_height="110dp"
android:minHeight="0dp" android:minHeight="0dp"
app:contentInsetStart="0dp" app:contentInsetStart="0dp"
app:contentInsetEnd="0dp" app:contentInsetEnd="0dp"
@ -96,35 +96,82 @@
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="110dp"
android:gravity="center_vertical"> android:orientation="vertical">
<LinearLayout
<TextView android:layout_width="match_parent"
android:id="@+id/text_playlists"
android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="16dp" android:gravity="center_vertical">
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 <TextView
android:layout_width="0dp" android:id="@+id/text_playlists"
android:layout_height="match_parent" android:layout_width="wrap_content"
android:layout_weight="1" /> 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> </LinearLayout>
</androidx.appcompat.widget.Toolbar> </androidx.appcompat.widget.Toolbar>
@ -136,7 +183,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" 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_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" app:layout_constraintRight_toRightOf="parent"
android:paddingTop="10dp" android:paddingTop="10dp"

View File

@ -30,7 +30,7 @@
android:orientation="vertical"> android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="220dp"> android:layout_height="wrap_content">
<ImageView <ImageView
android:id="@+id/image_playlist_thumbnail" android:id="@+id/image_playlist_thumbnail"
@ -53,6 +53,22 @@
android:scaleType="fitXY" /> 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 <ImageButton
android:id="@+id/button_export" android:id="@+id/button_export"
@ -89,7 +105,7 @@
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="120dp" android:layout_height="wrap_content"
android:layout_marginTop="-90dp" android:layout_marginTop="-90dp"
android:layout_marginStart="20dp" android:layout_marginStart="20dp"
app:layout_constraintBottom_toBottomOf="parent"> app:layout_constraintBottom_toBottomOf="parent">
@ -116,6 +132,8 @@
app:layout_constraintLeft_toLeftOf="@id/container_buttons" app:layout_constraintLeft_toLeftOf="@id/container_buttons"
app:layout_constraintBottom_toTopOf="@id/container_buttons" /> app:layout_constraintBottom_toTopOf="@id/container_buttons" />
<LinearLayout <LinearLayout
android:id="@+id/container_buttons" android:id="@+id/container_buttons"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -176,20 +194,18 @@
</LinearLayout> </LinearLayout>
<ImageButton <ImageButton
android:id="@+id/button_edit" android:id="@+id/button_search"
android:layout_width="40dp" android:layout_width="40dp"
android:layout_height="40dp" android:layout_height="40dp"
android:contentDescription="@string/cd_button_edit" android:contentDescription="@string/cd_search_icon"
android:background="@drawable/background_button_round" android:background="@drawable/background_button_round"
android:gravity="center" android:gravity="center"
android:layout_marginStart="5dp" android:layout_marginStart="10dp"
app:layout_constraintLeft_toRightOf="@id/button_shuffle"
app:layout_constraintBottom_toBottomOf="@id/button_play_all"
android:orientation="horizontal" android:orientation="horizontal"
android:scaleType="fitCenter" app:srcCompat="@drawable/ic_search"
app:srcCompat="@drawable/ic_edit" app:tint="@color/white"
android:padding="10dp" android:padding="5dp"
app:tint="@color/white" /> android:scaleType="fitCenter" />
<ImageButton <ImageButton
android:id="@+id/button_download" android:id="@+id/button_download"
@ -207,6 +223,16 @@
</LinearLayout> </LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </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> </LinearLayout>
</androidx.appcompat.widget.Toolbar> </androidx.appcompat.widget.Toolbar>
</com.google.android.material.appbar.AppBarLayout> </com.google.android.material.appbar.AppBarLayout>

View File

@ -579,6 +579,12 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="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 <com.futo.platformplayer.views.overlays.SupportOverlay
android:id="@+id/videodetail_container_support" android:id="@+id/videodetail_container_support"
android:visibility="gone" android:visibility="gone"

View 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>

View File

@ -135,7 +135,7 @@
android:ellipsize="end" android:ellipsize="end"
app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail" app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail"
app:layout_constraintTop_toTopOf="parent" 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" app:layout_constraintBottom_toTopOf="@id/text_author"
android:layout_marginStart="10dp" /> android:layout_marginStart="10dp" />
@ -152,7 +152,7 @@
android:ellipsize="end" android:ellipsize="end"
app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail" app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail"
app:layout_constraintTop_toBottomOf="@id/text_video_name" 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" app:layout_constraintBottom_toTopOf="@id/text_video_metadata"
android:layout_marginStart="10dp" /> android:layout_marginStart="10dp" />
@ -169,19 +169,35 @@
android:ellipsize="end" android:ellipsize="end"
app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail" app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail"
app:layout_constraintTop_toBottomOf="@id/text_author" app:layout_constraintTop_toBottomOf="@id/text_author"
app:layout_constraintRight_toLeftOf="@id/image_trash" app:layout_constraintRight_toLeftOf="@id/buttons"
android:layout_marginStart="10dp" /> android:layout_marginStart="10dp" />
<ImageButton <LinearLayout
android:id="@+id/image_trash" android:id="@+id/buttons"
android:layout_width="40dp" android:layout_width="wrap_content"
android:layout_height="40dp" android:layout_height="wrap_content"
android:contentDescription="@string/cd_button_delete" android:orientation="vertical"
app:srcCompat="@drawable/ic_trash_18dp"
android:scaleType="fitCenter"
android:paddingTop="10dp"
android:paddingBottom="10dp"
app:layout_constraintRight_toRightOf="parent" app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="@id/layout_video_thumbnail" 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> </androidx.constraintlayout.widget.ConstraintLayout>

View 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>

View 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>

View File

@ -3,14 +3,14 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<ScrollView <HorizontalScrollView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:scrollbars="horizontal"> android:scrollbars="horizontal">
<LinearLayout <LinearLayout
android:id="@+id/container_tags" android:id="@+id/container_tags"
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" /> android:orientation="horizontal" />
</ScrollView> </HorizontalScrollView>
</LinearLayout> </LinearLayout>

View File

@ -3,23 +3,37 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="32dp" android:layout_height="32dp"
android:paddingStart="15dp" android:paddingStart="12dp"
android:paddingEnd="15dp" android:paddingEnd="12dp"
android:background="@drawable/background_pill" android:background="@drawable/background_pill"
android:layout_marginEnd="6dp" android:layout_marginEnd="6dp"
android:layout_marginTop="17dp" android:layout_marginTop="17dp"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
android:id="@+id/root"> android:id="@+id/root">
<LinearLayout
<TextView
android:id="@+id/text_tag"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:textColor="@color/white" android:orientation="horizontal">
android:layout_gravity="center" <ImageView
android:gravity="center" android:id="@+id/image_tag"
android:textSize="11dp" android:visibility="gone"
android:fontFamily="@font/inter_light" android:layout_width="24dp"
tools:text="Tag text" /> 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> </FrameLayout>

View File

@ -73,6 +73,8 @@
<string name="keep_screen_on_while_casting">Keep screen on while casting</string> <string name="keep_screen_on_while_casting">Keep screen on while casting</string>
<string name="always_proxy_requests">Always proxy requests</string> <string name="always_proxy_requests">Always proxy requests</string>
<string name="always_proxy_requests_description">Always proxy requests when casting data through the device.</string> <string name="always_proxy_requests_description">Always proxy requests when casting data through the device.</string>
<string name="allow_ipv6">Allow IPV6</string>
<string name="allow_ipv6_description">If casting over IPV6 is allowed, can cause issues on some networks</string>
<string name="discover">Discover</string> <string name="discover">Discover</string>
<string name="find_new_video_sources_to_add">Find new video sources to add</string> <string name="find_new_video_sources_to_add">Find new video sources to add</string>
<string name="these_sources_have_been_disabled">These sources have been disabled</string> <string name="these_sources_have_been_disabled">These sources have been disabled</string>
@ -414,9 +416,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="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="subscription_group_menu">Groups</string>
<string name="show_subscription_group">Show Subscription 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="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">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="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="log_level">Log Level</string>
<string name="logging">Logging</string> <string name="logging">Logging</string>
<string name="sync_grayjay">Sync Grayjay</string> <string name="sync_grayjay">Sync Grayjay</string>
@ -449,6 +457,8 @@
<string name="preferred_preview_quality">Preferred Preview Quality</string> <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="preferred_preview_quality_description">Default quality while previewing a video in a feed</string>
<string name="primary_language">Primary Language</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="default_comment_section">Default Comment Section</string>
<string name="hide_recommendations">Hide Recommendations</string> <string name="hide_recommendations">Hide Recommendations</string>
<string name="hide_recommendations_description">Fully hide the recommendations tab.</string> <string name="hide_recommendations_description">Fully hide the recommendations tab.</string>
@ -664,6 +674,7 @@
<string name="failed_to_load_post">Failed to load post.</string> <string name="failed_to_load_post">Failed to load post.</string>
<string name="replies">replies</string> <string name="replies">replies</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_saved">Plugin settings saved</string>
<string name="plugin_settings">Plugin settings</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> <string name="these_settings_are_defined_by_the_plugin">These settings are defined by the plugin</string>
@ -971,6 +982,16 @@
<item>Release Date (Oldest)</item> <item>Release Date (Oldest)</item>
<item>Release Date (Newest)</item> <item>Release Date (Newest)</item>
</string-array> </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"> <string-array name="feed_style">
<item>Preview</item> <item>Preview</item>
<item>List</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

@ -0,0 +1 @@
Subproject commit 4e490737a02491b52611af321582af8bead7d506

@ -1 +1 @@
Subproject commit 857c147b3a3d3e9d0a79c47f1bd5813e08ed2daf Subproject commit 6f1266a038d11998fef429ae0eac0798b3280d75

View File

@ -13,7 +13,8 @@
"4e365633-6d3f-4267-8941-fdc36631d813": "sources/spotify/build/SpotifyConfig.json", "4e365633-6d3f-4267-8941-fdc36631d813": "sources/spotify/build/SpotifyConfig.json",
"9c87e8db-e75d-48f4-afe5-2d203d4b95c5": "sources/dailymotion/build/DailymotionConfig.json", "9c87e8db-e75d-48f4-afe5-2d203d4b95c5": "sources/dailymotion/build/DailymotionConfig.json",
"e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.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": [ "SOURCES_EMBEDDED_DEFAULT": [
"35ae969a-a7db-11ed-afa1-0242ac120002" "35ae969a-a7db-11ed-afa1-0242ac120002"

View File

@ -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.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnail import com.futo.platformplayer.api.media.models.Thumbnail
import com.futo.platformplayer.api.media.models.Thumbnails 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.SerializedPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
@ -39,6 +40,7 @@ class RequireMigrationTests {
val viewCount = 1000L val viewCount = 1000L
return SerializedPlatformVideo( return SerializedPlatformVideo(
ContentType.MEDIA,
platformId, platformId,
name, name,
thumbnails, 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

@ -0,0 +1 @@
Subproject commit 4e490737a02491b52611af321582af8bead7d506

@ -1 +1 @@
Subproject commit 857c147b3a3d3e9d0a79c47f1bd5813e08ed2daf Subproject commit 6f1266a038d11998fef429ae0eac0798b3280d75

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