Compare commits

...

8 Commits

Author SHA1 Message Date
Kelvin 73918a8d76 refs 2023-12-07 20:18:39 +01:00
Kelvin a3c8bbb21f Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-07 20:17:49 +01:00
Kelvin 53525cb365 Improved import flow, Empty pager view support, No subscriptions result view, LoginRequiredException support 2023-12-07 20:17:35 +01:00
Koen e4d39cbec4 Added stop all gestures flow. 2023-12-07 17:16:25 +01:00
Koen a15e4beafb Updated youtube ref for stable. 2023-12-07 17:02:19 +01:00
Koen d47298102e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-07 17:00:57 +01:00
Koen 280feea06e Default language fixes. 2023-12-07 17:00:47 +01:00
Kelvin f649d62e38 Logging and refs 2023-12-07 16:04:22 +01:00
34 changed files with 458 additions and 79 deletions
+5
View File
@@ -71,6 +71,11 @@ class ScriptException extends Error {
}
}
}
class ScriptLoginRequiredException extends ScriptException {
constructor(msg) {
super("ScriptLoginRequiredException", msg);
}
}
class CaptchaRequiredException extends Error {
constructor(url, body) {
super(JSON.stringify({ 'plugin_type': 'CaptchaRequiredException', url, body }));
@@ -109,6 +109,10 @@ class UISlideOverlays {
menu.onOK.subscribe {
subscription.save();
menu.hide(true);
if(subscription.doNotifications && !originalNotif && Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) {
UIDialogs.toast(container.context, "Enable 'Background Update' in settings for notifications to work");
}
};
menu.onCancel.subscribe {
subscription.doNotifications = originalNotif;
@@ -34,6 +34,7 @@ import com.futo.platformplayer.engine.exceptions.PluginEngineException
import com.futo.platformplayer.engine.exceptions.PluginEngineStoppedException
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.engine.exceptions.ScriptLoginRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptValidationException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable
@@ -146,7 +146,7 @@ class DashBuilder : XMLBuilder {
dashBuilder.withAdaptationSet(
mapOf(
Pair("mimeType", subtitleSource.format ?: "text/vtt"),
Pair("lang", "en"),
Pair("lang", "df"),
Pair("default", "true")
)
) {
@@ -18,6 +18,7 @@ class AirPlayCastingDevice : CastingDevice {
override var usedRemoteAddress: InetAddress? = null;
override var localAddress: InetAddress? = null;
override val canSetVolume: Boolean get() = false;
override val canSetSpeed: Boolean get() = false; //TODO: Implement playback speed for AirPlay
var addresses: Array<InetAddress>? = null;
var port: Int = 0;
@@ -43,12 +44,12 @@ class AirPlayCastingDevice : CastingDevice {
return addresses?.toList() ?: listOf();
}
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double) {
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration) })) {
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
return;
}
Logger.i(FCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)");
Logger.i(FCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
time = resumePosition;
if (resumePosition > 0.0) {
@@ -60,7 +61,7 @@ class AirPlayCastingDevice : CastingDevice {
}
}
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double) {
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
throw NotImplementedError();
}
@@ -48,6 +48,7 @@ abstract class CastingDevice {
abstract var usedRemoteAddress: InetAddress?;
abstract var localAddress: InetAddress?;
abstract val canSetVolume: Boolean;
abstract val canSetSpeed: Boolean;
var name: String? = null;
var isPlaying: Boolean = false
@@ -77,6 +78,14 @@ abstract class CastingDevice {
onVolumeChanged.emit(value);
}
};
var speed: Double = 1.0
set(value) {
val changed = value != field;
speed = value;
if (changed) {
onSpeedChanged.emit(value);
}
};
val expectedCurrentTime: Double
get() {
val diff = timeReceivedAt.getNowDiffMiliseconds().toDouble() / 1000.0;
@@ -96,6 +105,7 @@ abstract class CastingDevice {
var onPlayChanged = Event1<Boolean>();
var onTimeChanged = Event1<Double>();
var onVolumeChanged = Event1<Double>();
var onSpeedChanged = Event1<Double>();
abstract fun stopCasting();
@@ -103,9 +113,10 @@ abstract class CastingDevice {
abstract fun stopVideo();
abstract fun pauseVideo();
abstract fun resumeVideo();
abstract fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double);
abstract fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double);
abstract fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?);
abstract fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?);
open fun changeVolume(volume: Double) { throw NotImplementedError() }
open fun changeSpeed(speed: Double) { throw NotImplementedError() }
abstract fun start();
abstract fun stop();
@@ -27,6 +27,7 @@ class ChromecastCastingDevice : CastingDevice {
override var usedRemoteAddress: InetAddress? = null;
override var localAddress: InetAddress? = null;
override val canSetVolume: Boolean get() = true;
override val canSetSpeed: Boolean get() = false; //TODO: Implement
var addresses: Array<InetAddress>? = null;
var port: Int = 0;
@@ -62,12 +63,12 @@ class ChromecastCastingDevice : CastingDevice {
return addresses?.toList() ?: listOf();
}
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double) {
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration) })) {
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
return;
}
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)");
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
time = resumePosition;
_streamType = streamType;
@@ -77,7 +78,7 @@ class ChromecastCastingDevice : CastingDevice {
playVideo();
}
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double) {
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
//TODO: Can maybe be implemented by sending data:contentType,base64...
throw NotImplementedError();
}
@@ -2,6 +2,7 @@ package com.futo.platformplayer.casting
import android.os.Looper
import android.util.Log
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.casting.models.*
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.getConnectedSocket
@@ -27,7 +28,10 @@ enum class Opcode(val value: Byte) {
SEEK(5),
PLAYBACK_UPDATE(6),
VOLUME_UPDATE(7),
SET_VOLUME(8)
SET_VOLUME(8),
PLAYBACK_ERROR(9),
SET_SPEED(10),
VERSION(11)
}
class FCastCastingDevice : CastingDevice {
@@ -38,6 +42,7 @@ class FCastCastingDevice : CastingDevice {
override var usedRemoteAddress: InetAddress? = null;
override var localAddress: InetAddress? = null;
override val canSetVolume: Boolean get() = true;
override val canSetSpeed: Boolean get() = true;
var addresses: Array<InetAddress>? = null;
var port: Int = 0;
@@ -47,6 +52,7 @@ class FCastCastingDevice : CastingDevice {
private var _inputStream: DataInputStream? = null;
private var _scopeIO: CoroutineScope? = null;
private var _started: Boolean = false;
private var _version: Long = 1;
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name;
@@ -64,33 +70,45 @@ class FCastCastingDevice : CastingDevice {
return addresses?.toList() ?: listOf();
}
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double) {
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration) })) {
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
return;
}
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)");
//TODO: Remove this later, temporary for the transition
if (_version <= 1L) {
UIDialogs.toast("Version not received, if you are experiencing issues, try updating FCast")
}
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
time = resumePosition;
sendMessage(Opcode.PLAY, FCastPlayMessage(
container = contentType,
url = contentId,
time = resumePosition.toInt()
time = resumePosition,
speed = speed
));
}
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double) {
if (invokeInIOScopeIfRequired({ loadContent(contentType, content, resumePosition, duration) })) {
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
if (invokeInIOScopeIfRequired({ loadContent(contentType, content, resumePosition, duration, speed) })) {
return;
}
Logger.i(TAG, "Start streaming content (contentType: $contentType, resumePosition: $resumePosition, duration: $duration)");
//TODO: Remove this later, temporary for the transition
if (_version <= 1L) {
UIDialogs.toast("Version not received, if you are experiencing issues, try updating FCast")
}
Logger.i(TAG, "Start streaming content (contentType: $contentType, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
time = resumePosition;
sendMessage(Opcode.PLAY, FCastPlayMessage(
container = contentType,
content = content,
time = resumePosition.toInt()
time = resumePosition,
speed = speed
));
}
@@ -103,13 +121,22 @@ class FCastCastingDevice : CastingDevice {
sendMessage(Opcode.SET_VOLUME, FCastSetVolumeMessage(volume))
}
override fun changeSpeed(speed: Double) {
if (invokeInIOScopeIfRequired({ changeSpeed(volume) })) {
return;
}
this.speed = speed
sendMessage(Opcode.SET_SPEED, FCastSetSpeedMessage(volume))
}
override fun seekVideo(timeSeconds: Double) {
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
return;
}
sendMessage(Opcode.SEEK, FCastSeekMessage(
time = timeSeconds.toInt()
time = timeSeconds
));
}
@@ -282,8 +309,8 @@ class FCastCastingDevice : CastingDevice {
return;
}
val playbackUpdate = Json.decodeFromString<FCastPlaybackUpdateMessage>(json);
time = playbackUpdate.time.toDouble();
val playbackUpdate = FCastCastingDevice.json.decodeFromString<FCastPlaybackUpdateMessage>(json);
time = playbackUpdate.time;
isPlaying = when (playbackUpdate.state) {
1 -> true
else -> false
@@ -295,9 +322,28 @@ class FCastCastingDevice : CastingDevice {
return;
}
val volumeUpdate = Json.decodeFromString<FCastVolumeUpdateMessage>(json);
val volumeUpdate = FCastCastingDevice.json.decodeFromString<FCastVolumeUpdateMessage>(json);
volume = volumeUpdate.volume;
}
Opcode.PLAYBACK_ERROR -> {
if (json == null) {
Logger.w(TAG, "Got playback error without JSON, ignoring.");
return;
}
val playbackError = FCastCastingDevice.json.decodeFromString<FCastPlaybackErrorMessage>(json);
Logger.e(TAG, "Remote casting playback error received: $playbackError")
}
Opcode.VERSION -> {
if (json == null) {
Logger.w(TAG, "Got version without JSON, ignoring.");
return;
}
val version = FCastCastingDevice.json.decodeFromString<FCastVersionMessage>(json);
_version = version.version;
Logger.i(TAG, "Remote version received: $version")
}
else -> { }
}
}
@@ -333,7 +379,7 @@ class FCastCastingDevice : CastingDevice {
val data: ByteArray;
var jsonString: String? = null;
if (message != null) {
jsonString = Json.encodeToString(message);
jsonString = json.encodeToString(message);
data = jsonString.encodeToByteArray();
} else {
data = ByteArray(0);
@@ -403,5 +449,6 @@ class FCastCastingDevice : CastingDevice {
companion object {
val TAG = "FastCastCastingDevice";
private val json = Json { ignoreUnknownKeys = true }
}
}
@@ -395,17 +395,17 @@ class StateCasting {
} else {
if (videoSource is IVideoUrlSource) {
Logger.i(TAG, "Casting as singular video");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble());
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble(), null);
} else if (audioSource is IAudioUrlSource) {
Logger.i(TAG, "Casting as singular audio");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble());
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble(), null);
} else if(videoSource is IHLSManifestSource) {
if (ad is ChromecastCastingDevice) {
Logger.i(TAG, "Casting as proxied HLS");
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition);
} else {
Logger.i(TAG, "Casting as non-proxied HLS");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble());
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), null);
}
} else if(audioSource is IHLSManifestAudioSource) {
if (ad is ChromecastCastingDevice) {
@@ -413,7 +413,7 @@ class StateCasting {
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition);
} else {
Logger.i(TAG, "Casting as non-proxied audio HLS");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble());
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), null);
}
} else if (videoSource is LocalVideoSource) {
Logger.i(TAG, "Casting as local video");
@@ -480,7 +480,7 @@ class StateCasting {
).withTag("cast");
Logger.i(TAG, "Casting local video (videoUrl: $videoUrl).");
ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble());
ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), null);
return listOf(videoUrl);
}
@@ -499,7 +499,7 @@ class StateCasting {
).withTag("cast");
Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl).");
ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble());
ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), null);
return listOf(audioUrl);
}
@@ -563,7 +563,7 @@ class StateCasting {
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castLocalHls")
mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "en", "english", true, true, true))
mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "df", "default", true, true, true))
}
if (subtitleSource != null) {
@@ -584,7 +584,7 @@ class StateCasting {
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castLocalHls")
mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "en", "english", true, true, true))
mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "df", "default", true, true, true))
}
val masterPlaylist = HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true)
@@ -595,7 +595,7 @@ class StateCasting {
).withTag("castLocalHls")
Logger.i(TAG, "added new castLocalHls handlers (hlsPath: $hlsPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath).")
ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble())
ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), null)
return listOf(hlsUrl, videoUrl, audioUrl, subtitleUrl)
}
@@ -641,7 +641,7 @@ class StateCasting {
}
Logger.i(TAG, "added new castLocalDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath).");
ad.loadVideo("BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble());
ad.loadVideo("BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), null);
return listOf(dashUrl, videoUrl, audioUrl, subtitleUrl);
}
@@ -686,7 +686,7 @@ class StateCasting {
val content = DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl);
Logger.i(TAG, "Direct dash cast to casting device (videoUrl: $videoUrl, audioUrl: $audioUrl).");
ad.loadContent("application/dash+xml", content, resumePosition, video.duration.toDouble());
ad.loadContent("application/dash+xml", content, resumePosition, video.duration.toDouble(), null);
return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "");
}
@@ -812,7 +812,7 @@ class StateCasting {
//ChromeCast is sometimes funky with resume position 0
val hackfixResumePosition = if (ad is ChromecastCastingDevice && !video.isLive && resumePosition == 0.0) 0.1 else resumePosition;
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, hackfixResumePosition, video.duration.toDouble());
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, hackfixResumePosition, video.duration.toDouble(), null);
return listOf(hlsUrl);
}
@@ -892,7 +892,7 @@ class StateCasting {
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "en", "english", true, true, true))
mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "df", "default", true, true, true))
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
@@ -942,7 +942,7 @@ class StateCasting {
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "en", "english", true, true, true))
mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "df", "default", true, true, true))
}
if (videoSource != null) {
@@ -986,7 +986,7 @@ class StateCasting {
).withTag("castHlsIndirectMaster")
Logger.i(TAG, "added new castHls handlers (hlsPath: $hlsPath).");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble());
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), null);
return listOf(hlsUrl, videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
}
@@ -1061,7 +1061,7 @@ class StateCasting {
}
Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath).");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble());
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), null);
return listOf(dashUrl, videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
}
@@ -2,32 +2,52 @@ package com.futo.platformplayer.casting.models
import kotlinx.serialization.Serializable
@kotlinx.serialization.Serializable
@Serializable
data class FCastPlayMessage(
val container: String,
val url: String? = null,
val content: String? = null,
val time: Int? = null
val time: Double? = null,
val speed: Double? = null
) { }
@kotlinx.serialization.Serializable
@Serializable
data class FCastSeekMessage(
val time: Int
val time: Double
) { }
@kotlinx.serialization.Serializable
@Serializable
data class FCastPlaybackUpdateMessage(
val time: Int,
val state: Int
val generationTime: Long,
val time: Double,
val duration: Double,
val state: Int,
val speed: Double
) { }
@Serializable
data class FCastVolumeUpdateMessage(
val generationTime: Long,
val volume: Double
)
@Serializable
data class FCastSetVolumeMessage(
val volume: Double
)
@Serializable
data class FCastSetSpeedMessage(
val speed: Double
)
@Serializable
data class FCastPlaybackErrorMessage(
val message: String
)
@Serializable
data class FCastVersionMessage(
val version: Long
)
@@ -301,6 +301,7 @@ class V8Plugin {
"CriticalException" -> throw ScriptCriticalException(config, msg, innerEx, stack, code);
"AgeException" -> throw ScriptAgeException(config, msg, innerEx, stack, code);
"UnavailableException" -> throw ScriptUnavailableException(config, msg, innerEx, stack, code);
"ScriptLoginRequiredException" -> throw ScriptLoginRequiredException(config, msg, innerEx, stack, code);
"ScriptExecutionException" -> throw ScriptExecutionException(config, msg, innerEx, stack, code);
"ScriptCompilationException" -> throw ScriptCompilationException(config, msg, innerEx, code);
"ScriptImplementationException" -> throw ScriptImplementationException(config, msg, innerEx, null, code);
@@ -0,0 +1,14 @@
package com.futo.platformplayer.engine.exceptions
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.getOrThrow
class ScriptLoginRequiredException(config: IV8PluginConfig, error: String, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptException(config, error, ex, stack, code) {
companion object {
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptException {
return ScriptLoginRequiredException(config, obj.getOrThrow(config, "message", "ScriptLoginRequiredException"));
}
}
}
@@ -78,7 +78,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
private var _moreButtons = arrayListOf<MenuButton>();
private var _buttonsVisible = 0;
private var _subscriptionsVisible = false;
private var _subscriptionsVisible = true;
var currentButtonDefinitions: List<ButtonDefinition>? = null;
@@ -261,11 +261,12 @@ class MenuBottomBarFragment : MainActivityFragment() {
}
private fun registerUpdateButtonEvents() {
/*
_subscriptionsVisible = StateSubscriptions.instance.getSubscriptionCount() > 0;
StateSubscriptions.instance.onSubscriptionsChanged.subscribe(this) { subs, _ ->
_subscriptionsVisible = subs.isNotEmpty();
updateButtonDefinitions()
}
}*/
StatePayment.instance.hasPaidChanged.subscribe(this) {
_fragment.lifecycleScope.launch(Dispatchers.Main) {
@@ -38,6 +38,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
private val _containerSortBy: LinearLayout;
private val _tagsView: TagsView;
private val _textCentered: TextView;
private val _emptyPagerContainer: FrameLayout;
protected val _toolbarContentView: LinearLayout;
@@ -69,6 +70,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
inflater.inflate(R.layout.fragment_feed, this);
_textCentered = findViewById(R.id.text_centered);
_emptyPagerContainer = findViewById(R.id.empty_pager_container);
_progress_bar = findViewById(R.id.progress_bar);
_progress_bar.inactiveColor = Color.TRANSPARENT;
@@ -199,6 +201,30 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
protected fun setTextCentered(text: String?) {
_textCentered.text = text;
}
protected open fun getEmptyPagerView(): View? {
return null;
}
fun setEmptyPager(enable: Boolean) {
if(enable) {
val viewToShow = getEmptyPagerView();
if(viewToShow != null) {
_emptyPagerContainer.removeAllViews();
_emptyPagerContainer.addView(viewToShow);
_emptyPagerContainer.visibility = VISIBLE;
setTextCentered(null);
}
else {
setTextCentered(context.getString(R.string.no_results_found_swipe_down_to_refresh));
_emptyPagerContainer.visibility = GONE;
}
}
else {
setTextCentered(null);
_emptyPagerContainer.removeAllViews();
_emptyPagerContainer.visibility = GONE;
}
}
fun onResume() {
//Reload the pager if the plugin was killed
@@ -420,7 +446,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
synchronized(_pager_lock) {
val pager: TPager = recyclerData.pager ?: return;
val hasMorePages = pager.hasMorePages();
Logger.i(TAG, "loadNextPage() hasMorePages=$hasMorePages");
Logger.i(TAG, "loadNextPage() hasMorePages=$hasMorePages, page size=${pager.getResults().size}");
//loadCachedPage();
if (pager.hasMorePages()) {
@@ -429,7 +455,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
_nextPageHandler.run(pager);
}
else if(_lastNextPage) {
Logger.i(TAG, "End of page reached");
Logger.i(TAG, "End of page reached (Last page size: ${pager.getResults().size})");
_lastNextPage = false;
}
}
@@ -22,6 +22,10 @@ import com.futo.platformplayer.views.adapters.viewholders.SelectableIPlatformCha
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StatePlatform
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ImportSubscriptionsFragment : MainFragment() {
override val isMainView : Boolean = true;
@@ -59,11 +63,15 @@ class ImportSubscriptionsFragment : MainFragment() {
class ImportSubscriptionsView : LinearLayout {
private val _fragment: ImportSubscriptionsFragment;
private val SLOWDOWN_COUNT = 100;
private val SLOWDOWN_MS: Long = 1000;
private var _spinner: ImageView;
private var _textSelectDeselectAll: TextView;
private var _textNothingToImport: TextView;
private var _textCounter: TextView;
private var _textLoadMore: TextView;
private var _loadProgress: TextView;
//private var _textLoadMore: TextView;
private var _adapterView: AnyAdapterView<SelectableIPlatformChannel, ImportSubscriptionViewHolder>;
private var _links: List<String> = listOf();
private val _items: ArrayList<SelectableIPlatformChannel> = arrayListOf();
@@ -80,8 +88,9 @@ class ImportSubscriptionsFragment : MainFragment() {
_textNothingToImport = findViewById(R.id.nothing_to_import);
_textSelectDeselectAll = findViewById(R.id.text_select_deselect_all);
_textCounter = findViewById(R.id.text_select_counter);
_textLoadMore = findViewById(R.id.text_load_more);
//_textLoadMore = findViewById(R.id.text_load_more);
_spinner = findViewById(R.id.channel_loader);
_loadProgress = findViewById(R.id.text_load_progress);
_adapterView = findViewById<RecyclerView>(R.id.recycler_import).asAny( _items) {
it.onSelectedChange.subscribe { c ->
@@ -113,6 +122,7 @@ class ImportSubscriptionsFragment : MainFragment() {
return@TaskHandler channel;
}).success {
_items.add(SelectableIPlatformChannel(it));
_loadProgress.text = "(${_items.size}/${_links.size})";
_adapterView.adapter.notifyItemInserted(_items.size - 1);
loadNext();
}.exceptionWithParameter<Throwable> { ex, para ->
@@ -123,6 +133,7 @@ class ImportSubscriptionsFragment : MainFragment() {
loadNext();
};
/*
_textLoadMore.setOnClickListener {
if (!_limitToastShown) {
return@setOnClickListener;
@@ -134,7 +145,7 @@ class ImportSubscriptionsFragment : MainFragment() {
load();
};
_textLoadMore.visibility = View.GONE;
_textLoadMore.visibility = View.GONE;*/
}
fun cleanup() {
@@ -165,12 +176,23 @@ class ImportSubscriptionsFragment : MainFragment() {
it.title = context.getString(R.string.import_subscriptions);
it.onImport.subscribe(this) {
val subscriptionsToImport = _items.filter { i -> i.selected }.toList();
for (subscriptionToImport in subscriptionsToImport) {
StateSubscriptions.instance.addSubscription(subscriptionToImport.channel);
UIDialogs.showDialogProgress(context) {
it.setText("Importing subscriptions..");
_fragment.lifecycleScope.launch(Dispatchers.IO) {
for ((i, subscriptionToImport) in subscriptionsToImport.withIndex()) {
StateSubscriptions.instance.addSubscription(subscriptionToImport.channel);
withContext(Dispatchers.Main) {
it.setProgress(i.toDouble() / subscriptionsToImport.size);
}
}
withContext(Dispatchers.Main) {
UIDialogs.toast("${subscriptionsToImport.size} " + context.getString(R.string.subscriptions_imported));
_fragment.closeSegment();
it.dismiss();
}
}
}
UIDialogs.toast("${subscriptionsToImport.size} " + context.getString(R.string.subscriptions_imported));
_fragment.closeSegment();
};
}
}
@@ -180,7 +202,7 @@ class ImportSubscriptionsFragment : MainFragment() {
if (_counter >= MAXIMUM_BATCH_SIZE) {
if (!_limitToastShown) {
_limitToastShown = true;
_textLoadMore.visibility = View.VISIBLE;
// _textLoadMore.visibility = View.VISIBLE;
UIDialogs.toast(context, context.getString(R.string.stopped_after_requestcount_to_avoid_rate_limit_click_load_more_to_load_more).replace("{requestCount}", MAXIMUM_BATCH_SIZE.toString()));
}
@@ -192,11 +214,25 @@ class ImportSubscriptionsFragment : MainFragment() {
private fun loadNext() {
_currentLoadIndex++;
if (_currentLoadIndex < _links.size) {
load();
} else {
setLoading(false);
if(_currentLoadIndex >= SLOWDOWN_COUNT) {
if(_currentLoadIndex % 10 == 0) {
val estTime = (SLOWDOWN_MS * (_links.size - _currentLoadIndex)) / 1000;
UIDialogs.toast(context, "Import slowed down to prevent rate limit (Estimate ${estTime.toInt().toHumanTimeIndicator()})");
}
_fragment.lifecycleScope.launch(Dispatchers.Default) {
delay(SLOWDOWN_MS);
withContext(Dispatchers.Main) {
load();
}
}
}
else
load();
}
else
setLoading(false);
}
private fun updateSelected() {
@@ -216,17 +252,19 @@ class ImportSubscriptionsFragment : MainFragment() {
if(isLoading){
(_spinner.drawable as Animatable?)?.start();
_spinner.visibility = View.VISIBLE;
_loadProgress.visibility = View.VISIBLE;
}
else {
_spinner.visibility = View.GONE;
(_spinner.drawable as Animatable?)?.stop();
_loadProgress.visibility = View.GONE;
}
}
}
companion object {
val TAG = "ImportSubscriptionsFragment";
private const val MAXIMUM_BATCH_SIZE = 100;
private const val MAXIMUM_BATCH_SIZE = 2000;
fun newInstance() = ImportSubscriptionsFragment().apply {}
}
}
@@ -9,17 +9,18 @@ import android.widget.LinearLayout
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.futo.platformplayer.*
import com.futo.platformplayer.activities.MainActivity
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.video.IPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.exceptions.ChannelException
import com.futo.platformplayer.exceptions.RateLimitException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StatePlatform
@@ -28,9 +29,11 @@ import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.FragmentedStorageFileJson
import com.futo.platformplayer.views.announcements.AnnouncementView
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.NoResultsView
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.InsertedViewHolder
import com.futo.platformplayer.views.buttons.BigButton
import com.futo.platformplayer.views.subscriptions.SubscriptionBar
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@@ -41,7 +44,6 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.OffsetDateTime
import kotlin.system.measureTimeMillis
import kotlin.time.measureTime
class SubscriptionsFeedFragment : MainFragment() {
override val isMainView : Boolean = true;
@@ -321,7 +323,7 @@ class SubscriptionsFeedFragment : MainFragment() {
withContext(Dispatchers.Main) {
val results = cachePager.getResults();
Logger.i(TAG, "Subscriptions show cache (${results.size})");
setTextCentered(if (results.isEmpty()) context.getString(R.string.no_results_found_swipe_down_to_refresh) else null);
setEmptyPager(results.isEmpty());
setPager(cachePager);
}
}
@@ -331,15 +333,14 @@ class SubscriptionsFeedFragment : MainFragment() {
Logger.i(TAG, "Subscriptions load");
if(recyclerData.results.size == 0) {
loadCache();
} else {
setTextCentered(null);
}
} else
setEmptyPager(false);
_taskGetPager.run(withRefetch);
}
override fun onRestoreCachedData(cachedData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>) {
super.onRestoreCachedData(cachedData);
setTextCentered(if (cachedData.results.isEmpty()) context.getString(R.string.no_results_found_swipe_down_to_refresh) else null);
setEmptyPager(cachedData.results.isEmpty());
}
private fun loadedResult(pager: IPager<IPlatformContent>) {
Logger.i(TAG, "Subscriptions new pager loaded (${pager.getResults().size})");
@@ -349,7 +350,7 @@ class SubscriptionsFeedFragment : MainFragment() {
finishRefreshLayoutLoader();
setLoading(false);
setPager(pager);
setTextCentered(if (pager.getResults().isEmpty()) context.getString(R.string.no_results_found_swipe_down_to_refresh) else null);
setEmptyPager(pager.getResults().isEmpty());
} catch (e: Throwable) {
Logger.e(TAG, "Failed to finish loading", e)
}
@@ -359,6 +360,22 @@ class SubscriptionsFeedFragment : MainFragment() {
}
}*/
}
override fun getEmptyPagerView(): View? {
val dp10 = 10.dp(resources);
val dp30 = 30.dp(resources);
if(StateSubscriptions.instance.getSubscriptions().isEmpty())
return NoResultsView(context, "You have no subscriptions", "Subscribe to some creators or import them from elsewhere.", R.drawable.ic_explore, listOf(
BigButton(context, "Search", "Search for creators in your enabled plugins", R.drawable.ic_creators) {
fragment.navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.CREATOR));
}.withMargin(dp10, dp30),
BigButton(context, "Import", "Import your subscriptions from another format", R.drawable.ic_move_up) {
val activity = StateApp.instance.context;
if(activity is MainActivity)
UIDialogs.showImportOptionsDialog(activity);
}.withMargin(dp10, dp30)
));
return null;
}
private fun handleExceptions(exs: List<Throwable>) {
context?.let {
@@ -216,6 +216,7 @@ class VideoDetailFragment : MainFragment {
}
_view!!.setTransitionListener(object : MotionLayout.TransitionListener {
override fun onTransitionChange(motionLayout: MotionLayout?, startId: Int, endId: Int, progress: Float) {
_viewDetail?.stopAllGestures()
if (state != State.MINIMIZED && progress < 0.1) {
state = State.MINIMIZED;
@@ -51,6 +51,8 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.casting.CastConnectionState
@@ -62,6 +64,7 @@ import com.futo.platformplayer.downloads.VideoLocal
import com.futo.platformplayer.engine.exceptions.ScriptAgeException
import com.futo.platformplayer.engine.exceptions.ScriptException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.engine.exceptions.ScriptLoginRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.helpers.VideoHelper
@@ -683,6 +686,11 @@ class VideoDetailView : ConstraintLayout {
}
}
fun stopAllGestures() {
_player.stopAllGestures();
_cast.stopAllGestures();
}
fun updateMoreButtons() {
val buttons = listOf(RoundButton(context, R.drawable.ic_add, context.getString(R.string.add), TAG_ADD) {
(video ?: _searchVideo)?.let {
@@ -1941,6 +1949,7 @@ class VideoDetailView : ConstraintLayout {
video?.let { updateQualitySourcesOverlay(it, videoLocal); };
val changed = _isCasting != isCasting;
_isCasting = isCasting;
if(isCasting) {
@@ -1948,8 +1957,7 @@ class VideoDetailView : ConstraintLayout {
_player.stop();
_player.hideControls(false);
_cast.visibility = View.VISIBLE;
}
else {
} else {
StateCasting.instance.stopVideo();
_cast.stopTimeJob();
_cast.visibility = View.GONE;
@@ -1958,6 +1966,10 @@ class VideoDetailView : ConstraintLayout {
_player.setPlaybackRate(Settings.instance.playback.getDefaultPlaybackSpeed());
}
}
if (changed) {
stopAllGestures();
}
}
fun setFullscreen(fullscreen : Boolean) {
@@ -2317,6 +2329,22 @@ class VideoDetailView : ConstraintLayout {
StateAnnouncement.instance.registerAnnouncement(video?.id?.value + "_Q_NOSOURCES", context.getString(R.string.video_without_source), context.getString(R.string.there_was_a_in_your_queue_videoname_by_authorname_without_the_required_source_being_enabled_playback_was_skipped).replace("{videoName}", video?.name ?: "").replace("{authorName}", video?.author?.name ?: ""), AnnouncementType.SESSION)
}
}
.exception<ScriptLoginRequiredException> {
Logger.w(TAG, "exception<ScriptLoginRequiredException>", it);
UIDialogs.showDialog(context, R.drawable.ic_security, "Authentication", it.message, null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Login", {
val id = it.config?.let { if(it is SourcePluginConfig) it.id else null };
val didLogin = if(id == null)
false
else StatePlugins.instance.loginPlugin(context, id) {
fetchVideo();
}
if(!didLogin)
UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Failed to login");
}));
}
.exception<ContentNotAvailableYetException> {
Logger.w(TAG, "exception<ContentNotAvailableYetException>", it)
@@ -31,6 +31,7 @@ import com.futo.platformplayer.background.BackgroundWorker
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptLoginRequiredException
import com.futo.platformplayer.fragment.mainactivity.main.HomeFragment
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
import com.futo.platformplayer.logging.AndroidLogConsumer
@@ -751,6 +752,9 @@ class StateApp {
})
}
}
fun handleLoginException(client: JSClient, exception: ScriptLoginRequiredException, onSuccess: (client: JSClient)->Unit) {
}
fun getLocaleContext(baseContext: Context?): Context? {
val locale = getLocaleSetting(baseContext);
@@ -3,6 +3,7 @@ package com.futo.platformplayer.states
import android.content.Context
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.LoginActivity
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourceAuth
@@ -53,6 +54,25 @@ class StatePlugins {
.load();
}
fun loginPlugin(context: Context, id: String, afterLogin: ()->Unit): Boolean {
val descriptor = getPlugin(id) ?: return false;
val config = descriptor.config;
if(config.authentication == null)
return false;
LoginActivity.showLogin(context, config) {
StatePlugins.instance.setPluginAuth(config.id, it);
StateApp.instance.scope.launch(Dispatchers.IO) {
StatePlatform.instance.reloadClient(context, id);
afterLogin.invoke();
}
};
return true;
}
private fun getResourceIdFromString(resourceName: String, c: Class<*> = R.drawable::class.java): Int? {
return try {
val idField = c.getDeclaredField(resourceName)
@@ -0,0 +1,34 @@
package com.futo.platformplayer.views
import android.content.Context
import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import com.futo.platformplayer.R
class NoResultsView: ConstraintLayout {
val textTitle: TextView;
val textCentered: TextView;
val icon: ImageView;
val containerExtraViews: LinearLayout;
constructor(context: Context, title: String, text: String, iconId: Int, extraViews: List<View>) : super(context) {
inflate(context, R.layout.view_no_results, this);
textTitle = findViewById(R.id.text_title)
textCentered = findViewById(R.id.text_centered);
icon = findViewById(R.id.icon);
containerExtraViews = findViewById(R.id.container_extra_views);
textTitle.text = title;
textCentered.text = text;
icon.setImageResource(iconId);
if(iconId < 0)
icon.visibility = GONE;
for(view in extraViews)
containerExtraViews.addView(view);
}
}
@@ -330,11 +330,21 @@ class GestureControlView : LinearLayout {
_controlsVisible = false;
}
fun stopAllGestures() {
stopAdjustingFullscreenDown()
stopAdjustingBrightness()
stopAdjustingSound()
stopAdjustingFullscreenUp()
stopFastForward()
stopAutoFastForward()
}
fun cleanup() {
_jobExitFastForward?.cancel();
_jobExitFastForward = null;
_jobAutoFastForward?.cancel();
_jobAutoFastForward = null;
stopAllGestures();
cancelHideJob();
_scope.cancel();
}
@@ -76,6 +76,11 @@ open class BigButton : LinearLayout {
_textSecondary.text = attrTextSecondary;
}
fun withMargin(bottom: Int, side: Int = 0): BigButton {
setPadding(side, 0, side, bottom)
return this;
}
fun setSecondaryText(text: String?) {
_textSecondary.text = text
}
@@ -102,6 +102,10 @@ class CastView : ConstraintLayout {
_updateTimeJob = null;
}
fun stopAllGestures() {
_gestureControlView.stopAllGestures();
}
fun setIsPlaying(isPlaying: Boolean) {
_updateTimeJob?.cancel();
@@ -343,6 +343,10 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
_currentChapterLoopActive = false;
}
fun stopAllGestures() {
gestureControl.stopAllGestures();
}
fun attachPlayer() {
exoPlayer?.attach(_videoView, PLAYER_STATE_NAME);
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#142B66" />
<corners android:radius="4dp" />
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
</shape>
+9
View File
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,840Q405.46,840 339.77,811.58Q274.08,783.15 225.46,734.54Q176.85,685.92 148.42,620.23Q120,554.54 120,480Q120,405.46 148.42,339.77Q176.85,274.08 225.46,225.46Q274.08,176.85 339.77,148.42Q405.46,120 480,120Q619.85,120 721.27,212.27Q822.69,304.54 837.39,440.23L796.92,440.23Q786.39,353.38 734.19,284.35Q682,215.31 600,182.46L600,200Q600,233 576.5,256.5Q553,280 520,280L440,280L440,360Q440,377 428.5,388.5Q417,400 400,400L320,400L320,480L393.85,480L393.85,600L360,600L168,408Q165,426 162.5,444Q160,462 160,480Q160,611 252,705Q344,799 480,800L480,840ZM840.92,829.23L702.92,692.77Q685,705.54 664.08,712.77Q643.15,720 620,720Q561.15,720 520.58,679.42Q480,638.85 480,580Q480,521.15 520.58,480.58Q561.15,440 620,440Q678.85,440 719.42,480.58Q760,521.15 760,580Q760,603.92 752.39,625.23Q744.77,646.54 731.23,664.46L869.23,800.92L840.92,829.23ZM620,680Q662,680 691,651Q720,622 720,580Q720,538 691,509Q662,480 620,480Q578,480 549,509Q520,538 520,580Q520,622 549,651Q578,680 620,680Z"/>
</vector>
@@ -106,6 +106,11 @@
android:gravity="center"
android:textColor="@color/gray_ac"
android:textSize="12dp" />
<FrameLayout
android:visibility="gone"
android:id="@+id/empty_pager_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
+10 -1
View File
@@ -39,7 +39,16 @@
<Space android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<TextView
android:id="@+id/text_load_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/inter_light"
android:textSize="15dp"
android:text="(0/0)"
android:textColor="#58595B" />
<!--
<TextView
android:id="@+id/text_load_more"
android:layout_width="wrap_content"
@@ -47,7 +56,7 @@
android:fontFamily="@font/inter_light"
android:textSize="15dp"
android:text="@string/load_more"
android:textColor="@color/colorPrimary" />
android:textColor="@color/colorPrimary" /> -->
<Space android:layout_width="0dp"
android:layout_height="match_parent"
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:id="@+id/icon"
android:layout_width="match_parent"
android:layout_height="60dp"
android:layout_marginBottom="10dp"
android:scaleType="fitCenter" />
<TextView
android:id="@+id/text_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:fontFamily="@font/inter_bold"
android:text="No results"
android:gravity="center"
android:textColor="@color/white"
android:textSize="22dp" />
<TextView
android:id="@+id/text_centered"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:layout_marginBottom="10dp"
android:fontFamily="@font/inter_regular"
android:text="No results were found for this page"
android:gravity="center"
android:textColor="@color/gray_ac"
android:textSize="13dp" />
<LinearLayout
android:id="@+id/container_extra_views"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_margin="10dp" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>