Compare commits

...

38 Commits

Author SHA1 Message Date
Koen bf3e8867c3 Synchronized writes. 2024-01-07 14:19:03 +01:00
Koen 176814a715 Crashfix on unreliable casting connection. Made casting more robust with intermittent TCP connections. 2024-01-07 13:41:43 +01:00
Koen 898637a616 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-06 23:07:50 +01:00
Koen f1860126a7 - Changed casting connection timeout to 2 seconds.
- Added ping pong to FCast.
- Removal of DataInputStream and just using raw InputStream.
- Fix slider position crash.
2024-01-06 23:07:34 +01:00
Kelvin f8402676d7 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-06 22:19:38 +01:00
Kelvin cf86ce1ab3 Minor leak fix, login warning support, refs 2024-01-06 22:19:29 +01:00
Koen f4cb1719e0 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-04 13:38:37 +01:00
Koen 4898cb53ae Fixed tint color. 2024-01-04 13:38:30 +01:00
Kelvin 0f60d4737e Plugin update appToast, Refs 2024-01-03 19:47:38 +01:00
Kelvin 0dc33e1f2b Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-03 17:59:11 +01:00
Kelvin d86a997a88 appToast system, VideoToOpen changed 2024-01-03 17:59:01 +01:00
Koen 34d4d92289 Casting stability fixes to ChromeCast connection thread. 2024-01-03 11:24:55 +01:00
Koen 4cb1bf268f Updated YouTube submodule. 2024-01-03 11:09:57 +01:00
Kelvin 8488706ff9 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-02 20:14:36 +01:00
Kelvin a348bb2662 Fix crash download livestream, refs 2024-01-02 20:14:28 +01:00
Koen 60a17b3c67 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-02 19:48:51 +01:00
Koen 386c58d4ad Added timeouts to casting flow. 2024-01-02 19:48:43 +01:00
Kelvin 356ba01dc1 Fix default comment section 2024-01-02 16:28:08 +01:00
Kelvin ed2aa848da Fix clear playback notification, Update V8 Library, Close modified requests after usage 2024-01-02 15:55:29 +01:00
Kelvin c5dd90048f Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-02 14:36:46 +01:00
Kelvin ab04f334dc Download cleanup after cancel/failure and on startup, auto-select subtitles if downloaded, refs 2024-01-02 14:36:41 +01:00
Koen 0d44f8a416 Fixed issue where some videos would not play in Odysee. 2024-01-02 13:36:43 +01:00
Koen d01a1545e2 Allow large heap to fix Rumble failing to load large videos. 2024-01-02 13:35:48 +01:00
Kelvin e599729ba1 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-27 15:21:52 +01:00
Kelvin 3ac043740e Fix RequestModifier not being applied, and add default option to add pre-existing headers 2023-12-27 15:21:43 +01:00
Koen 89603d0ff3 changed typeface when update is available. 2023-12-27 14:31:26 +01:00
Koen 05b6cd7c97 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-27 11:11:36 +01:00
Koen ea5aad0631 Implemented check for updates. 2023-12-27 11:11:29 +01:00
Kelvin 96e034b9bf Fix plugin.d.ts and update 2023-12-22 19:29:38 +01:00
Kelvin 6141c36855 Refs 2023-12-21 20:04:53 +01:00
Kelvin 4084ab3ed0 refs 2023-12-21 20:01:51 +01:00
Kelvin 34e733823a Refs 2023-12-21 19:27:13 +01:00
Kelvin f1d01642cd Refs, ui fixes 2023-12-21 19:24:30 +01:00
Kelvin d5551d7118 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-21 17:24:18 +01:00
Kelvin d079a1e8e4 Add missing channel name setter 2023-12-21 17:24:11 +01:00
Koen c06c00ee9b Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-21 17:19:17 +01:00
Koen 1d8eababc2 Minor casting dialog fixes. 2023-12-21 17:19:05 +01:00
Kelvin 75cf1ffbdd Offline playback fix with remote sources 2023-12-21 17:13:43 +01:00
60 changed files with 904 additions and 437 deletions
+1 -1
View File
@@ -169,7 +169,7 @@ dependencies {
implementation 'com.google.code.gson:gson:2.10.1' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
//JS
implementation("com.caoccao.javet:javet-android:2.2.1")
implementation("com.caoccao.javet:javet-android:3.0.2")
//Exoplayer
implementation 'androidx.media3:media3-exoplayer:1.2.0'
+2 -1
View File
@@ -24,7 +24,8 @@
android:supportsRtl="true"
android:theme="@style/Theme.FutoVideo"
android:usesCleartextTraffic="true"
tools:targetApi="31">
tools:targetApi="31"
android:largeHeap="true">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="@string/authority"
+120 -46
View File
@@ -1,13 +1,37 @@
declare class ScriptException extends Error {
//If only one parameter is provided, acts as msg
constructor(type: string, msg: string);
}
declare class TimeoutException extends ScriptException {
declare class LoginRequiredException extends ScriptException {
constructor(msg: string);
}
//Alias
declare class ScriptLoginRequiredException extends ScriptException {
constructor(msg: string);
}
declare class CaptchaRequiredException extends ScriptException {
constructor(url: string, body: string);
}
declare class CriticalException extends ScriptException {
constructor(msg: string);
}
declare class UnavailableException extends ScriptException {
constructor(msg: string);
}
declare class AgeException extends ScriptException {
constructor(msg: string);
}
declare class TimeoutException extends ScriptException {
constructor(msg: string);
}
declare class ScriptImplementationException extends ScriptException {
constructor(msg: string);
}
@@ -38,16 +62,23 @@ declare class FilterCapability {
declare class PlatformAuthorLink {
constructor(id: PlatformID, name: string, url: string, thumbnail: string, subscribers: integer?);
constructor(id: PlatformID, name: string, url: string, thumbnail: string, subscribers: integer?, membershipUrl: string?);
}
declare class PlatformAuthorMembershipLink {
constructor(id: PlatformID, name: string, url: string, thumbnail: string, subscribers: integer?, membershipUrl: string?);
}
declare interface PlatformContentDef {
id: PlatformID,
name: string,
thumbnails: Thumbnails,
author: PlatformAuthorLink,
datetime: integer,
url: string
}
declare interface PlatformContent {}
declare interface PlatformNestedMediaContentDef extends PlatformContentDef {
contentUrl: string,
contentName: string?,
@@ -59,16 +90,26 @@ declare class PlatformNestedMediaContent {
constructor(obj: PlatformNestedMediaContentDef);
}
declare interface PlatformLockedContentDef extends PlatformContentDef {
contentName: string?,
contentThumbnails: Thumbnails?,
unlockUrl: string,
lockDescription: string?,
}
declare class PlatformLockedContent {
constructor(obj: PlatformLockedContentDef);
}
declare interface PlatformVideoDef extends PlatformContentDef {
thumbnails: Thumbnails,
author: PlatformAuthorLink,
duration: int,
viewCount: long,
isLive: boolean
isLive: boolean,
shareUrl: string?
}
declare interface PlatformContent {}
declare class PlatformVideo implements PlatformContent {
constructor(obj: PlatformVideoDef);
}
@@ -77,14 +118,15 @@ declare class PlatformVideo implements PlatformContent {
declare interface PlatformVideoDetailsDef extends PlatformVideoDef {
description: string,
video: VideoSourceDescriptor,
live: SubtitleSource[],
rating: IRating
live: IVideoSource,
rating: IRating,
subtitles: SubtitleSource[]
}
declare class PlatformVideoDetails extends PlatformVideo {
constructor(obj: PlatformVideoDetailsDef);
}
declare class PlatformPostDef extends PlatformContentDef {
declare interface PlatformPostDef extends PlatformContentDef {
thumbnails: string[],
images: string[],
description: string
@@ -93,7 +135,7 @@ declare class PlatformPost extends PlatformContent {
constructor(obj: PlatformPostDef)
}
declare class PlatformPostDetailsDef extends PlatformPostDef {
declare interface PlatformPostDetailsDef extends PlatformPostDef {
rating: IRating,
textType: int,
content: String
@@ -110,8 +152,8 @@ declare interface MuxVideoSourceDescriptorDef {
isUnMuxed: boolean,
videoSources: VideoSource[]
}
declare class MuxVideoSourceDescriptor implements IVideoSourceDescriptor {
constructor(obj: VideoSourceDescriptorDef);
declare class VideoSourceDescriptor implements IVideoSourceDescriptor {
constructor(videoSourcesOrObj: VideoSource[]);
}
declare interface UnMuxVideoSourceDescriptorDef {
@@ -129,7 +171,7 @@ declare interface IVideoSource {
declare interface IAudioSource {
}
interface VideoUrlSourceDef implements IVideoSource {
declare interface VideoUrlSourceDef implements IVideoSource {
width: integer,
height: integer,
container: string,
@@ -139,22 +181,22 @@ interface VideoUrlSourceDef implements IVideoSource {
duration: integer,
url: string
}
class VideoUrlSource {
declare class VideoUrlSource {
constructor(obj: VideoUrlSourceDef);
getRequestModifier(): RequestModifier?;
}
interface VideoUrlRangeSourceDef extends VideoUrlSource {
declare interface VideoUrlRangeSourceDef extends VideoUrlSource {
itagId: integer,
initStart: integer,
initEnd: integer,
indexStart: integer,
indexEnd: integer,
}
class VideoUrlRangeSource extends VideoUrlSource {
declare class VideoUrlRangeSource extends VideoUrlSource {
constructor(obj: YTVideoSourceDef);
}
interface AudioUrlSourceDef {
declare interface AudioUrlSourceDef {
name: string,
bitrate: integer,
container: string,
@@ -163,24 +205,12 @@ interface AudioUrlSourceDef {
url: string,
language: string
}
class AudioUrlSource implements IAudioSource {
declare class AudioUrlSource implements IAudioSource {
constructor(obj: AudioUrlSourceDef);
getRequestModifier(): RequestModifier?;
}
interface IRequest {
url: string,
headers: Map<string, string>
}
interface IRequestModifierDef {
allowByteSkip: boolean
}
class RequestModifier {
constructor(obj: IRequestModifierDef) { }
modifyRequest(url: string, headers: Map<string, string>): IRequest;
}
interface AudioUrlRangeSourceDef extends AudioUrlSource {
declare interface AudioUrlRangeSourceDef extends AudioUrlSource {
itagId: integer,
initStart: integer,
initEnd: integer,
@@ -188,28 +218,44 @@ interface AudioUrlRangeSourceDef extends AudioUrlSource {
indexEnd: integer,
audioChannels: integer
}
class AudioUrlRangeSource extends AudioUrlSource {
declare class AudioUrlRangeSource extends AudioUrlSource {
constructor(obj: AudioUrlRangeSourceDef);
}
interface HLSSourceDef {
declare interface HLSSourceDef {
name: string,
duration: integer,
url: string
url: string,
priority: boolean?,
language: string?
}
class HLSSource implements IVideoSource {
declare class HLSSource implements IVideoSource {
constructor(obj: HLSSourceDef);
}
interface DashSourceDef {
declare interface DashSourceDef {
name: string,
duration: integer,
url: string
url: string,
language: string?
}
class DashSource implements IVideoSource {
declare class DashSource implements IVideoSource {
constructor(obj: DashSourceDef)
}
declare interface IRequest {
url: string,
headers: Map<string, string>
}
declare interface IRequestModifierDef {
allowByteSkip: boolean
}
declare class RequestModifier {
constructor(obj: IRequestModifierDef) { }
modifyRequest(url: string, headers: Map<string, string>): IRequest;
}
//Channel
interface PlatformChannelDef {
declare interface PlatformChannelDef {
id: PlatformID,
name: string,
thumbnail: string,
@@ -217,12 +263,29 @@ interface PlatformChannelDef {
subscribers: integer,
description: string,
url: string,
urlAlternatives: string[],
links: Map<string>?
}
class PlatformChannel {
declare class PlatformChannel {
constructor(obj: PlatformChannelDef);
}
//Playlist
declare interface PlatformPlaylistDef implements PlatformContent {
videoCount: integer,
thumbnail: string
}
declare class PlatformPlaylist extends PlatformContent {
constructor(obj: PlatformPlaylistDef);
}
declare interface PlatformPlaylistDetailsDef implements PlatformPlaylistDef {
contents: ContentPager
}
declare class PlatformPlaylistDetails extends PlatformContent {
constructor(obj: PlatformPlaylistDetailsDef);
}
//Ratings
interface IRating {
type: integer
@@ -250,7 +313,11 @@ declare class PlatformComment {
constructor(obj: CommentDef);
}
declare class PlaybackTracker {
constructor(interval: integer);
setProgress(seconds: integer);
}
declare class LiveEventPager {
nextRequest = 4000;
@@ -261,8 +328,8 @@ declare class LiveEventPager {
nextPage(): LiveEventPager; //Could be self
}
class LiveEvent {
type: String
declare class LiveEvent {
constructor(type: integer);
}
declare class LiveEventComment extends LiveEvent {
constructor(name: string, message: string, thumbnail: string?, colorName: string?, badges: string[]);
@@ -287,25 +354,31 @@ declare class ContentPager {
constructor(results: PlatformContent[], hasMore: boolean);
hasMorePagers(): boolean
nextPage(): VideoPager; //Could be self
nextPage(): ContentPager?; //Could be self
}
declare class VideoPager {
constructor(results: PlatformVideo[], hasMore: boolean);
hasMorePagers(): boolean
nextPage(): VideoPager; //Could be self
nextPage(): VideoPager?; //Could be self
}
declare class ChannelPager {
constructor(results: PlatformChannel[], hasMore: boolean);
hasMorePagers(): boolean;
nextPage(): ChannelPager; //Could be self
nextPage(): ChannelPager?; //Could be self
}
declare class PlaylistPager {
constructor(results: PlatformPlaylist[], hasMore: boolean);
hasMorePagers(): boolean;
nextPage(): PlaylistPager?;
}
declare class CommentPager {
constructor(results: PlatformComment[], hasMore: boolean);
hasMorePagers(): boolean
nextPage(): CommentPager; //Could be self
nextPage(): CommentPager?; //Could be self
}
interface Map<T> {
@@ -341,8 +414,9 @@ interface Source {
getChannelCapabilities(): ResultCapabilities;
isContentDetailsUrl(url: string): boolean;
getContentDetails(url: string): PlatformVideoDetails;
getContentDetails(url: string): PlatformContentDetails;
//Optional
getLiveEvents(url: string): LiveEventPager;
//Optional
+16 -3
View File
@@ -78,6 +78,11 @@ class ScriptLoginRequiredException extends ScriptException {
super("ScriptLoginRequiredException", msg);
}
}
class LoginRequiredException extends ScriptException {
constructor(msg) {
super("ScriptLoginRequiredException", msg);
}
}
class CaptchaRequiredException extends Error {
constructor(url, body) {
super(JSON.stringify({ 'plugin_type': 'CaptchaRequiredException', url, body }));
@@ -249,8 +254,8 @@ class PlatformVideoDetails extends PlatformVideo {
this.description = obj.description ?? "";//String
this.video = obj.video ?? {}; //VideoSourceDescriptor
this.dash = obj.dash ?? null; //DashSource
this.hls = obj.hls ?? null; //HLSSource
this.dash = obj.dash ?? null; //DashSource, deprecated
this.hls = obj.hls ?? null; //HLSSource, deprecated
this.live = obj.live ?? null; //VideoSource
this.rating = obj.rating ?? null; //IRating
@@ -321,6 +326,8 @@ class VideoUrlSource {
this.bitrate = obj.bitrate ?? 0;
this.duration = obj.duration ?? 0;
this.url = obj.url;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
}
}
class VideoUrlRangeSource extends VideoUrlSource {
@@ -346,6 +353,8 @@ class AudioUrlSource {
this.duration = obj.duration ?? 0;
this.url = obj.url;
this.language = obj.language ?? Language.UNKNOWN;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
}
}
class AudioUrlRangeSource extends AudioUrlSource {
@@ -371,6 +380,8 @@ class HLSSource {
this.priority = obj.priority ?? false;
if(obj.language)
this.language = obj.language;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
}
}
class DashSource {
@@ -382,13 +393,15 @@ class DashSource {
this.url = obj.url;
if(obj.language)
this.language = obj.language;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
}
}
class RequestModifier {
constructor(obj) {
obj = obj ?? {};
this.allowByteSkip = obj.allowByteSkip;
this.allowByteSkip = obj.allowByteSkip; //Kinda deprecated.. wip
}
}
@@ -1,5 +1,6 @@
package com.futo.platformplayer
import android.util.Log
import com.google.common.base.CharMatcher
import java.io.ByteArrayOutputStream
import java.io.IOException
@@ -9,7 +10,6 @@ import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.nio.ByteBuffer
import java.nio.charset.Charset
private const val IPV4_PART_COUNT = 4;
@@ -216,15 +216,20 @@ private fun ByteArray.toInetAddress(): InetAddress {
}
fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
val timeout = 2000
if (addresses.isEmpty()) {
return null;
}
if (addresses.size == 1) {
val socket = Socket()
try {
return Socket(addresses[0], port);
return socket.apply { this.connect(InetSocketAddress(addresses[0], port), timeout) }
} catch (e: Throwable) {
//Ignored.
Log.i("getConnectedSocket", "Failed to connect to: ${addresses[0]}", e)
socket.close()
}
return null;
@@ -249,7 +254,7 @@ fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
}
}
socket.connect(InetSocketAddress(address, port));
socket.connect(InetSocketAddress(address, port), timeout);
synchronized(syncObject) {
if (connectedSocket == null) {
@@ -263,7 +268,7 @@ fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
}
}
} catch (e: Throwable) {
//Ignore
Log.i("getConnectedSocket", "Failed to connect to: $address", e)
}
};
@@ -685,7 +685,9 @@ class Settings : FragmentedStorageFileJson() {
fun manualCheck() {
if (!BuildConfig.IS_PLAYSTORE_BUILD) {
SettingsActivity.getActivity()?.let {
StateUpdate.instance.checkForUpdates(it, true);
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
StateUpdate.instance.checkForUpdates(it, true)
}
}
} else {
SettingsActivity.getActivity()?.let {
@@ -37,6 +37,7 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.views.ToastView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -398,13 +399,28 @@ class UIDialogs {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
try {
StateApp.withContext {
Toast.makeText(it, text, if (long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT).show();
toast(it, text, long);
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to show toast.", e);
}
}
}
fun appToast(text: String, long: Boolean = false) {
appToast(ToastView.Toast(text, long))
}
fun appToastError(text: String, long: Boolean) {
StateApp.withContext {
appToast(ToastView.Toast(text, long, it.getColor(R.color.pastel_red)));
};
}
fun appToast(toast: ToastView.Toast) {
StateApp.withContext {
if(it is MainActivity) {
it.showAppToast(toast);
}
}
}
fun showClickableToast(context: Context, text: String, onClick: () -> Unit, isLongDuration: Boolean = false) {
//TODO: Is not actually clickable...
@@ -343,7 +343,7 @@ class UISlideOverlays {
videoSources.filter { it is IVideoUrlSource && it.isDownloadable() }.asIterable(),
Settings.instance.downloads.getDefaultVideoQualityPixels(),
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS
) as IVideoUrlSource;
) as IVideoUrlSource?;
}
if (audioSources != null) {
@@ -216,8 +216,10 @@ class AddSourceActivity : AppCompatActivity() {
fun install(config: SourcePluginConfig, script: String) {
StatePlugins.instance.installPlugin(this, lifecycleScope, config, script) {
if(it)
if(it) {
StatePlatform.instance.clearUpdateAvailable(config)
backToSources();
}
}
}
@@ -9,9 +9,11 @@ import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R
import com.futo.platformplayer.logging.LogLevel
import com.futo.platformplayer.logging.Logging
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -10,6 +10,7 @@ import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.platforms.js.SourceAuth
import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
@@ -39,6 +40,7 @@ class LoginActivity : AppCompatActivity() {
_textUrl = findViewById(R.id.text_url);
_buttonClose = findViewById(R.id.button_close);
_buttonClose.setOnClickListener {
UIDialogs.toast("Login cancelled", false);
finish();
}
@@ -1,7 +1,6 @@
package com.futo.platformplayer.activities
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
@@ -24,6 +23,7 @@ import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentContainerView
import androidx.lifecycle.Lifecycle
@@ -45,6 +45,7 @@ import com.futo.platformplayer.states.*
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.SubscriptionStorage
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.views.ToastView
import com.google.gson.JsonParser
import com.google.zxing.integration.android.IntentIntegrator
import kotlinx.coroutines.*
@@ -54,6 +55,7 @@ import java.io.PrintWriter
import java.io.StringWriter
import java.lang.reflect.InvocationTargetException
import java.util.*
import java.util.concurrent.ConcurrentLinkedQueue
class MainActivity : AppCompatActivity, IWithResultLauncher {
@@ -65,6 +67,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var rootView : MotionLayout;
private lateinit var _overlayContainer: FrameLayout;
private lateinit var _toastView: ToastView;
//Segment Containers
private lateinit var _fragContainerTopBar: FragmentContainerView;
@@ -207,7 +210,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragContainerVideoDetail = findViewById(R.id.fragment_overlay);
_fragContainerOverlay = findViewById(R.id.fragment_overlay_container);
_overlayContainer = findViewById(R.id.overlay_container);
//_overlayContainer.visibility = View.GONE;
_toastView = findViewById(R.id.toast_view);
//Initialize fragments
@@ -478,21 +481,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
_isVisible = true;
val videoToOpen = StateSaved.instance.videoToOpen;
if (_wasStopped) {
_wasStopped = false;
if (videoToOpen != null && _fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
Logger.i(TAG, "onResume videoToOpen=$videoToOpen");
if (StatePlatform.instance.hasEnabledVideoClient(videoToOpen.url)) {
navigate(_fragVideoDetail, UrlVideoWithTime(videoToOpen.url, videoToOpen.timeSeconds, false));
_fragVideoDetail.maximizeVideoDetail(true);
}
StateSaved.instance.setVideoToOpenNonBlocking(null);
}
}
}
override fun onPause() {
@@ -864,7 +852,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_orientationManager.disable();
StateApp.instance.mainAppDestroyed(this);
StateSaved.instance.setVideoToOpenBlocking(null);
}
inline fun <reified T> isFragmentActive(): Boolean {
@@ -1052,6 +1039,43 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
private val _toastQueue = ConcurrentLinkedQueue<ToastView.Toast>();
private var _toastJob: Job? = null;
fun showAppToast(toast: ToastView.Toast) {
synchronized(_toastQueue) {
_toastQueue.add(toast);
if(_toastJob?.isActive != true)
_toastJob = lifecycleScope.launch(Dispatchers.Default) {
launchAppToastJob();
};
}
}
private suspend fun launchAppToastJob() {
Logger.i(TAG, "Starting appToast loop");
while(!_toastQueue.isEmpty()) {
val toast = _toastQueue.poll() ?: continue;
Logger.i(TAG, "Showing next toast (${toast.msg})");
lifecycleScope.launch(Dispatchers.Main) {
if (!_toastView.isVisible) {
Logger.i(TAG, "First showing toast");
_toastView.setToast(toast);
_toastView.show(true);
} else {
_toastView.setToastAnimated(toast);
}
}
if(toast.long)
delay(5000);
else
delay(3000);
}
Logger.i(TAG, "Ending appToast loop");
lifecycleScope.launch(Dispatchers.Main) {
_toastView.hide(true) {
};
}
}
//TODO: Only calls last handler due to missing request codes on ActivityResultLaunchers.
@@ -3,4 +3,5 @@ package com.futo.platformplayer.api.media.models.modifier
interface IModifierOptions {
val applyAuthClient: String?;
val applyCookieClient: String?;
val applyOtherHeaders: Boolean;
}
@@ -11,4 +11,5 @@ class SourcePluginAuthConfig(
val userAgent: String? = null,
val loginButton: String? = null,
val domainHeadersToFind: Map<String, List<String>>? = null,
val loginWarning: String? = null
) { }
@@ -23,21 +23,31 @@ class JSRequest : IRequest {
_v8Options = options;
initialize(plugin, originalUrl, originalHeaders);
}
constructor(plugin: JSClient, obj: V8ValueObject, originalUrl: String?, originalHeaders: Map<String, String>?) {
constructor(plugin: JSClient, obj: V8ValueObject, originalUrl: String?, originalHeaders: Map<String, String>?, applyOtherHeadersByDefault: Boolean = false) {
val contextName = "ModifyRequestResponse";
val config = plugin.config;
_v8Url = obj.getOrDefault<String>(config, "url", contextName, null);
_v8Headers = obj.getOrDefault<Map<String, String>>(config, "headers", contextName, null);
_v8Options = obj.getOrDefault<V8ValueObject>(config, "options", "JSRequestModifier.options", null)?.let {
Options(config, it);
}
Options(config, it, applyOtherHeadersByDefault);
} ?: Options(null, null, applyOtherHeadersByDefault);
initialize(plugin, originalUrl, originalHeaders);
}
private fun initialize(plugin: JSClient, originalUrl: String?, originalHeaders: Map<String, String>?) {
val config = plugin.config;
url = _v8Url ?: originalUrl;
headers = _v8Headers ?: originalHeaders ?: mapOf();
if(_v8Options?.applyOtherHeaders ?: false) {
val headersToSet = _v8Headers?.toMutableMap() ?: mutableMapOf();
if (originalHeaders != null)
for (head in originalHeaders)
if (!headersToSet.containsKey(head.key))
headersToSet[head.key] = head.value;
headers = headersToSet;
}
else
headers = _v8Headers ?: originalHeaders ?: mapOf();
if(_v8Options != null) {
if(_v8Options.applyCookieClient != null && url != null) {
@@ -68,10 +78,18 @@ class JSRequest : IRequest {
class Options: IModifierOptions {
override val applyAuthClient: String?;
override val applyCookieClient: String?;
override val applyOtherHeaders: Boolean;
constructor(config: IV8PluginConfig, obj: V8ValueObject) {
constructor(config: IV8PluginConfig, obj: V8ValueObject, applyOtherHeadersByDefault: Boolean = false) {
applyAuthClient = obj.getOrDefault(config, "applyAuthClient", "JSRequestModifier.options.applyAuthClient", null);
applyCookieClient = obj.getOrDefault(config, "applyCookieClient", "JSRequestModifier.options.applyCookieClient", null);
applyOtherHeaders = obj.getOrDefault(config, "applyOtherHeaders", "JSRequestModifier.options.applyOtherHeaders", applyOtherHeadersByDefault) ?: applyOtherHeadersByDefault;
}
constructor(applyAuthClient: String? = null, applyCookieClient: String? = null, applyOtherHeaders: Boolean = false) {
this.applyAuthClient = applyAuthClient;
this.applyCookieClient = applyCookieClient;
this.applyOtherHeaders = applyOtherHeaders;
}
}
@@ -40,6 +40,7 @@ class JSRequestModifier: IRequestModifier {
} as V8ValueObject;
val req = JSRequest(_plugin, result, url, headers);
result.close();
return req;
}
@@ -33,7 +33,7 @@ abstract class JSSource {
this.type = type;
_requestModifier = obj.getOrDefault<V8ValueObject>(_config, "requestModifier", "JSSource.requestModifier", null)?.let {
JSRequest(plugin, it, null, null);
JSRequest(plugin, it, null, null, true);
}
hasRequestModifier = _requestModifier != null || obj.has("getRequestModifier");
}
@@ -138,7 +138,7 @@ class AirPlayCastingDevice : CastingDevice {
try {
val connectedSocket = getConnectedSocket(adrs.toList(), port);
if (connectedSocket == null) {
delay(3000);
delay(1000);
continue;
}
@@ -17,6 +17,8 @@ import org.json.JSONObject
import java.io.DataInputStream
import java.io.DataOutputStream
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocket
@@ -303,17 +305,18 @@ class ChromecastCastingDevice : CastingDevice {
_thread = Thread {
connectionState = CastConnectionState.CONNECTING;
var connectedSocket: Socket? = null
while (_scopeIO?.isActive == true) {
try {
val connectedSocket = getConnectedSocket(adrs.toList(), port);
if (connectedSocket == null) {
Thread.sleep(3000);
val resultSocket = getConnectedSocket(adrs.toList(), port);
if (resultSocket == null) {
Thread.sleep(1000);
continue;
}
connectedSocket = resultSocket
usedRemoteAddress = connectedSocket.inetAddress;
localAddress = connectedSocket.localAddress;
connectedSocket.close();
break;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
@@ -325,6 +328,8 @@ class ChromecastCastingDevice : CastingDevice {
val factory = sslContext.socketFactory;
val address = InetSocketAddress(usedRemoteAddress, port)
//Connection loop
while (_scopeIO?.isActive == true) {
Logger.i(TAG, "Connecting to Chromecast.");
@@ -332,7 +337,16 @@ class ChromecastCastingDevice : CastingDevice {
try {
_socket?.close()
_socket = factory.createSocket(usedRemoteAddress, port) as SSLSocket;
if (connectedSocket != null) {
Logger.i(TAG, "Using connected socket.")
_socket = factory.createSocket(connectedSocket, connectedSocket.inetAddress.hostAddress, connectedSocket.port, true) as SSLSocket
connectedSocket = null
} else {
Logger.i(TAG, "Using new socket.")
val s = Socket().apply { this.connect(address, 2000) }
_socket = factory.createSocket(s, s.inetAddress.hostAddress, s.port, true) as SSLSocket
}
_socket?.startHandshake();
Logger.i(TAG, "Successfully connected to Chromecast at $usedRemoteAddress:$port");
@@ -347,7 +361,7 @@ class ChromecastCastingDevice : CastingDevice {
Logger.i(TAG, "Failed to connect to Chromecast.", e);
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(3000);
Thread.sleep(1000);
continue;
}
@@ -363,7 +377,7 @@ class ChromecastCastingDevice : CastingDevice {
_socket?.close();
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(3000);
Thread.sleep(1000);
continue;
}
@@ -415,7 +429,7 @@ class ChromecastCastingDevice : CastingDevice {
Logger.i(TAG, "Socket disconnected.");
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(3000);
Thread.sleep(1000);
}
Logger.i(TAG, "Stopped connection loop.");
@@ -432,10 +446,11 @@ class ChromecastCastingDevice : CastingDevice {
while (_scopeIO?.isActive == true) {
try {
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.heartbeat", pingObject.toString());
Thread.sleep(5000);
} catch (e: Throwable) {
Log.w(TAG, "Failed to send ping.");
}
Thread.sleep(5000);
}
Logger.i(TAG, "Stopped ping loop.");
@@ -27,11 +27,12 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.math.BigInteger
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.security.KeyFactory
import java.security.KeyPair
@@ -81,12 +82,15 @@ class FCastCastingDevice : CastingDevice {
var port: Int = 0;
private var _socket: Socket? = null;
private var _outputStream: DataOutputStream? = null;
private var _inputStream: DataInputStream? = null;
private var _outputStream: OutputStream? = null;
private var _inputStream: InputStream? = null;
private var _scopeIO: CoroutineScope? = null;
private var _started: Boolean = false;
private var _version: Long = 1;
private var _thread: Thread? = null
private var _pingThread: Thread? = null
private var _lastPongTime = -1L
private var _outputStreamLock = Object()
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name;
@@ -206,7 +210,13 @@ class FCastCastingDevice : CastingDevice {
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
if(Looper.getMainLooper().thread == Thread.currentThread()) {
_scopeIO?.launch { action(); }
_scopeIO?.launch {
try {
action();
} catch (e: Throwable) {
Logger.e(TAG, "Failed to invoke in IO scope.", e)
}
}
return true;
}
@@ -240,77 +250,103 @@ class FCastCastingDevice : CastingDevice {
val adrs = addresses ?: return;
val thread = _thread
if (thread == null || !thread.isAlive) {
Log.i(TAG, "Restarting thread because the thread has died")
val pingThread = _pingThread
if (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive) {
Log.i(TAG, "(Re)starting thread because the thread has died")
_scopeIO?.let {
it.cancel()
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
}
_scopeIO?.cancel();
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
_scopeIO = CoroutineScope(Dispatchers.IO);
_thread = Thread {
connectionState = CastConnectionState.CONNECTING;
Log.i(TAG, "Connection thread started.")
var connectedSocket: Socket? = null
while (_scopeIO?.isActive == true) {
try {
val connectedSocket = getConnectedSocket(adrs.toList(), port);
if (connectedSocket == null) {
Thread.sleep(3000);
Log.i(TAG, "getConnectedSocket (adrs = [ ${adrs.joinToString(", ")} ], port = ${port}).")
val resultSocket = getConnectedSocket(adrs.toList(), port);
if (resultSocket == null) {
Log.i(TAG, "Connection failed, waiting 1 seconds.")
Thread.sleep(1000);
continue;
}
usedRemoteAddress = connectedSocket.inetAddress;
localAddress = connectedSocket.localAddress;
connectedSocket.close();
Log.i(TAG, "Connection succeeded.")
connectedSocket = resultSocket
usedRemoteAddress = connectedSocket.inetAddress
localAddress = connectedSocket.localAddress
break;
} catch (e: Throwable) {
Logger.w(ChromecastCastingDevice.TAG, "Failed to get setup initial connection to FastCast device.", e)
Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e)
}
}
val address = InetSocketAddress(usedRemoteAddress, port)
//Connection loop
while (_scopeIO?.isActive == true) {
Logger.i(TAG, "Connecting to FastCast.");
connectionState = CastConnectionState.CONNECTING;
try {
_socket = Socket(usedRemoteAddress, port);
_socket?.close()
if (connectedSocket != null) {
Logger.i(TAG, "Using connected socket.");
_socket = connectedSocket
connectedSocket = null
} else {
Logger.i(TAG, "Using new socket.");
_socket = Socket().apply { this.connect(address, 2000) };
}
Logger.i(TAG, "Successfully connected to FastCast at $usedRemoteAddress:$port");
_outputStream = DataOutputStream(_socket?.outputStream);
_inputStream = DataInputStream(_socket?.inputStream);
_outputStream = _socket?.outputStream;
_inputStream = _socket?.inputStream;
} catch (e: IOException) {
_socket?.close();
Logger.i(TAG, "Failed to connect to FastCast.", e);
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(3000);
Thread.sleep(1000);
continue;
}
localAddress = _socket?.localAddress;
connectionState = CastConnectionState.CONNECTED;
_lastPongTime = -1L
val buffer = ByteArray(4096);
Logger.i(TAG, "Started receiving.");
var exceptionOccurred = false;
while (_scopeIO?.isActive == true && !exceptionOccurred) {
while (_scopeIO?.isActive == true) {
try {
val inputStream = _inputStream ?: break;
Log.d(TAG, "Receiving next packet...");
val b1 = inputStream.readUnsignedByte();
val b2 = inputStream.readUnsignedByte();
val b3 = inputStream.readUnsignedByte();
val b4 = inputStream.readUnsignedByte();
val size = ((b4.toLong() shl 24) or (b3.toLong() shl 16) or (b2.toLong() shl 8) or b1.toLong()).toInt();
var headerBytesRead = 0
while (headerBytesRead < 4) {
headerBytesRead += inputStream.read(buffer, headerBytesRead, 4 - headerBytesRead)
}
val size = ((buffer[3].toLong() shl 24) or (buffer[2].toLong() shl 16) or (buffer[1].toLong() shl 8) or buffer[0].toLong()).toInt();
if (size > buffer.size) {
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
inputStream.skip(size.toLong());
continue;
Logger.w(TAG, "Packets larger than $size bytes are not supported.")
break
}
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
inputStream.read(buffer, 0, size);
var bytesRead = 0
while (bytesRead < size) {
bytesRead += inputStream.read(buffer, bytesRead, size - bytesRead)
}
val messageBytes = buffer.sliceArray(IntRange(0, size));
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
@@ -324,31 +360,65 @@ class FCastCastingDevice : CastingDevice {
try {
handleMessage(Opcode.find(opcode), json);
} catch (e: Throwable) {
Logger.w(TAG, "Failed to handle message.", e);
Logger.w(TAG, "Failed to handle message.", e)
break
}
} catch (e: java.net.SocketException) {
Logger.e(TAG, "Socket exception while receiving.", e);
exceptionOccurred = true;
break
} catch (e: Throwable) {
Logger.e(TAG, "Exception while receiving.", e);
exceptionOccurred = true;
break
}
}
try {
_socket?.close();
_socket?.close()
_socket = null
Logger.i(TAG, "Socket disconnected.");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to close socket.", e)
}
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(3000);
Thread.sleep(1000);
}
Logger.i(TAG, "Stopped connection loop.");
connectionState = CastConnectionState.DISCONNECTED;
}.apply { start() };
}.apply { start() }
_pingThread = Thread {
Logger.i(TAG, "Started ping loop.")
while (_scopeIO?.isActive == true) {
try {
send(Opcode.Ping)
} catch (e: Throwable) {
Log.w(TAG, "Failed to send ping.")
try {
_socket?.close()
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
}
}
if (_lastPongTime != -1L && System.currentTimeMillis() - _lastPongTime > 6000) {
Logger.w(TAG, "Closing socket due to last pong time being larger than 6 seconds.")
try {
_socket?.close()
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
}
}
Thread.sleep(2000)
}
Logger.i(TAG, "Stopped ping loop.");
}.apply { start() }
} else {
Log.i(TAG, "Thread was still alive, not restarted")
}
@@ -401,39 +471,42 @@ class FCastCastingDevice : CastingDevice {
Logger.i(TAG, "Remote version received: $version")
}
Opcode.Ping -> send(Opcode.Pong)
Opcode.Pong -> _lastPongTime = System.currentTimeMillis()
else -> { }
}
}
private fun send(opcode: Opcode, message: String? = null) {
try {
val data: ByteArray = message?.encodeToByteArray() ?: ByteArray(0)
val size = 1 + data.size
val outputStream = _outputStream
if (outputStream == null) {
Log.w(TAG, "Failed to send $size bytes, output stream is null.")
return
synchronized (_outputStreamLock) {
try {
val data: ByteArray = message?.encodeToByteArray() ?: ByteArray(0)
val size = 1 + data.size
val outputStream = _outputStream
if (outputStream == null) {
Log.w(TAG, "Failed to send $size bytes, output stream is null.")
return
}
val serializedSizeLE = ByteArray(4)
serializedSizeLE[0] = (size and 0xff).toByte()
serializedSizeLE[1] = (size shr 8 and 0xff).toByte()
serializedSizeLE[2] = (size shr 16 and 0xff).toByte()
serializedSizeLE[3] = (size shr 24 and 0xff).toByte()
outputStream.write(serializedSizeLE)
val opcodeBytes = ByteArray(1)
opcodeBytes[0] = opcode.value
outputStream.write(opcodeBytes)
if (data.isNotEmpty()) {
outputStream.write(data)
}
Log.d(TAG, "Sent $size bytes: (opcode: $opcode, body: $message).")
} catch (e: Throwable) {
Log.i(TAG, "Failed to send message.", e)
throw e
}
val serializedSizeLE = ByteArray(4)
serializedSizeLE[0] = (size and 0xff).toByte()
serializedSizeLE[1] = (size shr 8 and 0xff).toByte()
serializedSizeLE[2] = (size shr 16 and 0xff).toByte()
serializedSizeLE[3] = (size shr 24 and 0xff).toByte()
outputStream.write(serializedSizeLE)
val opcodeBytes = ByteArray(1)
opcodeBytes[0] = opcode.value
outputStream.write(opcodeBytes)
if (data.isNotEmpty()) {
outputStream.write(data)
}
Log.d(TAG, "Sent $size bytes: (opcode: $opcode, body: $message).")
} catch (e: Throwable) {
Log.i(TAG, "Failed to send message.", e)
throw e
}
}
@@ -453,6 +526,7 @@ class FCastCastingDevice : CastingDevice {
_started = false;
//TODO: Kill and/or join thread?
_thread = null;
_pingThread = null;
val socket = _socket;
val scopeIO = _scopeIO;
@@ -7,6 +7,7 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.widget.Button
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.LinearLayoutManager
@@ -26,8 +27,8 @@ import kotlinx.coroutines.launch
class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
private lateinit var _imageLoader: ImageView;
private lateinit var _buttonClose: Button;
private lateinit var _buttonAdd: Button;
private lateinit var _buttonScanQR: Button;
private lateinit var _buttonAdd: ImageButton;
private lateinit var _buttonScanQR: ImageButton;
private lateinit var _textNoDevicesFound: TextView;
private lateinit var _textNoDevicesRemembered: TextView;
private lateinit var _recyclerDevices: RecyclerView;
@@ -143,7 +143,9 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
StateCasting.instance.onActiveDeviceDurationChanged.remove(this);
StateCasting.instance.onActiveDeviceDurationChanged.subscribe {
_sliderPosition.valueTo = it.toFloat().coerceAtLeast(1.0f);
val dur = it.toFloat().coerceAtLeast(1.0f)
_sliderPosition.value = _sliderPosition.value.coerceAtLeast(0.0f).coerceAtMost(dur);
_sliderPosition.valueTo = dur
};
_device = StateCasting.instance.activeDevice;
@@ -185,8 +187,10 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_sliderPosition.valueFrom = 0.0f;
_sliderVolume.valueFrom = 0.0f;
_sliderVolume.value = d.volume.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo);
_sliderPosition.valueTo = d.duration.toFloat().coerceAtLeast(1.0f);
_sliderPosition.value = d.time.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo);
val dur = d.duration.toFloat().coerceAtLeast(1.0f)
_sliderPosition.value = d.time.toFloat().coerceAtLeast(0.0f).coerceAtMost(dur)
_sliderPosition.valueTo = dur
if (d.canSetVolume) {
_layoutVolumeAdjustable.visibility = View.VISIBLE;
@@ -337,8 +337,10 @@ class VideoDownload {
});
}
var wasSuccesful = false;
try {
awaitAll(*sourcesToDownload.toTypedArray());
wasSuccesful = true;
}
catch(runtimeEx: RuntimeException) {
if(runtimeEx.cause != null)
@@ -349,6 +351,29 @@ class VideoDownload {
catch(ex: Throwable) {
throw ex;
}
finally {
if(!wasSuccesful) {
try {
if(videoFilePath != null) {
val remainingVideo = File(videoFilePath!!);
if (remainingVideo.exists()) {
Logger.i(TAG, "Deleting remaining video file");
remainingVideo.delete();
}
}
if(audioFilePath != null) {
val remainingAudio = File(audioFilePath!!);
if (remainingAudio.exists()) {
Logger.i(TAG, "Deleting remaining audio file");
remainingAudio.delete();
}
}
}
catch(iex: Throwable) {
Logger.e(TAG, "Failed to delete files after failure:\n${iex.message}", iex);
}
}
}
}
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
@@ -418,6 +418,7 @@ class ChannelFragment : MainFragment() {
_buttonSubscribe.setSubscribeChannel(channel);
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
_textChannel.text = channel.name;
_textChannelSub.text = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} " + context.getString(R.string.subscribers).lowercase() else "";
//TODO: Find a better way to access the adapter fragments..
@@ -354,11 +354,22 @@ class SourceDetailFragment : MainFragment() {
if(config.authentication == null)
return;
LoginActivity.showLogin(StateApp.instance.context, config) {
StatePlugins.instance.setPluginAuth(config.id, it);
reloadSource(config.id);
};
if(config.authentication.loginWarning != null) {
UIDialogs.showDialog(context, R.drawable.ic_warning_yellow, "Login Warning",
config.authentication.loginWarning, null, 0,
UIDialogs.Action("Cancel", {}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Login", {
LoginActivity.showLogin(StateApp.instance.context, config) {
StatePlugins.instance.setPluginAuth(config.id, it);
reloadSource(config.id);
};
}, UIDialogs.ActionStyle.PRIMARY))
}
else
LoginActivity.showLogin(StateApp.instance.context, config) {
StatePlugins.instance.setPluginAuth(config.id, it);
reloadSource(config.id);
};
}
private fun logoutSource(clear: Boolean = true) {
val config = _config ?: return;
@@ -454,6 +465,7 @@ class SourceDetailFragment : MainFragment() {
}
});
}
private fun checkForUpdatesSource() {
val c = _config ?: return;
val sourceUrl = c.sourceUrl ?: return;
@@ -10,6 +10,7 @@ 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.IPlatformClient
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
@@ -108,16 +109,6 @@ class SubscriptionsFeedFragment : MainFragment() {
constructor(fragment: SubscriptionsFeedFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
Logger.i(TAG, "SubscriptionsFeedFragment constructor()");
StateSubscriptions.instance.onFeedProgress.subscribe(this) { id, progress, total ->
if(subGroup?.id == id)
fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
setProgress(progress, total);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to set progress", e);
}
}
}
StateSubscriptions.instance.global.onUpdateProgress.subscribe(this) { progress, total ->
};
@@ -173,12 +164,24 @@ class SubscriptionsFeedFragment : MainFragment() {
if (!StateSubscriptions.instance.global.isGlobalUpdating) {
finishRefreshLayoutLoader();
}
StateSubscriptions.instance.onFeedProgress.subscribe(this) { id, progress, total ->
if(subGroup?.id == id)
fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
setProgress(progress, total);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to set progress", e);
}
}
}
}
override fun cleanup() {
super.cleanup()
StateSubscriptions.instance.global.onUpdateProgress.remove(this);
StateSubscriptions.instance.onSubscriptionsChanged.remove(this);
StateSubscriptions.instance.onFeedProgress.remove(this);
}
override val feedStyle: FeedStyle get() = Settings.instance.subscriptions.getSubscriptionsFeedStyle();
@@ -427,7 +430,7 @@ class SubscriptionsFeedFragment : MainFragment() {
context?.let {
fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
if (exs.size <= 8) {
if (exs.size <= 3) {
for (ex in exs) {
var toShow = ex;
var channel: String? = null;
@@ -437,12 +440,11 @@ class SubscriptionsFeedFragment : MainFragment() {
}
Logger.e(TAG, "Channel [${channel}] failed", ex);
if (toShow is PluginException)
UIDialogs.toast(
it,
UIDialogs.appToast(
context.getString(R.string.plugin_pluginname_failed_message).replace("{pluginName}", toShow.config.name).replace("{message}", toShow.message ?: "")
);
else
UIDialogs.toast(it, ex.message ?: "");
UIDialogs.appToast(ex.message ?: "");
}
}
else {
@@ -453,7 +455,7 @@ class SubscriptionsFeedFragment : MainFragment() {
.map { it!! }
.toList();
for(distinctPluginFail in failedPlugins)
UIDialogs.toast(it, context.getString(R.string.plugin_pluginname_failed_message).replace("{pluginName}", distinctPluginFail.config.name).replace("{message}", distinctPluginFail.message ?: ""));
UIDialogs.appToast(context.getString(R.string.plugin_pluginname_failed_message).replace("{pluginName}", distinctPluginFail.config.name).replace("{message}", distinctPluginFail.message ?: ""));
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle exceptions", e)
@@ -25,8 +25,6 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.PlatformVideoWithTime
import com.futo.platformplayer.models.UrlVideoWithTime
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StateSaved
import com.futo.platformplayer.states.VideoToOpen
import com.futo.platformplayer.views.containers.SingleViewTouchableMotionLayout
class VideoDetailFragment : MainFragment {
@@ -372,11 +370,6 @@ class VideoDetailFragment : MainFragment {
Logger.v(TAG, "shouldStop: $shouldStop");
if(shouldStop) {
_viewDetail?.let {
val v = it.video ?: return@let;
StateSaved.instance.setVideoToOpenBlocking(VideoToOpen(v.url, (it.lastPositionMilliseconds / 1000.0f).toLong()));
}
_viewDetail?.onStop();
StateCasting.instance.onStop();
Logger.v(TAG, "called onStop() shouldStop: $shouldStop");
@@ -149,6 +149,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import userpackage.Protocol
import java.time.OffsetDateTime
@@ -853,14 +855,19 @@ class VideoDetailView : ConstraintLayout {
}
}
}
private val _historyIndexLock = Mutex(false);
suspend fun getHistoryIndex(video: IPlatformVideo): DBHistory.Index = withContext(Dispatchers.IO){
val current = _historyIndex;
if(current == null || current.url != video.url) {
val index = StateHistory.instance.getHistoryByVideo(video, true)!!;
_historyIndex = index;
return@withContext index;
_historyIndexLock.withLock {
val current = _historyIndex;
if(current == null || current.url != video.url) {
val index = StateHistory.instance.getHistoryByVideo(video, true)!!;
_historyIndex = index;
return@withContext index;
}
return@withContext current;
}
return@withContext current;
}
@@ -1121,7 +1128,7 @@ class VideoDetailView : ConstraintLayout {
switchContentView(_container_content_main);
}
@OptIn(ExperimentalCoroutinesApi::class)
//@OptIn(ExperimentalCoroutinesApi::class)
fun setVideoDetails(videoDetail: IPlatformVideoDetails, newVideo: Boolean = false) {
Logger.i(TAG, "setVideoDetails (${videoDetail.name})")
@@ -1144,6 +1151,7 @@ class VideoDetailView : ConstraintLayout {
if(videoDetail is VideoLocal) {
videoLocal = videoDetail;
video = videoDetail;
this.video = video;
val videoTask = StatePlatform.instance.getContentDetails(videoDetail.url);
videoTask.invokeOnCompletion { ex ->
if(ex != null) {
@@ -1216,7 +1224,7 @@ class VideoDetailView : ConstraintLayout {
_addCommentView.setContext(video.url, ref)
_player.setMetadata(video.name, video.author.name);
if (video !is TutorialFragment.TutorialVideo) {
if (video is TutorialFragment.TutorialVideo) {
_toggleCommentType.setValue(false, false);
} else {
_toggleCommentType.setValue(!Settings.instance.other.polycentricEnabled || Settings.instance.comments.defaultCommentSection == 1, false);
@@ -1483,12 +1491,12 @@ class VideoDetailView : ConstraintLayout {
private fun loadCurrentVideo(resumePositionMs: Long = 0) {
_didStop = false;
val video = video ?: return;
val video = (videoLocal ?: video) ?: return;
try {
val videoSource = _lastVideoSource ?: _player.getPreferredVideoSource(video, Settings.instance.playback.getCurrentPreferredQualityPixelCount());
val audioSource = _lastAudioSource ?: _player.getPreferredAudioSource(video, Settings.instance.playback.getPrimaryLanguage(context));
val subtitleSource = _lastSubtitleSource;
val subtitleSource = _lastSubtitleSource ?: (if(video is VideoLocal) video.subtitlesSources.firstOrNull() else null);
Logger.i(TAG, "loadCurrentVideo(videoSource=$videoSource, audioSource=$audioSource, subtitleSource=$subtitleSource, resumePositionMs=$resumePositionMs)")
if(videoSource == null && audioSource == null) {
@@ -1516,6 +1524,8 @@ class VideoDetailView : ConstraintLayout {
_player.setArtwork(null);
_player.setSource(videoSource, audioSource, _playWhenReady, false);
if(subtitleSource != null)
_player.swapSubtitles(fragment.lifecycleScope, subtitleSource);
_player.seekTo(resumePositionMs);
}
else
@@ -1523,6 +1533,7 @@ class VideoDetailView : ConstraintLayout {
_lastVideoSource = videoSource;
_lastAudioSource = audioSource;
_lastSubtitleSource = subtitleSource;
}
catch(ex: UnsupportedCastException) {
Logger.e(TAG, "Failed to load cast media", ex);
@@ -143,7 +143,7 @@ class MediaPlaybackService : Service() {
override fun onDestroy() {
Logger.v(TAG, "onDestroy");
_instance = null;
MediaControlReceiver.onCloseReceived.emit();
MediaControlReceiver.onPauseReceived.emit();
super.onDestroy();
}
@@ -153,7 +153,7 @@ class MediaPlaybackService : Service() {
fun closeMediaSession() {
Logger.v(TAG, "closeMediaSession");
stopForeground(STOP_FOREGROUND_DETACH);
stopForeground(STOP_FOREGROUND_REMOVE);
val focusRequest = _focusRequest;
if (focusRequest != null) {
@@ -162,7 +162,9 @@ class MediaPlaybackService : Service() {
}
_hasFocus = false;
_notificationManager?.cancel(MEDIA_NOTIF_ID);
val notifManager = _notificationManager;
Logger.i(TAG, "Cancelling playback notification (notifManager: ${notifManager != null})");
notifManager?.cancel(MEDIA_NOTIF_ID);
_notif_last_video = null;
_notif_last_bitmap = null;
_mediaSession = null;
@@ -5,6 +5,7 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Color
import android.media.AudioManager
import android.net.ConnectivityManager
import android.net.Network
@@ -38,6 +39,7 @@ import com.futo.platformplayer.receivers.AudioNoisyReceiver
import com.futo.platformplayer.services.DownloadService
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.views.ToastView
import kotlinx.coroutines.*
import java.io.File
import java.time.OffsetDateTime
@@ -380,13 +382,15 @@ class StateApp {
Logger.i(TAG, "MainApp Starting: Initializing [Polycentric]");
StatePolycentric.instance.load(context);
Logger.i(TAG, "MainApp Starting: Initializing [Saved]");
StateSaved.instance.load();
Logger.i(TAG, "MainApp Starting: Initializing [Connectivity]");
displayMetrics = context.resources.displayMetrics;
ensureConnectivityManager(context);
Logger.i(TAG, "MainApp Starting: Cleaning up unused downloads");
StateDownloads.instance.cleanupDownloads();
Logger.i(TAG, "MainApp Starting: Initializing [Telemetry]");
if (!BuildConfig.DEBUG) {
StateTelemetry.instance.initialize();
@@ -460,7 +464,9 @@ class StateApp {
//Foreground download
autoUpdateEnabled -> {
StateUpdate.instance.checkForUpdates(context, false);
scopeOrNull?.launch(Dispatchers.IO) {
StateUpdate.instance.checkForUpdates(context, false)
}
}
else -> {
@@ -558,6 +564,40 @@ class StateApp {
if(StateHistory.instance.shouldMigrateLegacyHistory())
StateHistory.instance.migrateLegacyHistory();
StateAnnouncement.instance.deleteAnnouncement("plugin-update")
scopeOrNull?.launch(Dispatchers.IO) {
val updateAvailable = StatePlatform.instance.checkForUpdates()
withContext(Dispatchers.Main) {
if (updateAvailable.isNotEmpty()) {
UIDialogs.appToast(
ToastView.Toast(updateAvailable
.map { " - " + it.name }
.joinToString("\n"),
true,
null,
"Plugin updates available"
));
StateAnnouncement.instance.registerAnnouncement(
"plugin-update",
"Plugin updates available",
"There are ${updateAvailable.size} plugin updates available.",
AnnouncementType.SESSION_RECURRING
)
}
}
}
/*
UIDialogs.appToast("This is a test", false);
UIDialogs.appToast("This is a test 2", false);
UIDialogs.appToastError("This is a test 3 (Error)", false);
UIDialogs.appToast(ToastView.Toast("This is a test 4, with title", false, Color.WHITE, "Test title"));
UIDialogs.appToast("This is a test 5 Long text\nWith enters\nasdh asfh fds h rwe h fxh sdfh sdf h dsfh sdf hasdfhsdhg ads as", true);
*/
}
fun mainAppStartedWithExternalFiles(context: Context) {
@@ -352,7 +352,10 @@ class StateDownloads {
fun cleanupDownloads(): Pair<Int, Long> {
val expected = getDownloadedVideos();
val validFiles = HashSet(expected.flatMap { e -> e.videoSource.map { it.filePath } + e.audioSource.map { it.filePath } });
val validFiles = HashSet(expected.flatMap { e ->
e.videoSource.map { it.filePath } +
e.audioSource.map { it.filePath } +
e.subtitlesSources.map { it.filePath }});
var totalDeleted: Long = 0;
var totalDeletedCount = 0;
@@ -5,6 +5,7 @@ import androidx.collection.LruCache
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.PlatformMultiClientPool
@@ -78,6 +79,7 @@ class StatePlatform {
private val _clientsLock = Object();
private val _availableClients : ArrayList<IPlatformClient> = ArrayList();
private val _enabledClients : ArrayList<IPlatformClient> = ArrayList();
private var _updatesAvailableMap: HashSet<String> = hashSetOf();
//ClientPools are used to isolate plugin usage of certain components from others
//This prevents for example a background task like subscriptions from blocking a user from opening a video
@@ -932,6 +934,67 @@ class StatePlatform {
}
}
fun hasUpdateAvailable(c: SourcePluginConfig): Boolean {
val updatesAvailableMap = _updatesAvailableMap
synchronized(updatesAvailableMap) {
return updatesAvailableMap.contains(c.id)
}
}
suspend fun checkForUpdates(): List<SourcePluginConfig> = withContext(Dispatchers.IO) {
var configs = mutableListOf<SourcePluginConfig>()
val updatesAvailableFor = hashSetOf<String>()
for (availableClient in getAvailableClients()) {
if (availableClient !is JSClient) {
continue
}
if (checkForUpdates(availableClient.config)) {
configs.add(availableClient.config);
updatesAvailableFor.add(availableClient.config.id)
}
}
_updatesAvailableMap = updatesAvailableFor
return@withContext configs;
}
fun clearUpdateAvailable(c: SourcePluginConfig) {
val updatesAvailableMap = _updatesAvailableMap
synchronized(updatesAvailableMap) {
updatesAvailableMap.remove(c.id)
}
}
private suspend fun checkForUpdates(c: SourcePluginConfig): Boolean = withContext(Dispatchers.IO) {
val sourceUrl = c.sourceUrl ?: return@withContext false;
Logger.i(TAG, "Check for source updates '${c.name}'.");
try {
val client = ManagedHttpClient();
val response = client.get(sourceUrl);
Logger.i(TAG, "Downloading source config '$sourceUrl'.");
if (!response.isOk || response.body == null) {
return@withContext false;
}
val configJson = response.body.string();
Logger.i(TAG, "Downloaded source config ($sourceUrl):\n${configJson}");
val config = SourcePluginConfig.fromJson(configJson);
if (config.version <= c.version) {
return@withContext false;
}
Logger.i(TAG, "Update is available (config.version=${config.version}, source.config.version=${c.version}).");
return@withContext true;
} catch (e: Throwable) {
Logger.e(TAG, "Failed to check for updates.", e);
return@withContext false;
}
}
companion object {
private var _instance : StatePlatform? = null;
val instance : StatePlatform
@@ -10,7 +10,6 @@ import com.futo.platformplayer.api.media.platforms.js.SourceAuth
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
import com.futo.platformplayer.developer.DeveloperEndpoints
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.stores.FragmentedStorage
@@ -467,7 +466,6 @@ class StatePlugins {
_plugins.save(descriptor);
}
@Serializable
private data class PluginConfig(
val SOURCES_EMBEDDED: Map<String, String>,
@@ -1,52 +0,0 @@
package com.futo.platformplayer.states
import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
@kotlinx.serialization.Serializable
data class VideoToOpen(val url: String, val timeSeconds: Long);
class StateSaved {
var videoToOpen: VideoToOpen? = null;
private val _videoToOpen = FragmentedStorage.get<StringStorage>("videoToOpen")
fun load() {
val videoToOpenString = _videoToOpen.value;
if (videoToOpenString.isNotEmpty()) {
try {
val v = Serializer.json.decodeFromString<VideoToOpen>(videoToOpenString);
videoToOpen = v;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to load video to open", e)
}
}
Logger.i(TAG, "loaded videoToOpen=$videoToOpen");
}
fun setVideoToOpenNonBlocking(v: VideoToOpen? = null) {
Logger.i(TAG, "set videoToOpen=$v");
videoToOpen = v;
_videoToOpen.setAndSave(if (v != null) Serializer.json.encodeToString(v) else "");
}
fun setVideoToOpenBlocking(v: VideoToOpen? = null) {
Logger.i(TAG, "set videoToOpen=$v");
videoToOpen = v;
_videoToOpen.setAndSaveBlocking(if (v != null) Serializer.json.encodeToString(v) else "");
}
companion object {
const val TAG = "StateSaved"
val instance: StateSaved = StateSaved()
}
}
@@ -2,15 +2,15 @@ package com.futo.platformplayer.states
import android.content.Context
import android.os.Build
import android.os.Environment
import com.futo.platformplayer.*
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.copyToOutputStream
import com.futo.platformplayer.logging.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileNotFoundException
import java.io.InputStream
import java.io.OutputStream
@@ -155,47 +155,45 @@ class StateUpdate {
}
}
fun checkForUpdates(context: Context, showUpToDateToast: Boolean) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
val client = ManagedHttpClient();
val latestVersion = downloadVersionCode(client);
suspend fun checkForUpdates(context: Context, showUpToDateToast: Boolean) = withContext(Dispatchers.IO) {
try {
val client = ManagedHttpClient();
val latestVersion = downloadVersionCode(client);
if (latestVersion != null) {
val currentVersion = BuildConfig.VERSION_CODE;
Logger.i(TAG, "Current version ${currentVersion} latest version ${latestVersion}.");
if (latestVersion != null) {
val currentVersion = BuildConfig.VERSION_CODE;
Logger.i(TAG, "Current version ${currentVersion} latest version ${latestVersion}.");
if (latestVersion > currentVersion) {
withContext(Dispatchers.Main) {
try {
UIDialogs.showUpdateAvailableDialog(context, latestVersion);
} catch (e: Throwable) {
UIDialogs.toast(context, "Failed to show update dialog");
Logger.w(TAG, "Error occurred in update dialog.");
}
}
} else {
if (showUpToDateToast) {
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Already on latest version");
}
if (latestVersion > currentVersion) {
withContext(Dispatchers.Main) {
try {
UIDialogs.showUpdateAvailableDialog(context, latestVersion);
} catch (e: Throwable) {
UIDialogs.toast(context, "Failed to show update dialog");
Logger.w(TAG, "Error occurred in update dialog.");
}
}
} else {
Logger.w(TAG, "Failed to retrieve version from version URL.");
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to retrieve version");
if (showUpToDateToast) {
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Already on latest version");
}
}
}
} catch (e: Throwable) {
Logger.w(TAG, "Failed to check for updates.", e);
} else {
Logger.w(TAG, "Failed to retrieve version from version URL.");
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to check for updates");
UIDialogs.toast(context, "Failed to retrieve version");
}
}
};
} catch (e: Throwable) {
Logger.w(TAG, "Failed to check for updates.", e);
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to check for updates");
}
}
}
private fun downloadApkToFile(client: ManagedHttpClient, destinationFile: File, isCancelled: (() -> Boolean)? = null) {
@@ -55,7 +55,7 @@ abstract class SubscriptionsTaskFetchAlgorithm(
val clientCacheCount = clientTasks.value.size - clientTaskCount;
val limit = clientTasks.key.getSubscriptionRateLimit();
if(clientCacheCount > 0 && clientTaskCount > 0 && limit != null && clientTaskCount >= limit && StateApp.instance.contextOrNull?.let { it is MainActivity && it.isFragmentActive<SubscriptionsFeedFragment>() } == true) {
UIDialogs.toast("[${clientTasks.key.name}] only updating ${clientTaskCount} most urgent channels (rqs). (${clientCacheCount} cached)");
UIDialogs.appToast("[${clientTasks.key.name}] only updating ${clientTaskCount} most urgent channels (rqs). (${clientCacheCount} cached)");
}
}
@@ -0,0 +1,91 @@
package com.futo.platformplayer.views
import android.content.Context
import android.graphics.Color
import android.util.AttributeSet
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import com.futo.platformplayer.R
import com.futo.platformplayer.dp
import com.futo.platformplayer.logging.Logger
class ToastView : LinearLayout {
private val root: LinearLayout;
private val title: TextView;
private val text: TextView;
init {
inflate(context, R.layout.toast, this);
root = findViewById(R.id.root);
title = findViewById(R.id.title);
text = findViewById(R.id.text);
}
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
setToast(ToastView.Toast("", false))
root.visibility = GONE;
}
fun hide(animate: Boolean, onFinished: (()->Unit)? = null) {
Logger.i("MainActivity", "Hiding toast");
if(!animate) {
root.visibility = GONE;
alpha = 0f;
onFinished?.invoke();
}
else {
animate()
.alpha(0f)
.setDuration(700)
.translationY(20.dp(context.resources).toFloat())
.withEndAction { root.visibility = GONE; onFinished?.invoke(); }
.start();
}
}
fun show(animate: Boolean) {
Logger.i("MainActivity", "Showing toast");
if(!animate) {
root.visibility = VISIBLE;
alpha = 1f;
}
else {
alpha = 0f;
root.visibility = VISIBLE;
translationY = 20.dp(context.resources).toFloat();
animate()
.alpha(1f)
.setDuration(700)
.translationY(0f)
.start();
}
}
fun setToast(toast: Toast) {
if(toast.title.isNullOrEmpty())
title.isVisible = false;
else {
title.text = toast.title;
title.isVisible = true;
}
text.text = toast.msg;
if(toast.color != null)
text.setTextColor(toast.color);
else
text.setTextColor(Color.WHITE);
}
fun setToastAnimated(toast: Toast) {
hide(true) {
setToast(toast);
show(true);
};
}
class Toast(
val msg: String,
val long: Boolean,
val color: Int? = null,
val title: String? = null
);
}
@@ -1,42 +0,0 @@
package com.futo.platformplayer.views.adapters
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.constructs.Event1
class DisabledSourceAdapter : RecyclerView.Adapter<DisabledSourceViewHolder> {
private val _sources: MutableList<IPlatformClient>;
var onClick = Event1<IPlatformClient>();
var onAdd = Event1<IPlatformClient>();
constructor(sources: MutableList<IPlatformClient>) : super() {
_sources = sources;
}
override fun getItemCount() = _sources.size
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): DisabledSourceViewHolder {
val holder = DisabledSourceViewHolder(viewGroup);
holder.onAdd.subscribe {
val source = holder.source;
if (source != null) {
onAdd.emit(source);
}
}
holder.onClick.subscribe {
val source = holder.source;
if (source != null) {
onClick.emit(source);
}
};
return holder;
}
override fun onBindViewHolder(viewHolder: DisabledSourceViewHolder, position: Int) {
viewHolder.bind(_sources[position])
}
}
@@ -1,17 +1,15 @@
package com.futo.platformplayer.views.adapters
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.states.StatePlatform
class DisabledSourceView : LinearLayout {
private val _root: LinearLayout;
@@ -38,7 +36,16 @@ class DisabledSourceView : LinearLayout {
client.icon?.setImageView(_imageSource);
_textSource.text = client.name;
_textSourceSubtitle.text = context.getString(R.string.tap_to_open);
if (client is JSClient && StatePlatform.instance.hasUpdateAvailable(client.config)) {
_textSourceSubtitle.text = context.getString(R.string.update_available_exclamation)
_textSourceSubtitle.setTextColor(context.getColor(R.color.light_blue_400))
_textSourceSubtitle.typeface = resources.getFont(R.font.inter_regular)
} else {
_textSourceSubtitle.text = context.getString(R.string.tap_to_open)
_textSourceSubtitle.setTextColor(context.getColor(R.color.gray_ac))
_textSourceSubtitle.typeface = resources.getFont(R.font.inter_extra_light)
}
_buttonAdd.setOnClickListener { onAdd.emit(source) }
_root.setOnClickListener { onClick.emit(); };
@@ -1,44 +0,0 @@
package com.futo.platformplayer.views.adapters
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.constructs.Event0
class DisabledSourceViewHolder : ViewHolder {
private val _imageSource: ImageView;
private val _textSource: TextView;
private val _textSourceSubtitle: TextView;
private val _buttonAdd: LinearLayout;
var onClick = Event0();
var onAdd = Event0();
var source: IPlatformClient? = null
private set
constructor(viewGroup: ViewGroup) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_source_disabled, viewGroup, false)) {
_imageSource = itemView.findViewById(R.id.image_source);
_textSource = itemView.findViewById(R.id.text_source);
_textSourceSubtitle = itemView.findViewById(R.id.text_source_subtitle);
_buttonAdd = itemView.findViewById(R.id.button_add);
val root = itemView.findViewById<LinearLayout>(R.id.root);
_buttonAdd.setOnClickListener { onAdd.emit() }
root.setOnClickListener { onClick.emit(); };
}
fun bind(client: IPlatformClient) {
client.icon?.setImageView(_imageSource);
_textSource.text = client.name;
_textSourceSubtitle.text = itemView.context.getString(R.string.tap_to_open);
source = client;
}
}
@@ -10,7 +10,9 @@ import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.states.StatePlatform
class EnabledSourceViewHolder : ViewHolder {
private val _imageSource: ImageView;
@@ -57,8 +59,18 @@ class EnabledSourceViewHolder : ViewHolder {
fun bind(client: IPlatformClient) {
client.icon?.setImageView(_imageSource);
_textSource.text = client.name;
_textSourceSubtitle.text = itemView.context.getString(R.string.tap_to_open);
_textSource.text = client.name
if (client is JSClient && StatePlatform.instance.hasUpdateAvailable(client.config)) {
_textSourceSubtitle.text = itemView.context.getString(R.string.update_available_exclamation)
_textSourceSubtitle.setTextColor(itemView.context.getColor(R.color.light_blue_400))
_textSourceSubtitle.typeface = itemView.resources.getFont(R.font.inter_regular)
} else {
_textSourceSubtitle.text = itemView.context.getString(R.string.tap_to_open)
_textSourceSubtitle.setTextColor(itemView.context.getColor(R.color.gray_ac))
_textSourceSubtitle.typeface = itemView.resources.getFont(R.font.inter_extra_light)
}
source = client
}
}
@@ -11,6 +11,7 @@ import android.view.ViewGroup
import android.widget.Button
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.net.toFile
@@ -48,7 +49,7 @@ class ImageVariableOverlay: ConstraintLayout {
private val _buttonGallery: BigButton;
private val _imageGallerySelected: ImageView;
private val _imageGallerySelectedContainer: LinearLayout;
private val _buttonSelect: Button;
private val _buttonSelect: TextView;
private val _topbar: OverlayTopbar;
private val _recyclerPresets: AnyAdapterView<PresetImage, PresetViewHolder>;
private val _recyclerCreators: AnyAdapterView<SelectableCreatorBarViewHolder.Selectable, SelectableCreatorBarViewHolder>;
@@ -25,6 +25,7 @@ class SourceHeaderView : LinearLayout {
private val _sourcePlatformUrl: TextView;
private val _sourceRepositoryUrl: TextView;
private val _sourceScriptUrl: TextView;
private val _sourceScriptConfig: TextView;
private val _sourceSignature: TextView;
private val _sourcePlatformUrlContainer: LinearLayout;
@@ -45,6 +46,7 @@ class SourceHeaderView : LinearLayout {
_sourcePlatformUrl = findViewById(R.id.source_platform);
_sourcePlatformUrlContainer = findViewById(R.id.source_platform_container);
_sourceScriptUrl = findViewById(R.id.source_script);
_sourceScriptConfig = findViewById(R.id.source_config);
_sourceSignature = findViewById(R.id.source_signature);
_sourceBy.setOnClickListener {
@@ -59,6 +61,10 @@ class SourceHeaderView : LinearLayout {
if(!_config?.absoluteScriptUrl.isNullOrEmpty())
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(_config?.absoluteScriptUrl)));
};
_sourceScriptConfig.setOnClickListener {
if(!_config?.sourceUrl.isNullOrEmpty())
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(_config?.sourceUrl)));
}
_sourcePlatformUrl.setOnClickListener {
if(!_config?.platformUrl.isNullOrEmpty())
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(_config?.platformUrl)));
@@ -82,6 +88,7 @@ class SourceHeaderView : LinearLayout {
_sourceVersion.text = config.version.toString();
_sourceScriptUrl.text = config.absoluteScriptUrl;
_sourceRepositoryUrl.text = config.repositoryUrl;
_sourceScriptConfig.text = config.sourceUrl
_sourceAuthorID.text = "";
_sourcePlatformUrl.text = config.platformUrl ?: "";
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#EE202020" />
<corners android:radius="10dp" />
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
</shape>
+1 -2
View File
@@ -2,8 +2,7 @@
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M120,640L120,560L440,560L440,640L120,640ZM120,480L120,400L600,400L600,480L120,480ZM120,320L120,240L600,240L600,320L120,320ZM640,840L640,520L880,680L640,840Z"/>
@@ -70,4 +70,13 @@
android:visibility="gone"
android:elevation="15dp">
</FrameLayout>
<com.futo.platformplayer.views.ToastView
android:id="@+id/toast_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="50dp"
android:elevation="30dp"
app:layout_constraintLeft_toLeftOf="@id/fragment_main"
app:layout_constraintRight_toRightOf="@id/fragment_main"
app:layout_constraintBottom_toBottomOf="@id/fragment_main" />
</androidx.constraintlayout.motion.widget.MotionLayout>
@@ -81,43 +81,38 @@
<TextView
android:id="@+id/text_remembered_devices"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_weight="3"
android:layout_height="wrap_content"
android:text="@string/remembered_devices"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:textSize="14dp"
android:ellipsize="end"
android:textColor="@color/white"
android:maxLines="1"
android:fontFamily="@font/inter_regular" />
<Button
<ImageButton
android:id="@+id/button_scan_qr"
android:layout_width="0dp"
android:layout_weight="1.7"
android:layout_height="wrap_content"
android:text="@string/scan_qr"
android:textSize="14dp"
android:textAlignment="center"
android:layout_marginEnd="2dp"
android:ellipsize="end"
android:maxLines="1"
android:fontFamily="@font/inter_regular"
android:textColor="@color/colorPrimary"
android:background="@color/transparent" />
android:layout_width="40dp"
android:layout_height="40dp"
android:scaleType="centerCrop"
app:srcCompat="@drawable/ic_qr"
app:tint="@color/primary" />
<Button
<Space android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<ImageButton
android:id="@+id/button_add"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:text="@string/add"
android:textSize="14dp"
android:textAlignment="textEnd"
android:layout_marginEnd="2dp"
android:ellipsize="end"
android:maxLines="1"
android:fontFamily="@font/inter_regular"
android:textColor="@color/colorPrimary"
android:background="@color/transparent" />
android:layout_width="40dp"
android:layout_height="40dp"
android:scaleType="centerCrop"
app:srcCompat="@drawable/ic_add"
app:tint="@color/primary"
android:layout_marginEnd="20dp"/>
</LinearLayout>
<LinearLayout
@@ -38,12 +38,12 @@
android:id="@+id/dialog_text_details"
android:layout_width="match_parent"
android:textColor="#AAAAAA"
android:fontFamily="@font/inter_regular"
android:fontFamily="@font/inter_light"
android:text=""
android:textAlignment="center"
android:layout_marginStart="30dp"
android:layout_marginEnd="30dp"
android:textSize="11dp"
android:textSize="13dp"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/dialog_text_code"
@@ -190,7 +190,7 @@
android:layout_marginStart="5dp"
android:layout_marginEnd="5dp"
android:layout_marginTop="5dp"
android:layout_marginBottom="5dp"
android:layout_marginBottom="10dp"
app:layout_constraintBottom_toBottomOf="parent">
<TextView
@@ -38,7 +38,7 @@
android:layout_marginStart="5dp"
android:layout_marginEnd="5dp"
android:layout_marginTop="5dp"
android:layout_marginBottom="5dp"
android:layout_marginBottom="10dp"
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="@drawable/background_button_primary"
@@ -116,20 +116,29 @@
</LinearLayout>
</ScrollView>
<LinearLayout
<FrameLayout
android:id="@+id/container_select"
android:layout_marginStart="5dp"
android:layout_marginEnd="5dp"
android:layout_marginTop="5dp"
android:layout_marginBottom="10dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_height="50dp"
android:background="@drawable/background_button_primary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent">
<Button
<TextView
android:id="@+id/button_select"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="10dp"
android:background="@drawable/background_button_primary"
android:text="Select" />
</LinearLayout>
android:layout_height="match_parent"
android:fontFamily="@font/inter_regular"
android:text="@string/select"
android:textSize="16dp"
android:gravity="center"
android:layout_gravity="center" />
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:toolNs="http://schemas.android.com/tools"
android:orientation="vertical"
android:id="@+id/root"
android:padding="10dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/background_toast"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:paddingLeft="15dp"
android:paddingRight="15dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:orientation="vertical">
<TextView
android:id="@+id/title"
android:textColor="@color/white"
toolNs:text="Some Title"
android:fontFamily="@font/inter_bold"
android:textSize="15dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/text"
android:textColor="@color/white"
android:textSize="14dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="@font/inter_light"
toolNs:text="This is a test" />
</LinearLayout>
</LinearLayout>
@@ -170,6 +170,28 @@
tools:text="https://some.repository.url/whatever/someScript.js" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14dp"
android:textColor="@color/white"
android:layout_marginTop="10dp"
android:fontFamily="@font/inter_light"
android:text="@string/config_url" />
<TextView
android:id="@+id/source_config"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14dp"
android:textColor="@color/colorPrimary"
android:fontFamily="@font/inter_extra_light"
tools:text="https://some.repository.url/whatever/someScript.js" />
</LinearLayout>
<!--Script Url-->
<LinearLayout
android:layout_width="match_parent"
+3 -1
View File
@@ -113,6 +113,7 @@
<string name="platform_url">Platform URL</string>
<string name="repository_url">Repository URL</string>
<string name="script_url">Script URL</string>
<string name="config_url">Config URL</string>
<string name="source_permissions_explanation">These are the permissions the plugin requires to function</string>
<string name="source_explain_eval_access">The plugin will have access to eval capacity</string>
<string name="source_explain_script_url">The plugin will have access to the following domains</string>
@@ -651,6 +652,7 @@
<string name="please_use_at_least_3_characters">Please use at least 3 characters</string>
<string name="are_you_sure_you_want_to_delete_this_video">Are you sure you want to delete this video?</string>
<string name="tap_to_open">Tap to open</string>
<string name="update_available_exclamation">Update available!</string>
<string name="watching">watching</string>
<string name="available_in">available in</string>
<string name="seconds">seconds</string>
@@ -724,7 +726,7 @@
<string name="position">Position</string>
<string name="tutorials">Tutorials</string>
<string name="do_you_want_to_see_the_tutorials_you_can_find_them_at_any_time_through_the_more_button">Do you want to see the tutorials? You can find them at any time through the more button.</string>
<string name="add_creator">Add More</string>
<string name="add_creator">Add Creators</string>
<string name="select">Select</string>
<string-array name="home_screen_array">
<item>Recommendations</item>