Compare commits

...

13 Commits

Author SHA1 Message Date
Kelvin e147fdd77e Empty view for notifs, back on toggle off 2026-02-02 20:12:30 +01:00
Kelvin 6a8ac0bfaa Refs 2026-02-02 18:46:01 +01:00
Kelvin 772bff6bc0 Browser package fixes, advanced settings for plugin support 2026-02-02 18:41:51 +01:00
Kelvin b6b04054b9 Clear cookies on startup & after login 2026-01-31 21:20:31 +01:00
Kelvin 1ea794459c refs 2026-01-31 19:27:57 +01:00
Kelvin c27f5e4096 Cleanup fixes, v8 locking 2026-01-31 19:23:32 +01:00
Kelvin 8469f17b4c Fix threading for callbacks from browser 2026-01-31 13:15:09 +01:00
Kelvin 067abc415b Submods 2026-01-30 16:40:49 +01:00
Kelvin d692533f20 Merge branch 'package-browser' into 'master'
New Notification UI & PackageBrowser support

See merge request videostreaming/grayjay!163
2026-01-30 15:38:48 +00:00
Kelvin 31a6ea0f39 Browser support 2026-01-30 16:17:06 +01:00
Kelvin 5ba2f2be75 Package Browser support for testing 2026-01-27 05:13:15 +01:00
Kelvin 8536861e09 Update dialogs 2026-01-05 23:56:53 +01:00
Kelvin 71262da3c2 New notification ui 2026-01-02 20:38:43 +01:00
40 changed files with 1173 additions and 54 deletions
@@ -1047,8 +1047,12 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
var polycentricLocalCache: Boolean = true;
var showPrivacyModeDialog: Boolean = true;
fun shouldClearWebviewCookies(): Boolean {
return false;
}
}
@FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19)
@@ -7,6 +7,10 @@ import android.os.IBinder
import android.os.SystemClock
import com.futo.platformplayer.UIDialogs.ActionStyle
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.StateUpdate
import kotlinx.coroutines.*
@@ -14,6 +18,7 @@ import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.time.OffsetDateTime
class UpdateDownloadService : Service() {
@@ -85,13 +90,16 @@ class UpdateDownloadService : Service() {
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 force = progress == 100 && !indeterminate
if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) {
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 partialFile = StateUpdate.getPartialApkFile(this, version)
var announcement: SessionAnnouncement? = null;
try {
if (apkFile.exists() && apkFile.length() > 0L) {
Logger.i(TAG, "APK already downloaded at ${apkFile.absolutePath}")
@@ -106,6 +115,14 @@ class UpdateDownloadService : Service() {
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
for (attempt in 0 until MAX_RETRIES) {
@@ -115,7 +132,13 @@ class UpdateDownloadService : Service() {
}
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 (apkFile.exists()) {
@@ -145,6 +168,12 @@ class UpdateDownloadService : Service() {
}
}
} finally {
try {
if (announcement != null) {
StateAnnouncement.instance.closeAnnouncement(announcement.id);
}
}
catch(ex: Throwable){}
isDownloading = false
cancelRequested = false
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
Logger.i(TAG, "Starting download. url=$url, existingBytes=$startOffset")
@@ -204,7 +233,7 @@ class UpdateDownloadService : Service() {
progress > 100 -> 100
else -> progress
}
throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false)
throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false, onProgress)
}
} else {
throttledUpdateDownloadProgress(version, progress = 0, indeterminate = true)
@@ -250,6 +279,18 @@ class UpdateDownloadService : Service() {
UpdateNotificationManager.cancelAll(ctx)
UpdateInstaller.startInstall(ctx, version, apkFile)
}, 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) {
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
updateDownloadedDialog = null
@@ -110,6 +110,7 @@ import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.stores.SubscriptionStorage
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.views.ToastView
import com.futo.platformplayer.views.notification.NotificationOverlayView
import com.futo.polycentric.core.ApiMethods
import com.google.gson.JsonParser
import com.google.zxing.integration.android.IntentIntegrator
@@ -201,6 +202,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragLibraryVideos: LibraryVideosFragment;
lateinit var _fragLibrarySearch: LibrarySearchFragment;
lateinit var _fragLibraryFiles: LibraryFilesFragment;
lateinit var _fragNotifications: NotificationOverlayView.Frag;
lateinit var _fragSettings: SettingsFragment;
lateinit var _fragDeveloper: DeveloperFragment;
lateinit var _fragLogin: LoginFragment;
@@ -389,6 +391,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragLibraryVideos = LibraryVideosFragment.newInstance();
_fragLibraryFiles = LibraryFilesFragment.newInstance();
_fragLibrarySearch = LibrarySearchFragment.newInstance();
_fragNotifications = NotificationOverlayView.Frag();
_fragSettings = SettingsFragment.newInstance();
_fragDeveloper = DeveloperFragment.newInstance();
_fragLogin = LoginFragment.newInstance();
@@ -538,6 +541,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragLibrarySearch.topBar = _fragTopBarSearch;
_fragSettings.topBar = _fragTopBarNavigation;
_fragDeveloper.topBar = _fragTopBarNavigation;
_fragNotifications.topBar = _fragTopBarGeneral;
_fragBrowser.topBar = _fragTopBarNavigation;
@@ -1368,6 +1372,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
LibraryVideosFragment::class -> _fragLibraryVideos as T;
LibraryFilesFragment::class -> _fragLibraryFiles as T;
LibrarySearchFragment::class -> _fragLibrarySearch as T;
NotificationOverlayView.Frag::class -> _fragNotifications as T;
SettingsFragment:: class -> _fragSettings as T;
DeveloperFragment::class -> _fragDeveloper as T;
LoginFragment::class -> _fragLogin as T;
@@ -61,6 +61,11 @@ class SourcePluginConfig(
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, 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? {
if(url == null)
return null;
@@ -165,6 +170,12 @@ class SourcePluginConfig(
"Unrestricted Http Header access",
"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;
}
@@ -224,7 +235,8 @@ class SourcePluginConfig(
val variable: String? = null,
val dependency: String? = null,
val warningDialog: String? = null,
val options: List<String>? = null
val options: List<String>? = null,
val isAdvanced: Boolean? = null
) {
val variableOrName: String get() = variable ?: name;
}
@@ -15,7 +15,9 @@ import com.caoccao.javet.values.primitive.V8ValueString
import com.caoccao.javet.values.reference.IV8ValuePromise
import com.caoccao.javet.values.reference.V8ValueObject
import com.caoccao.javet.values.reference.V8ValuePromise
import com.futo.platformplayer.BuildConfig
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.constructs.Event1
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.internal.V8Converter
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.PackageHttp
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.logging.Logger
import com.futo.platformplayer.states.StateAssets
import com.futo.platformplayer.states.StateDeveloper
import com.futo.platformplayer.toList
import com.futo.platformplayer.toV8ValueBlocking
import com.futo.platformplayer.toV8ValueAsync
@@ -218,6 +222,9 @@ class V8Plugin {
if(pack is PackageHttp) {
pack.cleanup();
}
else if(pack is PackageBrowser) {
pack.deinitialize();
}
}
_runtime?.let {
@@ -387,6 +394,18 @@ class V8Plugin {
"HttpImp" -> PackageHttpImp(this, config)
"Utilities" -> PackageUtilities(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}");
};
}
@@ -105,6 +105,11 @@ class PackageBridge : V8Package {
)
}
@V8Function
fun hasPackage(str: String): Boolean {
return _plugin.getPackages().any { it.name == str };
}
@V8Function
fun dispose(value: V8Value) {
Logger.e(TAG, "Manual dispose: " + value.javaClass.name);
@@ -0,0 +1,239 @@
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.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.collection.emptyLongSet
import com.caoccao.javet.annotations.V8Function
import com.caoccao.javet.utils.JavetResourceUtils
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
import kotlinx.serialization.json.Json
class PackageBrowser: V8Package {
override val name: String get() = "Browser";
override val variableName: String = "browser";
private val _json = Json { };
@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");
}
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
return false;
}
}
_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() {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
_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) {
try {
browser.loadUrl(url);
} catch(ex: Throwable) {}
}
}
@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, {
_plugin.busy {
funcClone?.callVoid(null, arrayOf(it));
}
if (!_plugin.isStopped)
JavetResourceUtils.safeClose(funcClone);
});
}
}
StateApp.instance.scope.launch(Dispatchers.Main) {
try {
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", "Browser running failed: " + ex.message, ex);
}
}
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 ?: ""));
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
Logger.i("PackageBrowser", "Invoking V8 with result (${funcClone != null})");
try {
_plugin.busy {
if (value != null) {
val json = _json.decodeFromString<String>(value);
funcClone?.callVoid(null, arrayOf(json));
} else
funcClone?.callVoid(null, arrayOf((null as String?)));
}
if (!_plugin.isStopped)
JavetResourceUtils.safeClose(funcClone);
}
catch(ex: Throwable) {
Logger.e("PackageBrowser", "Browser Failed to callback: " + ex.message, ex);
}
}
}
})
}
catch(ex: Throwable) {
Logger.e("PackageBrowser", "Browser 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) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
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.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.LinearLayout
import android.widget.Spinner
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@@ -21,6 +22,7 @@ class BrowserFragment : MainFragment() {
override val isTab: Boolean = false;
override val hasBottomBar: Boolean get() = true;
private var _root: LinearLayout? = null;
private var _webview: WebView? = null;
private val _webviewWithoutHandling = object: WebViewClient() {
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 {
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 {
this.webViewClient = _webviewWithoutHandling;
this.settings.javaScriptEnabled = true;
@@ -43,7 +46,12 @@ class BrowserFragment : MainFragment() {
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
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?.loadUrl(parameter);
}
@@ -47,7 +47,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
private val _progressBar: ProgressBar;
private val _spinnerSortBy: Spinner;
private val _containerSortBy: LinearLayout;
private val _announcementView: AnnouncementView;
//private val _announcementView: AnnouncementView;
private val _tagsView: TagsView;
private val _textCentered: TextView;
private val _emptyPagerContainer: FrameLayout;
@@ -87,7 +87,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
_textCentered = findViewById(R.id.text_centered);
_emptyPagerContainer = findViewById(R.id.empty_pager_container);
_progressBar = findViewById(R.id.progress_bar);
_announcementView = findViewById(R.id.announcement_view)
//_announcementView = findViewById(R.id.announcement_view)
_progressBar.inactiveColor = Color.TRANSPARENT;
_swipeRefresh = findViewById(R.id.swipe_refresh);
@@ -192,7 +192,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
}
protected fun showAnnouncementView() {
_announcementView.visibility = View.VISIBLE
//_announcementView.visibility = View.VISIBLE
}
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
@@ -266,7 +266,7 @@ class LibraryFragment : MainFragment() {
});
if(this.allowMusic) {
val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount);
val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount)
adapterArtists.setData(artists);
if (artists.size == 0)
sectionArtists.setEmpty(
@@ -289,7 +289,7 @@ class LibraryFragment : MainFragment() {
}
if(this.allowMusic) {
val albums = StateLibrary.instance.getAlbums();
val albums = StateLibrary.instance.getAlbums()
adapterAlbums.setData(albums);
if (albums.size == 0)
sectionAlbums.setEmpty("No albums", "No albums were found on your device", -1);
@@ -309,13 +309,14 @@ class SourceDetailFragment : MainFragment() {
BigButton(c, context.getString(R.string.logout), context.getString(R.string.sign_out_of_the_platform), R.drawable.ic_logout) {
logoutSource();
},
if(!Settings.instance.other.shouldClearWebviewCookies())
BigButton(c, "Logout without Clear", "Logout but keep the browser cookies.\nThis allows for quick re-logging.", R.drawable.ic_logout) {
logoutSource(false);
}.apply {
this.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
};
}
} else null
)
);
@@ -518,6 +519,17 @@ class SourceDetailFragment : MainFragment() {
}
Logger.e(TAG, "Failed to set plugin authentication (loginSource, loginWarning)", e)
}
finally {
if(Settings.instance.other.shouldClearWebviewCookies()) {
try {
val cookieManager: CookieManager =
CookieManager.getInstance();
cookieManager.removeAllCookies(null);
} catch (ex: Throwable) {
Logger.e(TAG, "Failed to clear cookies", ex);
}
}
}
};
}, UIDialogs.ActionStyle.PRIMARY))
}
@@ -7,6 +7,9 @@ import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
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.UIDialogs
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.SuggestionsFragmentData
import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.states.StateAnnouncement
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() {
private var _buttonSearch: ImageButton? = 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?) {
if(currentMain is CreatorsFragment) {
_buttonSearch?.setImageResource(R.drawable.ic_person_search_300w);
} else {
_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() {
@@ -44,6 +83,19 @@ class GeneralTopBarFragment : TopFragment() {
val buttonSearch: ImageButton = view.findViewById(R.id.button_search);
_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 {
if(currentMain is NotificationOverlayView.Frag)
closeSegment();
else
navigate<NotificationOverlayView.Frag>();
}
buttonSearch.setOnClickListener {
if(currentMain is CreatorsFragment) {
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.CREATOR));
@@ -1,13 +1,22 @@
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.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
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.models.ImageVariable
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringHashSetStorage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@@ -110,6 +119,48 @@ class StateAnnouncement {
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> {
synchronized(_lock) {
if (category != null) {
@@ -122,7 +173,9 @@ class StateAnnouncement {
}
}
fun closeAnnouncement(id: String) {
fun closeAnnouncement(id: String?) {
if(id == null)
return;
val item: Announcement?;
synchronized(_lock) {
item = _announcementsStore.findItem { it.id == id };
@@ -164,6 +217,7 @@ class StateAnnouncement {
cancelAction?.invoke(item);
}
}
onAnnouncementChanged?.emit();
}
fun deleteAllAnnouncements() {
@@ -194,7 +248,9 @@ class StateAnnouncement {
onAnnouncementChanged.emit();
}
fun neverAnnouncement(id: String) {
fun neverAnnouncement(id: String?) {
if(id == null)
return;
synchronized(_lock) {
val item = _announcementsStore.findItem { it.id == id };
if (item != null && !_announcementsNever.contains(id))
@@ -208,19 +264,26 @@ class StateAnnouncement {
_announcementsNever.save();
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];
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];
if (action != null) {
action(item);
} else {
when (item.actionId) {
when (actionId) {
ACTION_NEVER -> neverAnnouncement(item.id);
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() {
registerAnnouncement(
"default-url-handler",
@@ -279,6 +420,8 @@ class StateAnnouncement {
const val ACTION_SOMETHING = "SOMETHING";
const val ACTION_CHANGELOG = "CHANGELOG";
const val ACTION_UPDATE_PLUGIN = "UPDATE_PLUGIN";
const val ACTION_NEVER = "NEVER";
private const val TAG = "StateAnnouncement";
}
@@ -294,7 +437,8 @@ open class Announcement(
val time: OffsetDateTime? = null,
val category: String? = null,
val actionName: String? = null,
val actionId: String? = null
val actionId: String? = null,
val actionData: String? = null
);
class SessionAnnouncement(
id: String,
@@ -306,7 +450,9 @@ class SessionAnnouncement(
actionName: String? = null,
actionId: String? = null,
val cancelName: String? = null,
val cancelActionId: String? = null
val cancelActionId: String? = null,
actionData: String? = null,
val icon: ImageVariable? = null
): Announcement(
id= id,
title = title,
@@ -315,13 +461,40 @@ class SessionAnnouncement(
time = time,
category = category,
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) {
DELETABLE(0), //Close button deletes announcement (generally for actions)
RECURRING(1), //Shows up till never is pressed (generally for patchnotes etc)
PERMANENT(2), //Shows up until deleted through other means (action)
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);
}
@@ -16,6 +16,7 @@ import android.net.Uri
import android.provider.DocumentsContract
import android.util.DisplayMetrics
import android.util.Log
import android.webkit.CookieManager
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
@@ -43,6 +44,7 @@ import com.futo.platformplayer.logging.AndroidLogConsumer
import com.futo.platformplayer.logging.FileLogConsumer
import com.futo.platformplayer.logging.LogLevel
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.receivers.AudioNoisyReceiver
import com.futo.platformplayer.services.DownloadService
import com.futo.platformplayer.stores.FragmentedStorage
@@ -447,6 +449,16 @@ class StateApp {
_cacheDirectory?.let { ApiMethods.initCache(it) };
}
if(Settings.instance.other.shouldClearWebviewCookies()) {
try {
val cookieManager: CookieManager =
CookieManager.getInstance();
cookieManager.removeAllCookies(null);
} catch (ex: Throwable) {
Logger.e(SourceDetailFragment.Companion.TAG, "Failed to clear cookies", ex);
}
}
Logger.i(TAG, "MainApp Starting: Initializing [ModerationsManager]");
ModerationsManager.initialize(context);
@@ -732,8 +744,10 @@ class StateApp {
));
for(update in updateAvailable)
if(StatePlatform.instance.isClientEnabled(update.first.id))
UIDialogs.showPluginUpdateDialog(context, update.first, update.second);
if(StatePlatform.instance.isClientEnabled(update.first.id)) {
//UIDialogs.showPluginUpdateDialog(context, update.first, update.second);
StateAnnouncement.instance.registerPluginUpdate(update.first, update.second);
}
}
}
}
@@ -116,7 +116,7 @@ class StatePlugins {
_updatesAvailableMap = updatesAvailableFor
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;
Logger.i(TAG, "Check for source updates '${c.name}'.");
@@ -113,7 +113,10 @@ class StateUpdate {
if (!dir.exists()) {
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 {
@@ -121,7 +124,10 @@ class StateUpdate {
if (!dir.exists()) {
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() {
@@ -6,12 +6,10 @@ import android.view.View
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.dp
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.Announcement
import com.futo.platformplayer.states.AnnouncementType
@@ -162,6 +160,10 @@ class AnnouncementView : LinearLayout {
_textClose.visibility = View.VISIBLE;
_textNever.visibility = View.VISIBLE;
}
AnnouncementType.ONGOING -> {
_textClose.visibility = View.GONE;
_textNever.visibility = View.GONE;
}
}
if (announcement.time != null) {
@@ -50,7 +50,7 @@ class FieldForm : LinearLayout {
}
}
fun updateSettingsVisibility(group: GroupField? = null) {
fun updateSettingsVisibility(group: GroupField? = null, allowEmptyGroups: Boolean = false) {
val settings = group?.getFields() ?: _fields;
val query = _editSearch.text.toString().lowercase();
@@ -58,7 +58,8 @@ class FieldForm : LinearLayout {
val isGroupMatch = query.isEmpty() || group?.searchContent?.lowercase()?.contains(query) == true;
for(field in settings) {
if(field is GroupField) {
updateSettingsVisibility(field);
if(!allowEmptyGroups)
updateSettingsVisibility(field);
} else if(field is View && field.descriptor != null) {
if(field.isAdvanced && !_showAdvancedSettings)
{
@@ -73,15 +74,21 @@ class FieldForm : LinearLayout {
}
}
}
else if(field is View) {
if(field.isAdvanced && !_showAdvancedSettings)
field.visibility = View.GONE;
else
field.visibility = VISIBLE;
}
}
if(group != null) {
group.visibility = if (groupVisible) View.VISIBLE else View.GONE;
}
}
fun setShowAdvancedSettings(show: Boolean) {
fun setShowAdvancedSettings(show: Boolean, allowEmptyGroups: Boolean = false) {
_showAdvancedSettings = show;
updateSettingsVisibility();
updateSettingsVisibility(null, allowEmptyGroups);
}
fun setSearchQuery(query: String) {
_editSearch.setText(query);
@@ -141,7 +148,9 @@ class FieldForm : LinearLayout {
}
fun fromPluginSettings(settings: List<SourcePluginConfig.Setting>, values: HashMap<String, String?>, groupTitle: String? = null, groupDescription: String? = null) {
_fieldsContainer.removeAllViews();
val newFields = getFieldsFromPluginSettings(context, settings, values);
val newFields = getFieldsFromPluginSettings(context, settings, values, {
setShowAdvancedSettings(it, true);
});
if (newFields.isEmpty()) {
return;
}
@@ -157,6 +166,7 @@ class FieldForm : LinearLayout {
_fieldsContainer.addView(v);
}
_fields = newFields.map { it.second };
updateSettingsVisibility(null, true);
} else {
for(field in newFields) {
finalizePluginSettingField(field.first, field.second, newFields);
@@ -164,6 +174,8 @@ class FieldForm : LinearLayout {
val group = GroupField(context, groupTitle, groupDescription)
.withFields(newFields.map { it.second });
_fieldsContainer.addView(group as View);
_fields = newFields.map { it.second };
updateSettingsVisibility(null, true);
}
}
private fun finalizePluginSettingField(setting: SourcePluginConfig.Setting, field: IField, others: List<Pair<SourcePluginConfig.Setting, IField>>) {
@@ -234,7 +246,7 @@ class FieldForm : LinearLayout {
private val _json = Json;
fun getFieldsFromPluginSettings(context: Context, settings: List<SourcePluginConfig.Setting>, values: HashMap<String, String?>): List<Pair<SourcePluginConfig.Setting, IField>> {
fun getFieldsFromPluginSettings(context: Context, settings: List<SourcePluginConfig.Setting>, values: HashMap<String, String?>, onAdvancedChanged: ((newVal: Boolean)->Unit)? = null): List<Pair<SourcePluginConfig.Setting, IField>> {
val fields = mutableListOf<Pair<SourcePluginConfig.Setting, IField>>()
for(setting in settings) {
@@ -243,6 +255,7 @@ class FieldForm : LinearLayout {
val field = when(setting.type.lowercase()) {
"header" -> {
val groupField = GroupField(context, setting.name, setting.description);
groupField.isAdvanced = (setting.isAdvanced ?: false);
groupField;
}
"boolean" -> {
@@ -252,6 +265,7 @@ class FieldForm : LinearLayout {
field.onChanged.subscribe { _, v, _ ->
values[setting.variableOrName] = _json.encodeToString (v == 1 || v == true);
}
field.isAdvanced = (setting.isAdvanced ?: false);
field;
}
"dropdown" -> {
@@ -261,6 +275,7 @@ class FieldForm : LinearLayout {
field.onChanged.subscribe { _, v, _ ->
values[setting.variableOrName] = v.toString();
}
field.isAdvanced = (setting.isAdvanced ?: false);
field;
}
else null;
@@ -272,6 +287,17 @@ class FieldForm : LinearLayout {
fields.add(Pair(setting, field));
}
}
if(onAdvancedChanged != null && settings.any { it.isAdvanced == true }) {
val setting = SourcePluginConfig.Setting("Show Advanced", "See advanced settings, which may be counter productive to change", "boolean", "false");
val field = ToggleField(context).withValue(setting.name, setting.description, false);
field.onChanged.subscribe { field, new, old ->
onAdvancedChanged?.invoke(new as Boolean);
}
fields.add(Pair(setting, field));
}
return fields;
}
@@ -0,0 +1,246 @@
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.NoResultsView
import com.futo.platformplayer.views.adapters.AnyAdapter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class NotificationOverlayView: ConstraintLayout {
lateinit var recycler: RecyclerView;
lateinit var emptyView: NoResultsView;
var adapterNotifications: AnyAdapterView<Announcement, ViewHolder>;
constructor(context: Context) : super(context) {
inflate(context, R.layout.overlay_notifications, this)
recycler = findViewById<RecyclerView>(R.id.container_notifications);
emptyView = findViewById<NoResultsView>(R.id.no_results);
adapterNotifications = recycler.asAny<Announcement, ViewHolder>(RecyclerView.VERTICAL, false, {
});
emptyView.setText("Nothing to see here", "You don't have any notifications", R.drawable.ic_notifications)
}
fun onShown(parameter: Any?) {
val announcements = StateAnnouncement.instance.getVisibleAnnouncements();
adapterNotifications.adapter.setData(announcements);
if(announcements.any())
emptyView.isVisible = false;
else
emptyView.isVisible = true;
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"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
<LinearLayout
android:id="@+id/root"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
@@ -11,4 +12,4 @@
android:layout_width="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:orientation="vertical">
<!--
<com.futo.platformplayer.views.announcements.AnnouncementView
android:id="@+id/announcement_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone" />
android:visibility="gone" /> -->
<LinearLayout
android:id="@+id/container_sort_by"
@@ -46,6 +46,42 @@
android:scaleType="fitCenter"
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-->
<ImageButton
android:id="@+id/button_search"
@@ -30,11 +30,12 @@
android:layout_height="wrap_content"
android:orientation="vertical">
<!--
<com.futo.platformplayer.views.announcements.AnnouncementView
android:id="@+id/announcement_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone" />
android:visibility="gone" /> -->
<com.futo.platformplayer.views.others.RadioGroupView
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,33 @@
<?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" />
<com.futo.platformplayer.views.NoResultsView
android:id="@+id/no_results"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<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_button_clear_search">Clear 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_button_back">Back button</string>
<string name="cd_app_icon">App icon</string>