Merge branch 'package-browser' into 'master'

New Notification UI & PackageBrowser support

See merge request videostreaming/grayjay!163
This commit is contained in:
Kelvin
2026-01-30 15:38:48 +00:00
25 changed files with 1040 additions and 33 deletions
@@ -7,6 +7,10 @@ import android.os.IBinder
import android.os.SystemClock import android.os.SystemClock
import com.futo.platformplayer.UIDialogs.ActionStyle import com.futo.platformplayer.UIDialogs.ActionStyle
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.SessionAnnouncement
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate import com.futo.platformplayer.states.StateUpdate
import kotlinx.coroutines.* import kotlinx.coroutines.*
@@ -14,6 +18,7 @@ import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.time.OffsetDateTime
class UpdateDownloadService : Service() { class UpdateDownloadService : Service() {
@@ -85,13 +90,16 @@ class UpdateDownloadService : Service() {
job.cancel() job.cancel()
} }
private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean) { private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean, onProgress: ((Int) -> Unit)? = null) {
val now = SystemClock.elapsedRealtime() val now = SystemClock.elapsedRealtime()
val force = progress == 100 && !indeterminate val force = progress == 100 && !indeterminate
if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) { if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) {
lastProgressUpdateElapsedMs = now lastProgressUpdateElapsedMs = now
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate) UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate);
if(onProgress != null)
onProgress.invoke(progress);
} }
} }
@@ -99,6 +107,7 @@ class UpdateDownloadService : Service() {
val apkFile = StateUpdate.getApkFile(this, version) val apkFile = StateUpdate.getApkFile(this, version)
val partialFile = StateUpdate.getPartialApkFile(this, version) val partialFile = StateUpdate.getPartialApkFile(this, version)
var announcement: SessionAnnouncement? = null;
try { try {
if (apkFile.exists() && apkFile.length() > 0L) { if (apkFile.exists() && apkFile.length() > 0L) {
Logger.i(TAG, "APK already downloaded at ${apkFile.absolutePath}") Logger.i(TAG, "APK already downloaded at ${apkFile.absolutePath}")
@@ -106,6 +115,14 @@ class UpdateDownloadService : Service() {
return return
} }
try {
announcement = StateAnnouncement.instance.registerLoading("Downloading new version [${version}]", "New version is being downloaded..",
ImageVariable.fromResource(R.drawable.foreground));
}
catch(ex: Exception){
Logger.e(TAG, "Failed to set progress announcement", ex);
}
var backoffMs = INITIAL_BACKOFF_MS var backoffMs = INITIAL_BACKOFF_MS
for (attempt in 0 until MAX_RETRIES) { for (attempt in 0 until MAX_RETRIES) {
@@ -115,7 +132,13 @@ class UpdateDownloadService : Service() {
} }
try { try {
performDownload(StateUpdate.APK_URL, partialFile, version) performDownload(StateUpdate.APK_URL, partialFile, version, {
try {
if (announcement != null)
announcement?.setProgress(it);
}
catch(ex: Throwable) {}
})
if (!cancelRequested) { if (!cancelRequested) {
if (apkFile.exists()) { if (apkFile.exists()) {
@@ -145,6 +168,12 @@ class UpdateDownloadService : Service() {
} }
} }
} finally { } finally {
try {
if (announcement != null) {
StateAnnouncement.instance.closeAnnouncement(announcement.id);
}
}
catch(ex: Throwable){}
isDownloading = false isDownloading = false
cancelRequested = false cancelRequested = false
stopForeground(Service.STOP_FOREGROUND_REMOVE) stopForeground(Service.STOP_FOREGROUND_REMOVE)
@@ -152,7 +181,7 @@ class UpdateDownloadService : Service() {
} }
} }
private fun performDownload(url: String, partialFile: File, version: Int) { private fun performDownload(url: String, partialFile: File, version: Int, onProgress: ((Int)->Unit)? = null) {
var startOffset = if (partialFile.exists()) partialFile.length() else 0L var startOffset = if (partialFile.exists()) partialFile.length() else 0L
Logger.i(TAG, "Starting download. url=$url, existingBytes=$startOffset") Logger.i(TAG, "Starting download. url=$url, existingBytes=$startOffset")
@@ -204,7 +233,7 @@ class UpdateDownloadService : Service() {
progress > 100 -> 100 progress > 100 -> 100
else -> progress else -> progress
} }
throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false) throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false, onProgress)
} }
} else { } else {
throttledUpdateDownloadProgress(version, progress = 0, indeterminate = true) throttledUpdateDownloadProgress(version, progress = 0, indeterminate = true)
@@ -250,6 +279,18 @@ class UpdateDownloadService : Service() {
UpdateNotificationManager.cancelAll(ctx) UpdateNotificationManager.cancelAll(ctx)
UpdateInstaller.startInstall(ctx, version, apkFile) UpdateInstaller.startInstall(ctx, version, apkFile)
}, ActionStyle.PRIMARY, true)); }, ActionStyle.PRIMARY, true));
try {
StateAnnouncement.instance.registerAnnouncement("install-update-apk", "Grayjay v${version} is ready!", "You can now install the new Grayjay version.",
AnnouncementType.SESSION,
OffsetDateTime.now(), "update", "Install", {
UpdateNotificationManager.cancelAll(ctx)
UpdateInstaller.startInstall(ctx, version, apkFile)
});
}
catch(ex: Throwable) {
}
} catch (t: Throwable) { } catch (t: Throwable) {
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t) Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
updateDownloadedDialog = null updateDownloadedDialog = null
@@ -110,6 +110,7 @@ import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.stores.SubscriptionStorage import com.futo.platformplayer.stores.SubscriptionStorage
import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.views.ToastView import com.futo.platformplayer.views.ToastView
import com.futo.platformplayer.views.notification.NotificationOverlayView
import com.futo.polycentric.core.ApiMethods import com.futo.polycentric.core.ApiMethods
import com.google.gson.JsonParser import com.google.gson.JsonParser
import com.google.zxing.integration.android.IntentIntegrator import com.google.zxing.integration.android.IntentIntegrator
@@ -201,6 +202,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragLibraryVideos: LibraryVideosFragment; lateinit var _fragLibraryVideos: LibraryVideosFragment;
lateinit var _fragLibrarySearch: LibrarySearchFragment; lateinit var _fragLibrarySearch: LibrarySearchFragment;
lateinit var _fragLibraryFiles: LibraryFilesFragment; lateinit var _fragLibraryFiles: LibraryFilesFragment;
lateinit var _fragNotifications: NotificationOverlayView.Frag;
lateinit var _fragSettings: SettingsFragment; lateinit var _fragSettings: SettingsFragment;
lateinit var _fragDeveloper: DeveloperFragment; lateinit var _fragDeveloper: DeveloperFragment;
lateinit var _fragLogin: LoginFragment; lateinit var _fragLogin: LoginFragment;
@@ -389,6 +391,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragLibraryVideos = LibraryVideosFragment.newInstance(); _fragLibraryVideos = LibraryVideosFragment.newInstance();
_fragLibraryFiles = LibraryFilesFragment.newInstance(); _fragLibraryFiles = LibraryFilesFragment.newInstance();
_fragLibrarySearch = LibrarySearchFragment.newInstance(); _fragLibrarySearch = LibrarySearchFragment.newInstance();
_fragNotifications = NotificationOverlayView.Frag();
_fragSettings = SettingsFragment.newInstance(); _fragSettings = SettingsFragment.newInstance();
_fragDeveloper = DeveloperFragment.newInstance(); _fragDeveloper = DeveloperFragment.newInstance();
_fragLogin = LoginFragment.newInstance(); _fragLogin = LoginFragment.newInstance();
@@ -538,6 +541,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragLibrarySearch.topBar = _fragTopBarSearch; _fragLibrarySearch.topBar = _fragTopBarSearch;
_fragSettings.topBar = _fragTopBarNavigation; _fragSettings.topBar = _fragTopBarNavigation;
_fragDeveloper.topBar = _fragTopBarNavigation; _fragDeveloper.topBar = _fragTopBarNavigation;
_fragNotifications.topBar = _fragTopBarGeneral;
_fragBrowser.topBar = _fragTopBarNavigation; _fragBrowser.topBar = _fragTopBarNavigation;
@@ -1368,6 +1372,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
LibraryVideosFragment::class -> _fragLibraryVideos as T; LibraryVideosFragment::class -> _fragLibraryVideos as T;
LibraryFilesFragment::class -> _fragLibraryFiles as T; LibraryFilesFragment::class -> _fragLibraryFiles as T;
LibrarySearchFragment::class -> _fragLibrarySearch as T; LibrarySearchFragment::class -> _fragLibrarySearch as T;
NotificationOverlayView.Frag::class -> _fragNotifications as T;
SettingsFragment:: class -> _fragSettings as T; SettingsFragment:: class -> _fragSettings as T;
DeveloperFragment::class -> _fragDeveloper as T; DeveloperFragment::class -> _fragDeveloper as T;
LoginFragment::class -> _fragLogin as T; LoginFragment::class -> _fragLogin as T;
@@ -61,6 +61,11 @@ class SourcePluginConfig(
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl); val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
val absoluteScriptUrl: String get() = resolveAbsoluteUrl(scriptUrl, sourceUrl)!!; val absoluteScriptUrl: String get() = resolveAbsoluteUrl(scriptUrl, sourceUrl)!!;
fun isOfficialAuthor(): Boolean {
return scriptSignature != null &&
scriptPublicKey == "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsoFJU4AReDyUnSQI9A99UjLCwkY8OH+1o8cdtf2EjSb+fO2qmP8MGMTAvfvgmq5d2QBJE2XHRkRO3JKcTlcc1j0WlOlU8P9W272DYCeX6oYaavpKNqGKoGEuodp9wtiyNwyH46++JfpU/uIUacZbZKkHv9gIGchmNvpKYZQjFd/8pUqXGpcXZP54tGSC9PLcY+5TozZThK7Oy1+3YEf1bZ44UinRYYATbLk/wNuAfsupvlt6nxZOcJhABhdo9V+gY0FE6Ayg5+1cd1noWhnRtLF+sPdEr3z8Nt15JEK5a/524t25FMhwz8yKxlGW5qW3QLJHSUgLQncL6a1zlZ1s8QIDAQAB"
}
private fun resolveAbsoluteUrl(url: String?, sourceUrl: String?): String? { private fun resolveAbsoluteUrl(url: String?, sourceUrl: String?): String? {
if(url == null) if(url == null)
return null; return null;
@@ -165,6 +170,12 @@ class SourcePluginConfig(
"Unrestricted Http Header access", "Unrestricted Http Header access",
"Allows this plugin to access all headers (including cookies and authorization headers) for unauthenticated requests." "Allows this plugin to access all headers (including cookies and authorization headers) for unauthenticated requests."
)) ))
if(packagesOptional.contains("Browser") || packages.contains("Browser")) {
list.add(Pair(
"Browser Interop",
"This plugin requires webbrowser interop. May access urls outside of the restricted urls. This will only work for official plugins and during development builds."
))
}
return list; return list;
} }
@@ -15,7 +15,9 @@ import com.caoccao.javet.values.primitive.V8ValueString
import com.caoccao.javet.values.reference.IV8ValuePromise import com.caoccao.javet.values.reference.IV8ValuePromise
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.caoccao.javet.values.reference.V8ValuePromise import com.caoccao.javet.values.reference.V8ValuePromise
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.engine.exceptions.NoInternetException import com.futo.platformplayer.engine.exceptions.NoInternetException
@@ -34,6 +36,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptTimeoutException
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
import com.futo.platformplayer.engine.internal.V8Converter import com.futo.platformplayer.engine.internal.V8Converter
import com.futo.platformplayer.engine.packages.PackageBridge import com.futo.platformplayer.engine.packages.PackageBridge
import com.futo.platformplayer.engine.packages.PackageBrowser
import com.futo.platformplayer.engine.packages.PackageDOMParser import com.futo.platformplayer.engine.packages.PackageDOMParser
import com.futo.platformplayer.engine.packages.PackageHttp import com.futo.platformplayer.engine.packages.PackageHttp
import com.futo.platformplayer.engine.packages.PackageHttpImp import com.futo.platformplayer.engine.packages.PackageHttpImp
@@ -44,6 +47,7 @@ import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateAssets import com.futo.platformplayer.states.StateAssets
import com.futo.platformplayer.states.StateDeveloper
import com.futo.platformplayer.toList import com.futo.platformplayer.toList
import com.futo.platformplayer.toV8ValueBlocking import com.futo.platformplayer.toV8ValueBlocking
import com.futo.platformplayer.toV8ValueAsync import com.futo.platformplayer.toV8ValueAsync
@@ -218,6 +222,9 @@ class V8Plugin {
if(pack is PackageHttp) { if(pack is PackageHttp) {
pack.cleanup(); pack.cleanup();
} }
else if(pack is PackageBrowser) {
pack.deinitialize();
}
} }
_runtime?.let { _runtime?.let {
@@ -387,6 +394,18 @@ class V8Plugin {
"HttpImp" -> PackageHttpImp(this, config) "HttpImp" -> PackageHttpImp(this, config)
"Utilities" -> PackageUtilities(this, config) "Utilities" -> PackageUtilities(this, config)
"JSDOM" -> PackageJSDOM(this, config) "JSDOM" -> PackageJSDOM(this, config)
"Browser" -> {
val isOfficial = (config is SourcePluginConfig && config.isOfficialAuthor());
if(BuildConfig.DEBUG)
PackageBrowser(this)
else if(isOfficial)
PackageBrowser(this)
else if(config is SourcePluginConfig && config.id == StateDeveloper.DEV_ID)
PackageBrowser(this)
else
throw IllegalArgumentException("Browser is only allowed for debug and official plugins due to security");
};
else -> if(allowNull) null else throw ScriptCompilationException(config, "Unknown package [${packageName}] required for plugin ${config.name}"); else -> if(allowNull) null else throw ScriptCompilationException(config, "Unknown package [${packageName}] required for plugin ${config.name}");
}; };
} }
@@ -105,6 +105,11 @@ class PackageBridge : V8Package {
) )
} }
@V8Function
fun hasPackage(str: String): Boolean {
return _plugin.getPackages().any { it.name == str };
}
@V8Function @V8Function
fun dispose(value: V8Value) { fun dispose(value: V8Value) {
Logger.e(TAG, "Manual dispose: " + value.javaClass.name); Logger.e(TAG, "Manual dispose: " + value.javaClass.name);
@@ -0,0 +1,197 @@
package com.futo.platformplayer.engine.packages
import android.graphics.Bitmap
import android.webkit.ConsoleMessage
import android.webkit.JavascriptInterface
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.collection.emptyLongSet
import com.caoccao.javet.annotations.V8Function
import com.caoccao.javet.values.reference.V8ValueFunction
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDeveloper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.withTimeout
class PackageBrowser: V8Package {
override val name: String get() = "Browser";
override val variableName: String = "browser";
@Transient
private var _readySemaphore: Semaphore? = null;
@Transient
private val _callbacks = mutableMapOf<String, (String?)->Unit>();
@Transient
private val _interop = JSInterop(this);
@Transient
private var _browser: WebView? = null;
private val browser: WebView get() {
if(_browser == null)
throw IllegalStateException("Browser not initialized");
return _browser!!;
}
constructor(v8Plugin: V8Plugin): super(v8Plugin) {
}
@V8Function
fun initialize() {
if(_browser == null){
StateApp.instance.scope.launch(Dispatchers.Main) {
_browser = WebView(StateApp.instance.contextOrNull ?: return@launch);
_browser?.settings?.javaScriptEnabled = true;
_browser?.settings?.blockNetworkImage = false;
_browser?.settings?.blockNetworkLoads = false;
_browser?.settings?.allowContentAccess = false;
_browser?.settings?.allowFileAccess = false;
//_browser?.settings?.useWideViewPort = true;
//_browser?.settings?.loadWithOverviewMode = true;
_browser?.webViewClient = object : WebViewClient() {
override fun onPageCommitVisible(view: WebView?, url: String?) {
super.onPageCommitVisible(view, url)
_readySemaphore?.release();
_readySemaphore = null;
Logger.i("PackageBrowser", "Browser loaded");
}
}
_browser?.webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
if(consoleMessage?.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
val msg = "Browser Error:${consoleMessage?.message()} [${consoleMessage?.lineNumber()}]" ?: ""
Logger.e("PackageBrowser", msg);
if(_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
StateDeveloper.instance.logDevException(StateDeveloper.instance.currentDevID ?: "", msg)
}
else {
val msg = "Browser Log:" + consoleMessage?.message() ?: "";
Logger.e("PackageBrowser", msg);
if(_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
StateDeveloper.instance.logDevInfo(StateDeveloper.instance.currentDevID ?: "", msg);
}
return super.onConsoleMessage(consoleMessage)
}
}
_browser?.addJavascriptInterface(_interop, "__GJ");
}
return;
}
}
@V8Function
fun deinitialize() {
_browser?.destroy();
_browser = null;
}
@V8Function
fun getCurrentUrl(): String? {
return browser.url;
}
@V8Function
fun waitTillLoaded(timeout: Int = 1000): Boolean {
val acquired = _readySemaphore?.let {
if(!it.tryAcquire()) {
Logger.i("PackageBrowser", "Waiting for browser to be ready");
if(!runBlocking {
try {
return@runBlocking withTimeout(timeout.toLong(), {
it.acquire()
return@withTimeout true;
});
}
catch(ex: TimeoutCancellationException) {
return@runBlocking false;
}
}) return@let false;
}
it.release();
return@let true;
} ?: true;
if(acquired)
Logger.i("PackageBrowser", "Browser is ready");
else
Logger.i("PackageBrowser", "Browser failed wait ready");
return acquired;
}
@V8Function
fun load(url: String) {
Logger.i("PackageBrowser", "Browser loading url [${url}]");
_readySemaphore = Semaphore(1, 1);
StateApp.instance.scope.launch(Dispatchers.Main) {
browser.loadUrl(url);
}
}
@V8Function
fun run(js: String, callbackId: String? = null, callback: V8ValueFunction? = null) {
waitTillLoaded();
val funcClone = callback?.toClone<V8ValueFunction>()
if(callbackId != null && callback != null) {
synchronized(_callbacks) {
_callbacks.put(callbackId, {
funcClone?.callVoid(null, arrayOf(it));
});
}
}
StateApp.instance.scope.launch(Dispatchers.Main) {
try {
Logger.i("PackageBrowser", "Browser running JS with callback [${callbackId}]\n${(if(js.length > 200) (js.substring(0, 200) + "...") else js)})");
browser.evaluateJavascript(js, object : ValueCallback<String> {
override fun onReceiveValue(value: String?) {
Logger.i("PackageBrowser", "Browser run finished");
}
})
}
catch(ex: Throwable) {
Logger.e("PackageBrowser", "Failed to invoke browser", ex);
}
}
}
@V8Function
fun runWithReturn(js: String, callback: V8ValueFunction? = null) {
waitTillLoaded();
val funcClone = callback?.toClone<V8ValueFunction>()
StateApp.instance.scope.launch(Dispatchers.Main) {
try {
Logger.i("PackageBrowser", "Browser running JS with callback [sync]\n${(if(js.length > 200) (js.substring(0, 200) + "...") else js)})");
browser.evaluateJavascript(js, object : ValueCallback<String> {
override fun onReceiveValue(value: String?) {
Logger.i("PackageBrowser", "Browser run returned: " + (value ?: ""));
funcClone?.callVoid(null, arrayOf(value));
}
})
}
catch(ex: Throwable) {
Logger.e("PackageBrowser", "Failed to invoke browser", ex);
}
}
}
class JSInterop(private val pack: PackageBrowser) {
@JavascriptInterface
fun callback(id: String, result: String) {
Logger.i("PackageBrowser", "Browser Callback [${id}]: ${result}");
val callback = synchronized(pack._callbacks) { pack._callbacks.remove(id); };
if(callback != null)
callback.invoke(result);
}
@JavascriptInterface
fun log(msg: String) {
Logger.i("PackageBrowser", "Log: " + msg);
}
}
}
@@ -10,6 +10,7 @@ import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import android.widget.AdapterView import android.widget.AdapterView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.LinearLayout
import android.widget.Spinner import android.widget.Spinner
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@@ -21,6 +22,7 @@ class BrowserFragment : MainFragment() {
override val isTab: Boolean = false; override val isTab: Boolean = false;
override val hasBottomBar: Boolean get() = true; override val hasBottomBar: Boolean get() = true;
private var _root: LinearLayout? = null;
private var _webview: WebView? = null; private var _webview: WebView? = null;
private val _webviewWithoutHandling = object: WebViewClient() { private val _webviewWithoutHandling = object: WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
@@ -31,6 +33,7 @@ class BrowserFragment : MainFragment() {
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = inflater.inflate(R.layout.fragment_browser, container, false); val view = inflater.inflate(R.layout.fragment_browser, container, false);
_root = view.findViewById<LinearLayout>(R.id.root);
_webview = view.findViewById<WebView?>(R.id.webview).apply { _webview = view.findViewById<WebView?>(R.id.webview).apply {
this.webViewClient = _webviewWithoutHandling; this.webViewClient = _webviewWithoutHandling;
this.settings.javaScriptEnabled = true; this.settings.javaScriptEnabled = true;
@@ -43,7 +46,12 @@ class BrowserFragment : MainFragment() {
override fun onShownWithView(parameter: Any?, isBack: Boolean) { override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack) super.onShownWithView(parameter, isBack)
if(parameter is String) { if(parameter is WebView) {
_root?.removeView(_webview);
_root?.addView(parameter);
_webview = parameter;
}
else if(parameter is String) {
_webview?.webViewClient = _webviewWithoutHandling; _webview?.webViewClient = _webviewWithoutHandling;
_webview?.loadUrl(parameter); _webview?.loadUrl(parameter);
} }
@@ -47,7 +47,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
private val _progressBar: ProgressBar; private val _progressBar: ProgressBar;
private val _spinnerSortBy: Spinner; private val _spinnerSortBy: Spinner;
private val _containerSortBy: LinearLayout; private val _containerSortBy: LinearLayout;
private val _announcementView: AnnouncementView; //private val _announcementView: AnnouncementView;
private val _tagsView: TagsView; private val _tagsView: TagsView;
private val _textCentered: TextView; private val _textCentered: TextView;
private val _emptyPagerContainer: FrameLayout; private val _emptyPagerContainer: FrameLayout;
@@ -87,7 +87,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
_textCentered = findViewById(R.id.text_centered); _textCentered = findViewById(R.id.text_centered);
_emptyPagerContainer = findViewById(R.id.empty_pager_container); _emptyPagerContainer = findViewById(R.id.empty_pager_container);
_progressBar = findViewById(R.id.progress_bar); _progressBar = findViewById(R.id.progress_bar);
_announcementView = findViewById(R.id.announcement_view) //_announcementView = findViewById(R.id.announcement_view)
_progressBar.inactiveColor = Color.TRANSPARENT; _progressBar.inactiveColor = Color.TRANSPARENT;
_swipeRefresh = findViewById(R.id.swipe_refresh); _swipeRefresh = findViewById(R.id.swipe_refresh);
@@ -192,7 +192,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
} }
protected fun showAnnouncementView() { protected fun showAnnouncementView() {
_announcementView.visibility = View.VISIBLE //_announcementView.visibility = View.VISIBLE
} }
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) { private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
@@ -266,7 +266,7 @@ class LibraryFragment : MainFragment() {
}); });
if(this.allowMusic) { if(this.allowMusic) {
val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount); val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount)
adapterArtists.setData(artists); adapterArtists.setData(artists);
if (artists.size == 0) if (artists.size == 0)
sectionArtists.setEmpty( sectionArtists.setEmpty(
@@ -289,7 +289,7 @@ class LibraryFragment : MainFragment() {
} }
if(this.allowMusic) { if(this.allowMusic) {
val albums = StateLibrary.instance.getAlbums(); val albums = StateLibrary.instance.getAlbums()
adapterAlbums.setData(albums); adapterAlbums.setData(albums);
if (albums.size == 0) if (albums.size == 0)
sectionAlbums.setEmpty("No albums", "No albums were found on your device", -1); sectionAlbums.setEmpty("No albums", "No albums were found on your device", -1);
@@ -7,6 +7,9 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.fragment.mainactivity.main.CreatorsFragment import com.futo.platformplayer.fragment.mainactivity.main.CreatorsFragment
@@ -17,18 +20,54 @@ import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragmentData import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragmentData
import com.futo.platformplayer.models.SearchType import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.views.casting.CastButton import com.futo.platformplayer.views.casting.CastButton
import com.futo.platformplayer.views.notification.NotificationOverlayView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class GeneralTopBarFragment : TopFragment() { class GeneralTopBarFragment : TopFragment() {
private var _buttonSearch: ImageButton? = null; private var _buttonSearch: ImageButton? = null;
private var _buttonCast: CastButton? = null; private var _buttonCast: CastButton? = null;
private var _buttonNotifs: ConstraintLayout? = null;
private var _buttonNotifIcon: ImageView? = null;
private var _buttonNotifCount: TextView? = null;
init {
StateAnnouncement.instance.onAnnouncementChanged.subscribe {
lifecycleScope?.launch(Dispatchers.Main) {
updateNotifCount();
}
}
}
fun updateNotifCount() {
val currentAnnouncements = StateAnnouncement.instance.getVisibleAnnouncements();
if(currentAnnouncements.any())
_buttonNotifCount?.let {
it.text = currentAnnouncements.size.toString();
it.visibility = View.VISIBLE;
}
else
_buttonNotifCount?.let {
it.text = currentAnnouncements.size.toString();
it.visibility = View.GONE;
}
}
override fun onShown(parameter: Any?) { override fun onShown(parameter: Any?) {
if(currentMain is CreatorsFragment) { if(currentMain is CreatorsFragment) {
_buttonSearch?.setImageResource(R.drawable.ic_person_search_300w); _buttonSearch?.setImageResource(R.drawable.ic_person_search_300w);
} else { } else {
_buttonSearch?.setImageResource(R.drawable.ic_search_300w); _buttonSearch?.setImageResource(R.drawable.ic_search_300w);
} }
if(currentMain is NotificationOverlayView.Frag) {
_buttonNotifIcon?.setImageResource(R.drawable.ic_notifications_filled)
}
else {
_buttonNotifIcon?.setImageResource(R.drawable.ic_notifications)
}
} }
override fun onHide() { override fun onHide() {
@@ -44,6 +83,16 @@ class GeneralTopBarFragment : TopFragment() {
val buttonSearch: ImageButton = view.findViewById(R.id.button_search); val buttonSearch: ImageButton = view.findViewById(R.id.button_search);
_buttonCast = view.findViewById(R.id.button_cast); _buttonCast = view.findViewById(R.id.button_cast);
_buttonNotifs = view.findViewById(R.id.button_notifs);
_buttonNotifIcon = view.findViewById(R.id.button_notifs_icon);
_buttonNotifCount = view.findViewById(R.id.button_notifs_count);
updateNotifCount();
_buttonNotifs?.setOnClickListener {
navigate<NotificationOverlayView.Frag>();
}
buttonSearch.setOnClickListener { buttonSearch.setOnClickListener {
if(currentMain is CreatorsFragment) { if(currentMain is CreatorsFragment) {
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.CREATOR)); navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.CREATOR));
@@ -1,13 +1,22 @@
package com.futo.platformplayer.states package com.futo.platformplayer.states
import android.view.View
import android.view.WindowManager
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.dialogs.PluginUpdateDialog
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringHashSetStorage import com.futo.platformplayer.stores.StringHashSetStorage
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -110,6 +119,48 @@ class StateAnnouncement {
onAnnouncementChanged.emit(); onAnnouncementChanged.emit();
} }
//Special Announcements
fun registerPluginUpdate(oldConfig: SourcePluginConfig, newConfig: SourcePluginConfig): SessionAnnouncement {
val announcement = SessionAnnouncement(
"update-plugin-" + UUID.randomUUID().toString(),
"${newConfig.name} update v${newConfig.version} available!",
"An update is available to upgrade from ${oldConfig.version} to ${newConfig.version}.",
AnnouncementType.SESSION,
null, "updates", "Update", StateAnnouncement.ACTION_UPDATE_PLUGIN,
null, null,oldConfig.id,
newConfig?.absoluteIconUrl?.let { ImageVariable.fromUrl(it) }
).withExtraAction("Changelog", StateAnnouncement.ACTION_CHANGELOG, oldConfig.id);
registerAnnouncementSession(announcement);
return announcement;
}
fun registerPluginUpdated(newConfig: SourcePluginConfig) {
registerAnnouncementSession(SessionAnnouncement(
"updated-plugin-" + UUID.randomUUID().toString(),
"${newConfig.name} updated to v${newConfig.version}!",
"You have succesfully been updater to v${newConfig.version}.",
AnnouncementType.SESSION,
null, "updates", null, null,
null, null,null,
newConfig?.absoluteIconUrl?.let { ImageVariable.fromUrl(it) }
).withExtraAction("Changelog", StateAnnouncement.ACTION_CHANGELOG, newConfig.id));
}
fun registerLoading(title: String, description: String, icon: ImageVariable? = null, customId: String? = null): SessionAnnouncement {
val id = "loading-" + UUID.randomUUID().toString();
val announcement = SessionAnnouncement(
customId ?: id,
title,
description,
AnnouncementType.ONGOING,
null, "loading", null, null,
null, null,null, icon
);
registerAnnouncementSession(announcement);
return announcement;
}
fun getVisibleAnnouncements(category: String? = null): List<Announcement> { fun getVisibleAnnouncements(category: String? = null): List<Announcement> {
synchronized(_lock) { synchronized(_lock) {
if (category != null) { if (category != null) {
@@ -122,7 +173,9 @@ class StateAnnouncement {
} }
} }
fun closeAnnouncement(id: String) { fun closeAnnouncement(id: String?) {
if(id == null)
return;
val item: Announcement?; val item: Announcement?;
synchronized(_lock) { synchronized(_lock) {
item = _announcementsStore.findItem { it.id == id }; item = _announcementsStore.findItem { it.id == id };
@@ -164,6 +217,7 @@ class StateAnnouncement {
cancelAction?.invoke(item); cancelAction?.invoke(item);
} }
} }
onAnnouncementChanged?.emit();
} }
fun deleteAllAnnouncements() { fun deleteAllAnnouncements() {
@@ -194,7 +248,9 @@ class StateAnnouncement {
onAnnouncementChanged.emit(); onAnnouncementChanged.emit();
} }
fun neverAnnouncement(id: String) { fun neverAnnouncement(id: String?) {
if(id == null)
return;
synchronized(_lock) { synchronized(_lock) {
val item = _announcementsStore.findItem { it.id == id }; val item = _announcementsStore.findItem { it.id == id };
if (item != null && !_announcementsNever.contains(id)) if (item != null && !_announcementsNever.contains(id))
@@ -208,19 +264,26 @@ class StateAnnouncement {
_announcementsNever.save(); _announcementsNever.save();
onAnnouncementChanged.emit(); onAnnouncementChanged.emit();
} }
fun actionAnnouncement(id: String) { fun actionAnnouncement(id: String?, extra: Boolean = false) {
if(id == null)
return;
val item = _announcementsStore.findItem { it.id == id } ?: _sessionAnnouncements[id]; val item = _announcementsStore.findItem { it.id == id } ?: _sessionAnnouncements[id];
if(item != null) if(item != null)
actionAnnouncement(item); actionAnnouncement(item, extra);
} }
fun actionAnnouncement(item: Announcement) { fun actionAnnouncement(item: Announcement, extra: Boolean = false) {
val actionId = if(!extra) item.actionId else if(item is SessionAnnouncement) item.extraActionId else null;
val actionData = if(!extra) item.actionData else if(item is SessionAnnouncement) item.extraActionData else null;
val action = _sessionActions[item.id]; val action = _sessionActions[item.id];
if (action != null) { if (action != null) {
action(item); action(item);
} else { } else {
when (item.actionId) { when (actionId) {
ACTION_NEVER -> neverAnnouncement(item.id); ACTION_NEVER -> neverAnnouncement(item.id);
ACTION_SOMETHING -> actionSomething(); ACTION_SOMETHING -> actionSomething();
ACTION_CHANGELOG -> actionChangelog(actionData);
ACTION_UPDATE_PLUGIN -> actionUpdatePlugin(item.id, actionData);
} }
} }
} }
@@ -251,6 +314,84 @@ class StateAnnouncement {
} }
private fun actionChangelog(id: String?) {
if(id == null)
return;
StateApp.instance.contextOrNull?.let { context ->
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
val plugin = StatePlugins.instance.getPlugin(id);
if (plugin == null)
return@launch
val update = StatePlugins.instance.checkForUpdates(plugin.config);
if(update == null)
return@launch;
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
UIDialogs.showChangelogDialog(context, update.version, update.changelog!!.filterKeys { it.toIntOrNull() != null }
.mapKeys { it.key.toInt() }
.mapValues { update.getChangelogString(it.key.toString()) ?: "" });
}
}
}
}
private fun actionUpdatePlugin(notifId: String?, id: String?) {
if(id == null)
return;
val plugin = StatePlugins.instance.getPlugin(id);
if (plugin == null)
return
closeAnnouncement(notifId);
val loadingAnnouncement = registerLoading("Updating ${plugin.config.name}..", "An update is in progress for ${plugin.config.name}.",
if(plugin.config.absoluteIconUrl != null) ImageVariable.fromUrl(plugin.config.absoluteIconUrl!!) else null);
val loadingId = loadingAnnouncement.id;
StateApp.instance.contextOrNull?.let { context ->
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
val update = StatePlugins.instance.checkForUpdates(plugin.config);
if (update == null)
return@launch;
val client = ManagedHttpClient();
client.setTimeout(10000);
val script = StatePlugins.instance.getScript(plugin.config.id) ?: "";
val newScript = client.get(update.absoluteScriptUrl)?.body?.string();
if(newScript.isNullOrEmpty())
throw IllegalStateException("No script found");
if(true || plugin.config.isLowRiskUpdate(script, update, newScript)) {
StatePlugins.instance.installPluginBackground(context, StateApp.instance.scope, update, newScript,
{ text: String, progress: Double -> },
{ ex ->
if(ex == null) {
registerPluginUpdated(update);
}
else {
UIDialogs.appToast("Update for ${update.name} failed\n" + ex.message);
}
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
closeAnnouncement(loadingId);
}
});
}
else {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
closeAnnouncement(loadingId);
UIDialogs.showPluginUpdateDialog(context, plugin.config, update);
}
}
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to trigger update from announcement", ex);
}
}
}
}
fun registerDefaultHandlerAnnouncement() { fun registerDefaultHandlerAnnouncement() {
registerAnnouncement( registerAnnouncement(
"default-url-handler", "default-url-handler",
@@ -279,6 +420,8 @@ class StateAnnouncement {
const val ACTION_SOMETHING = "SOMETHING"; const val ACTION_SOMETHING = "SOMETHING";
const val ACTION_CHANGELOG = "CHANGELOG";
const val ACTION_UPDATE_PLUGIN = "UPDATE_PLUGIN";
const val ACTION_NEVER = "NEVER"; const val ACTION_NEVER = "NEVER";
private const val TAG = "StateAnnouncement"; private const val TAG = "StateAnnouncement";
} }
@@ -294,7 +437,8 @@ open class Announcement(
val time: OffsetDateTime? = null, val time: OffsetDateTime? = null,
val category: String? = null, val category: String? = null,
val actionName: String? = null, val actionName: String? = null,
val actionId: String? = null val actionId: String? = null,
val actionData: String? = null
); );
class SessionAnnouncement( class SessionAnnouncement(
id: String, id: String,
@@ -306,7 +450,9 @@ class SessionAnnouncement(
actionName: String? = null, actionName: String? = null,
actionId: String? = null, actionId: String? = null,
val cancelName: String? = null, val cancelName: String? = null,
val cancelActionId: String? = null val cancelActionId: String? = null,
actionData: String? = null,
val icon: ImageVariable? = null
): Announcement( ): Announcement(
id= id, id= id,
title = title, title = title,
@@ -315,13 +461,40 @@ class SessionAnnouncement(
time = time, time = time,
category = category, category = category,
actionName = actionName, actionName = actionName,
actionId = actionId actionId = actionId,
); actionData = actionData
) {
var extraActionName: String? = null;
var extraActionId: String? = null;
var extraActionData: String? = null;
var extraObj: Any? = null;
var progress: Double? = null;
val onProgressChanged = Event1<SessionAnnouncement>();
fun withExtraAction(name: String, id: String, data: String? = null): SessionAnnouncement {
extraActionName = name;
extraActionId = id;
extraActionData = data;
return this;
}
fun setProgress(progress: Double) {
this.progress = progress;
onProgressChanged?.emit(this);
}
fun setProgress(progress: Int) {
this.progress = progress.toDouble().div(100);
onProgressChanged?.emit(this);
}
}
enum class AnnouncementType(val value : Int) { enum class AnnouncementType(val value : Int) {
DELETABLE(0), //Close button deletes announcement (generally for actions) DELETABLE(0), //Close button deletes announcement (generally for actions)
RECURRING(1), //Shows up till never is pressed (generally for patchnotes etc) RECURRING(1), //Shows up till never is pressed (generally for patchnotes etc)
PERMANENT(2), //Shows up until deleted through other means (action) PERMANENT(2), //Shows up until deleted through other means (action)
SESSION(3), //Not persistent, only during this session SESSION(3), //Not persistent, only during this session
SESSION_RECURRING(4); //Not persistent, only during this session, recurring id SESSION_RECURRING(4), //Not persistent, only during this session, recurring id
ONGOING(5);
} }
@@ -43,6 +43,7 @@ import com.futo.platformplayer.logging.AndroidLogConsumer
import com.futo.platformplayer.logging.FileLogConsumer import com.futo.platformplayer.logging.FileLogConsumer
import com.futo.platformplayer.logging.LogLevel import com.futo.platformplayer.logging.LogLevel
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.receivers.AudioNoisyReceiver import com.futo.platformplayer.receivers.AudioNoisyReceiver
import com.futo.platformplayer.services.DownloadService import com.futo.platformplayer.services.DownloadService
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
@@ -732,8 +733,10 @@ class StateApp {
)); ));
for(update in updateAvailable) for(update in updateAvailable)
if(StatePlatform.instance.isClientEnabled(update.first.id)) if(StatePlatform.instance.isClientEnabled(update.first.id)) {
UIDialogs.showPluginUpdateDialog(context, update.first, update.second); //UIDialogs.showPluginUpdateDialog(context, update.first, update.second);
StateAnnouncement.instance.registerPluginUpdate(update.first, update.second);
}
} }
} }
} }
@@ -116,7 +116,7 @@ class StatePlugins {
_updatesAvailableMap = updatesAvailableFor _updatesAvailableMap = updatesAvailableFor
return@withContext configs; return@withContext configs;
} }
private suspend fun checkForUpdates(c: SourcePluginConfig): SourcePluginConfig? = withContext(Dispatchers.IO) { suspend fun checkForUpdates(c: SourcePluginConfig): SourcePluginConfig? = withContext(Dispatchers.IO) {
val sourceUrl = c.sourceUrl ?: return@withContext null; val sourceUrl = c.sourceUrl ?: return@withContext null;
Logger.i(TAG, "Check for source updates '${c.name}'."); Logger.i(TAG, "Check for source updates '${c.name}'.");
@@ -113,7 +113,10 @@ class StateUpdate {
if (!dir.exists()) { if (!dir.exists()) {
dir.mkdirs(); dir.mkdirs();
} }
return File(dir, "app-${DESIRED_ABI}-${version}.apk"); val result = File(dir, "app-${DESIRED_ABI}-${version}.apk");
//if(result.exists())
// result.delete();
return result;
} }
fun getPartialApkFile(context: Context, version: Int): File { fun getPartialApkFile(context: Context, version: Int): File {
@@ -121,7 +124,10 @@ class StateUpdate {
if (!dir.exists()) { if (!dir.exists()) {
dir.mkdirs(); dir.mkdirs();
} }
return File(dir, "app-${DESIRED_ABI}-${version}.apk.part"); val result = File(dir, "app-${DESIRED_ABI}-${version}.apk.part");
//if(result.exists())
// result.delete();
return result;
} }
fun finish() { fun finish() {
@@ -6,12 +6,10 @@ import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.dp
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.Announcement import com.futo.platformplayer.states.Announcement
import com.futo.platformplayer.states.AnnouncementType import com.futo.platformplayer.states.AnnouncementType
@@ -162,6 +160,10 @@ class AnnouncementView : LinearLayout {
_textClose.visibility = View.VISIBLE; _textClose.visibility = View.VISIBLE;
_textNever.visibility = View.VISIBLE; _textNever.visibility = View.VISIBLE;
} }
AnnouncementType.ONGOING -> {
_textClose.visibility = View.GONE;
_textNever.visibility = View.GONE;
}
} }
if (announcement.time != null) { if (announcement.time != null) {
@@ -0,0 +1,238 @@
package com.futo.platformplayer.views.notification
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.Announcement
import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.SessionAnnouncement
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.AnyAdapterView
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.LoaderView
import com.futo.platformplayer.views.adapters.AnyAdapter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class NotificationOverlayView: ConstraintLayout {
lateinit var recycler: RecyclerView;
var adapterNotifications: AnyAdapterView<Announcement, ViewHolder>;
constructor(context: Context) : super(context) {
inflate(context, R.layout.overlay_notifications, this)
recycler = findViewById<RecyclerView>(R.id.container_notifications);
adapterNotifications = recycler.asAny<Announcement, ViewHolder>(RecyclerView.VERTICAL, false, {
});
}
fun onShown(parameter: Any?) {
val announcements = StateAnnouncement.instance.getVisibleAnnouncements();
adapterNotifications.adapter.setData(announcements);
StateAnnouncement.instance.onAnnouncementChanged.subscribe(this) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
Logger.i("NotificationOverlayView", "Announcements Changed");
val adapter = adapterNotifications;
val announcements = StateAnnouncement.instance.getVisibleAnnouncements();
adapter.adapter.setData(announcements);
}
}
}
fun onResume() {
}
fun onPause() {
StateAnnouncement.instance.onAnnouncementChanged.remove(this);
}
class ViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder<Announcement>(
LayoutInflater.from(_viewGroup.context).inflate(
R.layout.list_announcement,
_viewGroup, false)) {
protected var _announcement: Announcement? = null;
protected val _textName: TextView
protected val _textMetadata: TextView;
protected val _icon: ImageView;
protected val _buttonIgnore: ImageView
protected val _buttonNever: LinearLayout
protected val _buttonAction: LinearLayout
protected val _buttonActionText: TextView
protected val _buttonExtra: LinearLayout
protected val _buttonExtraText: TextView
protected val _loader: LoaderView;
protected val _progress: ProgressBar;
init {
_textName = _view.findViewById(R.id.text_name);
_textMetadata = _view.findViewById(R.id.text_metadata);
_buttonIgnore = _view.findViewById(R.id.button_ignore);
_buttonNever = _view.findViewById(R.id.button_never);
_buttonAction = _view.findViewById(R.id.button_action);
_buttonActionText = _view.findViewById(R.id.button_action_text);
_buttonExtra = _view.findViewById(R.id.button_extra);
_buttonExtraText = _view.findViewById(R.id.button_extra_text);
_icon = _view.findViewById(R.id.icon);
_loader = _view.findViewById(R.id.loader);
_progress = _view.findViewById(R.id.progress);
_buttonIgnore.setOnClickListener {
_announcement.let {
StateAnnouncement.instance.closeAnnouncement(it?.id);
}
}
_buttonNever.setOnClickListener {
_announcement.let {
StateAnnouncement.instance.neverAnnouncement(it?.id);
}
}
_buttonExtra.setOnClickListener {
_announcement.let {
StateAnnouncement.instance.actionAnnouncement(it?.id, true)
}
}
_buttonAction.setOnClickListener {
_announcement.let {
StateAnnouncement.instance.actionAnnouncement(it?.id);
}
}
}
override fun bind(value: Announcement) {
val oldAnnouncement = _announcement;
_announcement = value;
if(oldAnnouncement is SessionAnnouncement)
oldAnnouncement.onProgressChanged.clear();
_textName.text = value.title;
_textMetadata.text = value.msg;
if(value is SessionAnnouncement) {
if(value.icon != null) {
value.icon.setImageView(_icon);
_icon.visibility = View.VISIBLE;
}
else
_icon.visibility = View.GONE;
if(value.extraActionName != null && value.extraActionId != null) {
_buttonExtraText.text = value.extraActionName;
_buttonExtra.visibility = View.VISIBLE;
}
else
_buttonExtra.visibility = View.GONE;
if(value.announceType == AnnouncementType.ONGOING) {
_buttonIgnore.visibility = View.GONE;
}
else {
_buttonIgnore.visibility = View.VISIBLE;
}
if(value.progress != null && value.announceType == AnnouncementType.ONGOING) {
_progress.isVisible = true;
_progress.min = 0;
_progress.max = 100;
value.onProgressChanged.subscribe {
val prog = it.progress;
if(prog == 0.toDouble() || prog == 100.toDouble()) {
_progress.isIndeterminate = true;
}
else {
_progress.isIndeterminate = false;
_progress.setProgress(it.progress?.times(100)?.toInt() ?: 0, false);
}
}
}
else
_progress.isVisible = false;
}
else {
_buttonExtra.visibility = View.GONE;
_icon.visibility = View.GONE;
_buttonIgnore.visibility = View.VISIBLE;
}
if(value.announceType == AnnouncementType.ONGOING) {
_loader.visibility = View.VISIBLE;
_loader.start();
}
else {
_loader.visibility = View.GONE;
_loader.stop();
}
_buttonNever.visibility =
if (value.announceType == AnnouncementType.RECURRING || value.announceType == AnnouncementType.SESSION_RECURRING)
View.VISIBLE
else
View.GONE;
_buttonAction.visibility =
if(value.actionId != null && value.actionName != null)
View.VISIBLE;
else View.GONE;
if(value.actionId != null && value.actionName != null) {
_buttonActionText.text = value.actionName;
}
}
}
class Frag : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var _view: NotificationOverlayView? = null;
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
_view?.onShown(parameter);
}
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = NotificationOverlayView(requireContext());
_view = view;
return view;
}
override fun onDestroyMainView() {
super.onDestroyMainView();
_view = null;
}
override fun onResume() {
super.onResume()
_view?.onResume();
}
override fun onPause() {
super.onPause()
_view?.onPause();
}
}
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#2D63ED" />
<corners android:radius="20dp" />
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
</shape>
@@ -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="M160,760L160,680L240,680L240,400Q240,317 290,252.5Q340,188 420,168L420,140Q420,115 437.5,97.5Q455,80 480,80Q505,80 522.5,97.5Q540,115 540,140L540,168Q620,188 670,252.5Q720,317 720,400L720,680L800,680L800,760L160,760ZM480,880Q447,880 423.5,856.5Q400,833 400,800L560,800Q560,833 536.5,856.5Q513,880 480,880Z"/>
</vector>
+3 -2
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout <LinearLayout
android:id="@+id/root"
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -11,4 +12,4 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" /> android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout> </LinearLayout>
+2 -1
View File
@@ -38,11 +38,12 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical"> android:orientation="vertical">
<!--
<com.futo.platformplayer.views.announcements.AnnouncementView <com.futo.platformplayer.views.announcements.AnnouncementView
android:id="@+id/announcement_view" android:id="@+id/announcement_view"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:visibility="gone" /> android:visibility="gone" /> -->
<LinearLayout <LinearLayout
android:id="@+id/container_sort_by" android:id="@+id/container_sort_by"
@@ -46,6 +46,42 @@
android:scaleType="fitCenter" android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_cast_white_25dp" /> app:srcCompat="@drawable/ic_cast_white_25dp" />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/button_notifs">
<ImageButton
android:id="@+id/button_notifs_icon"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:contentDescription="@string/cd_button_notifs"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="11dp"
android:paddingBottom="10dp"
android:scaleType="fitCenter"
android:clickable="false"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:srcCompat="@drawable/ic_notifications" />
<TextView
android:id="@+id/button_notifs_count"
android:layout_width="18dp"
android:layout_height="18dp"
android:text="5"
android:textSize="12dp"
android:textAlignment="center"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:background="@drawable/background_primary_round_20dp"
android:layout_marginTop="3dp"
android:layout_marginRight="5dp"
android:clickable="false"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
<!--Back Button--> <!--Back Button-->
<ImageButton <ImageButton
android:id="@+id/button_search" android:id="@+id/button_search"
@@ -30,11 +30,12 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical"> android:orientation="vertical">
<!--
<com.futo.platformplayer.views.announcements.AnnouncementView <com.futo.platformplayer.views.announcements.AnnouncementView
android:id="@+id/announcement_view" android:id="@+id/announcement_view"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:visibility="gone" /> android:visibility="gone" /> -->
<com.futo.platformplayer.views.others.RadioGroupView <com.futo.platformplayer.views.others.RadioGroupView
android:id="@+id/radio_group" android:id="@+id/radio_group"
@@ -0,0 +1,167 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="0dp"
android:layout_marginBottom="0dp"
android:id="@+id/root"
android:clickable="true"
android:paddingTop="10dp"
android:paddingLeft="10dp"
android:paddingRight="10dp">
<ImageView
android:id="@+id/icon"
android:layout_width="30dp"
android:layout_height="30dp"
android:visibility="gone"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="@id/text_metadata" />
<TextView
android:id="@+id/text_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:textSize="13dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
tools:text="Example Artist"
android:maxLines="1"
app:layout_constraintLeft_toRightOf="@id/icon"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintRight_toLeftOf="@id/button_ignore"
android:layout_marginRight="20dp"
android:layout_marginTop="10dp"
app:layout_constraintBottom_toTopOf="@id/text_metadata"
android:layout_marginStart="10dp" />
<TextView
android:id="@+id/text_metadata"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:textSize="12dp"
android:textColor="#888888"
android:fontFamily="@font/inter_regular"
tools:text="3 videos"
android:maxLines="2"
app:layout_constraintTop_toBottomOf="@id/text_name"
app:layout_constraintLeft_toRightOf="@id/icon"
app:layout_constraintRight_toLeftOf="@id/button_ignore"
android:layout_marginRight="20dp"
android:paddingBottom="10dp"
android:layout_marginStart="10dp" />
<ImageView
android:id="@+id/button_ignore"
android:layout_width="30dp"
android:layout_height="30dp"
android:src="@drawable/ic_close"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="@id/text_metadata" />
<com.futo.platformplayer.views.LoaderView
android:id="@+id/loader"
android:layout_width="30dp"
android:layout_height="30dp"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="@id/text_metadata" />
<LinearLayout
android:id="@+id/container_buttons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_metadata"
app:layout_constraintBottom_toTopOf="@id/separator"
android:gravity="center"
android:paddingBottom="10dp"
android:orientation="horizontal">
<LinearLayout
android:id="@+id/button_never"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/background_button_accent"
android:layout_marginLeft="5dp"
android:layout_marginRight="5dp">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:paddingStart="28dp"
android:paddingEnd="28dp"
android:text="Never" />
</LinearLayout>
<LinearLayout
android:id="@+id/button_extra"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/background_button_accent"
android:layout_marginLeft="5dp"
android:layout_marginRight="5dp">
<TextView
android:id="@+id/button_extra_text"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:paddingStart="28dp"
android:paddingEnd="28dp"
android:text="Extra" />
</LinearLayout>
<LinearLayout
android:id="@+id/button_action"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="5dp"
android:layout_marginRight="5dp"
android:background="@drawable/background_button_primary"
android:clickable="true">
<TextView
android:id="@+id/button_action_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Action"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:paddingStart="28dp"
android:paddingEnd="28dp" />
</LinearLayout>
</LinearLayout>
<ProgressBar
android:id="@+id/progress"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:layout_marginBottom="1dp"
android:progressTint="@color/primary"
/>
<View
android:id="@+id/separator"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/container_buttons"
android:layout_width="match_parent"
android:layout_height="1px"
android:background="#181818" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<View
android:id="@+id/overlay_slide_up_menu_background"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#C9000000" />
<View
android:id="@+id/separator"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_width="match_parent"
android:layout_height="1px"
android:background="#181818" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/container_notifications"
app:layout_constraintTop_toBottomOf="@id/separator"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_width="match_parent"
android:layout_height="0dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
+1
View File
@@ -896,6 +896,7 @@
<string name="cd_creator_thumbnail">Creator thumbnail</string> <string name="cd_creator_thumbnail">Creator thumbnail</string>
<string name="cd_button_clear_search">Clear search</string> <string name="cd_button_clear_search">Clear search</string>
<string name="cd_button_search">Search</string> <string name="cd_button_search">Search</string>
<string name="cd_button_notifs">Notifications</string>
<string name="cd_search_icon">Search icon</string> <string name="cd_search_icon">Search icon</string>
<string name="cd_button_back">Back button</string> <string name="cd_button_back">Back button</string>
<string name="cd_app_icon">App icon</string> <string name="cd_app_icon">App icon</string>