mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 04:52:39 +02:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7fc549afb | |||
| b345ba5ca3 | |||
| c65cee86b1 | |||
| cf3fc61f6a | |||
| d03019f0b7 | |||
| f1ce0078fd | |||
| cd90497a59 |
+8
-1
@@ -1,3 +1,6 @@
|
||||
variables:
|
||||
GIT_SUBMODULE_STRATEGY: recursive
|
||||
|
||||
buildAndDeployApkUnstable:
|
||||
stage: build
|
||||
script:
|
||||
@@ -31,7 +34,7 @@ buildAndDeployPlaystore:
|
||||
stage: deploy
|
||||
script:
|
||||
- sh build-playstore.sh
|
||||
- bash tools/venv_playstore.sh
|
||||
- bash venv-playstore.sh
|
||||
- . .venv-playstore/bin/activate
|
||||
- python publish_playstore.py --sa /root/grayjay.json --package com.futo.platformplayer.playstore --aab ./app/build/outputs/bundle/playstoreRelease/app-playstore-release.aab --track production --status completed
|
||||
only:
|
||||
@@ -53,5 +56,9 @@ updateFdroidRepo:
|
||||
needs:
|
||||
- job: buildAndDeployApkStable
|
||||
artifacts: true
|
||||
before_script:
|
||||
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
- touch ~/.ssh/known_hosts && chmod 644 ~/.ssh/known_hosts
|
||||
- ssh-keygen -F gitlab.futo.org >/dev/null 2>&1 || ssh-keyscan -t rsa,ecdsa,ed25519 gitlab.futo.org >> ~/.ssh/known_hosts
|
||||
script:
|
||||
- python3 update_fdroid_index.py
|
||||
|
||||
@@ -32,6 +32,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
|
||||
import com.futo.platformplayer.downloads.VideoLocal
|
||||
@@ -382,7 +383,8 @@ class UISlideOverlays {
|
||||
val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
val masterPlaylistResponse = ManagedHttpClient().get(sourceUrl)
|
||||
val modifier = if (source is JSSource && source.hasRequestModifier) source.getRequestModifier() else null
|
||||
val masterPlaylistResponse = ManagedHttpClient().get(sourceUrl, HashMap(), modifier)
|
||||
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
|
||||
|
||||
val masterPlaylistContent = masterPlaylistResponse.body?.string()
|
||||
@@ -515,7 +517,7 @@ class UISlideOverlays {
|
||||
|
||||
slideUpMenuOverlay.onOK.subscribe {
|
||||
//TODO: Fix SubtitleRawSource issue
|
||||
StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null);
|
||||
StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null, videoModifier = modifier, audioModifier = modifier);
|
||||
slideUpMenuOverlay.hide()
|
||||
}
|
||||
|
||||
@@ -526,11 +528,11 @@ class UISlideOverlays {
|
||||
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (source is IHLSManifestSource) {
|
||||
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedPlaylistUrl), null, null)
|
||||
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedPlaylistUrl), null, null, videoModifier = modifier)
|
||||
UIDialogs.toast(container.context, "Variant video HLS playlist download started")
|
||||
slideUpMenuOverlay.hide()
|
||||
} else if (source is IHLSManifestAudioSource) {
|
||||
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, resolvedPlaylistUrl), null)
|
||||
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, resolvedPlaylistUrl), null, audioModifier = modifier)
|
||||
UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
|
||||
slideUpMenuOverlay.hide()
|
||||
} else {
|
||||
|
||||
@@ -5,50 +5,14 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.dialogs.AutoUpdateDialog
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import java.io.File
|
||||
|
||||
class UpdateActionReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
UpdateNotificationManager.ACTION_UPDATE_YES -> handleUpdateYes(context, intent)
|
||||
UpdateNotificationManager.ACTION_UPDATE_NO -> handleUpdateNo(context)
|
||||
UpdateNotificationManager.ACTION_UPDATE_NEVER -> handleUpdateNever(context)
|
||||
UpdateNotificationManager.ACTION_DOWNLOAD_CANCEL -> handleDownloadCancel(context, intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUpdateYes(context: Context, intent: Intent) {
|
||||
AutoUpdateDialog.currentDialog?.dismiss()
|
||||
|
||||
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
||||
if (version == 0) {
|
||||
return
|
||||
}
|
||||
|
||||
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
|
||||
|
||||
val serviceIntent = Intent(context, UpdateDownloadService::class.java).apply {
|
||||
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
|
||||
}
|
||||
ContextCompat.startForegroundService(context, serviceIntent)
|
||||
}
|
||||
|
||||
private fun handleUpdateNo(context: Context) {
|
||||
AutoUpdateDialog.currentDialog?.dismiss()
|
||||
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
|
||||
}
|
||||
|
||||
private fun handleUpdateNever(context: Context) {
|
||||
AutoUpdateDialog.currentDialog?.dismiss()
|
||||
Settings.instance.autoUpdate.check = 1
|
||||
Settings.instance.save()
|
||||
|
||||
UpdateNotificationManager.cancelAll(context)
|
||||
}
|
||||
|
||||
private fun handleDownloadCancel(context: Context, intent: Intent) {
|
||||
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateUpdate
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -35,18 +36,16 @@ class UpdateCheckWorker(appContext: Context, workerParams: WorkerParameters) : C
|
||||
return@withContext Result.success()
|
||||
}
|
||||
|
||||
UpdateNotificationManager.showUpdateAvailableNotification(applicationContext, latestVersion)
|
||||
StateUpdate.Companion.instance.setUiAvailable(latestVersion)
|
||||
|
||||
if (StateApp.instance.isMainActive) {
|
||||
withContext(Dispatchers.Main) {
|
||||
StateApp.withContext { ctx ->
|
||||
try {
|
||||
UIDialogs.showUpdateAvailableDialog(ctx, latestVersion, false)
|
||||
val serviceIntent = Intent(applicationContext, UpdateDownloadService::class.java).apply {
|
||||
putExtra(UpdateDownloadService.EXTRA_VERSION, latestVersion)
|
||||
}
|
||||
ContextCompat.startForegroundService(applicationContext, serviceIntent)
|
||||
} catch (t: Throwable) {
|
||||
Logger.w(TAG, "Failed to show in-app update dialog from worker", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
Logger.w(TAG, "Failed to start UpdateDownloadService", t)
|
||||
StateUpdate.Companion.instance.setUiFailed(latestVersion, t.message)
|
||||
}
|
||||
|
||||
Result.success()
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.app.Dialog
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.os.SystemClock
|
||||
import com.futo.platformplayer.UIDialogs.ActionStyle
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
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.*
|
||||
import java.io.File
|
||||
@@ -30,8 +28,6 @@ class UpdateDownloadService : Service() {
|
||||
private const val INITIAL_BACKOFF_MS = 5_000L
|
||||
private const val BUFFER_SIZE = 8 * 1024
|
||||
private const val MIN_PROGRESS_UPDATE_INTERVAL_MS = 500L
|
||||
|
||||
var updateDownloadedDialog: Dialog? = null
|
||||
}
|
||||
|
||||
private val job = SupervisorJob()
|
||||
@@ -56,6 +52,7 @@ class UpdateDownloadService : Service() {
|
||||
if (intent.getBooleanExtra(EXTRA_CANCEL, false)) {
|
||||
cancelRequested = true
|
||||
Logger.i(TAG, "Download cancel requested")
|
||||
StateUpdate.Companion.instance.clearUi()
|
||||
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
@@ -75,6 +72,10 @@ class UpdateDownloadService : Service() {
|
||||
isDownloading = true
|
||||
cancelRequested = false
|
||||
|
||||
StateUpdate.Companion.instance.setUiDownloading(version, 0, indeterminate = true)
|
||||
|
||||
NotificationManagerCompat.from(this).cancel(UpdateNotificationManager.NOTIF_ID_READY)
|
||||
|
||||
val notification = UpdateNotificationManager.buildDownloadProgressNotification(this, version, 0, true)
|
||||
startForeground(UpdateNotificationManager.NOTIF_ID_DOWNLOADING, notification)
|
||||
|
||||
@@ -97,6 +98,7 @@ class UpdateDownloadService : Service() {
|
||||
if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) {
|
||||
lastProgressUpdateElapsedMs = now
|
||||
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate);
|
||||
StateUpdate.Companion.instance.setUiDownloading(version, progress, indeterminate)
|
||||
|
||||
if(onProgress != null)
|
||||
onProgress.invoke(progress);
|
||||
@@ -132,7 +134,7 @@ class UpdateDownloadService : Service() {
|
||||
}
|
||||
|
||||
try {
|
||||
performDownload(StateUpdate.APK_URL, partialFile, version, {
|
||||
performDownload(StateUpdate.getApkUrl(version), partialFile, version, {
|
||||
try {
|
||||
if (announcement != null)
|
||||
announcement?.setProgress(it);
|
||||
@@ -159,6 +161,7 @@ class UpdateDownloadService : Service() {
|
||||
if (attempt == MAX_RETRIES - 1) {
|
||||
Logger.e(TAG, "Download failed after ${attempt + 1} attempts", t)
|
||||
UpdateNotificationManager.showDownloadFailedNotification(this, version, t)
|
||||
StateUpdate.Companion.instance.setUiFailed(version, t.message)
|
||||
break
|
||||
} else {
|
||||
Logger.w(TAG, "Download attempt ${attempt + 1} failed, retrying in ${backoffMs / 1000}s", t)
|
||||
@@ -264,39 +267,16 @@ class UpdateDownloadService : Service() {
|
||||
private fun onDownloadComplete(version: Int, apkFile: File) {
|
||||
Logger.i(TAG, "Download complete for version=$version, file=${apkFile.absolutePath}")
|
||||
UpdateNotificationManager.showDownloadCompleteNotification(this, version, apkFile)
|
||||
|
||||
if (StateApp.instance.isMainActive) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
StateApp.withContext { ctx ->
|
||||
try {
|
||||
updateDownloadedDialog = UIDialogs.showDialog(ctx, R.drawable.foreground,
|
||||
"Update downloaded",
|
||||
"Would you like to install it now?", null, 0,
|
||||
UIDialogs.Action("Not now", {
|
||||
updateDownloadedDialog = null
|
||||
}, ActionStyle.NONE, true),
|
||||
UIDialogs.Action("Install", {
|
||||
UpdateNotificationManager.cancelAll(ctx)
|
||||
UpdateInstaller.startInstall(ctx, version, apkFile)
|
||||
}, ActionStyle.PRIMARY, true));
|
||||
StateUpdate.Companion.instance.setUiReady(version, apkFile)
|
||||
|
||||
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", {
|
||||
val ctx = applicationContext
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
Logger.w(TAG, "Failed to register install announcement", ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import java.io.InputStream
|
||||
import androidx.core.net.toUri
|
||||
import com.futo.platformplayer.dialogs.AutoUpdateDialog
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateUpdate
|
||||
|
||||
object UpdateInstaller {
|
||||
private const val TAG = "UpdateInstaller"
|
||||
@@ -61,6 +62,17 @@ object UpdateInstaller {
|
||||
var inputStream: InputStream? = null
|
||||
var session: PackageInstaller.Session? = null
|
||||
try {
|
||||
val dataLength = apkFile.length()
|
||||
val usable = try { context.filesDir.usableSpace } catch (_: Throwable) { -1L }
|
||||
if (usable in 0 until dataLength) {
|
||||
val msg = "Not enough storage to install update. Need ${dataLength / 1_048_576L}MB, have ${usable / 1_048_576L}MB free."
|
||||
Logger.w(TAG, msg)
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(context, msg)
|
||||
}
|
||||
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, msg)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
|
||||
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||
@@ -68,7 +80,6 @@ object UpdateInstaller {
|
||||
session = packageInstaller.openSession(sessionId)
|
||||
|
||||
inputStream = apkFile.inputStream()
|
||||
val dataLength = apkFile.length()
|
||||
|
||||
session.openWrite("package", 0, dataLength).use { sessionStream ->
|
||||
inputStream.copyToOutputStream(dataLength, sessionStream) { _ -> }
|
||||
@@ -91,11 +102,18 @@ object UpdateInstaller {
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Exception while installing update", e)
|
||||
session?.abandon()
|
||||
|
||||
val raw = e.message ?: ""
|
||||
val friendly = if (raw.contains("Failed to allocate") || raw.contains("allocatable") || raw.contains("ENOSPC", ignoreCase = true)) {
|
||||
"Not enough storage to install update. Free up some space and try again."
|
||||
} else {
|
||||
"Failed to install update: $raw"
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(context, "Failed to install update: ${e.message}")
|
||||
UIDialogs.toast(context, friendly)
|
||||
}
|
||||
|
||||
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, e.message)
|
||||
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, friendly)
|
||||
} finally {
|
||||
session?.close()
|
||||
inputStream?.close()
|
||||
@@ -110,10 +128,12 @@ object UpdateInstaller {
|
||||
if (result.isNullOrEmpty()) {
|
||||
Logger.i(TAG, "Update install finished successfully")
|
||||
UpdateNotificationManager.showInstallSucceededNotification(context, version)
|
||||
StateUpdate.instance.clearUi()
|
||||
} else {
|
||||
Logger.w(TAG, "Update install failed: $result")
|
||||
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, result)
|
||||
UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n$result")
|
||||
StateUpdate.instance.setUiReady(version, apkFile)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to handle install result", e)
|
||||
|
||||
@@ -22,9 +22,6 @@ object UpdateNotificationManager {
|
||||
private const val CHANNEL_NAME = "App updates"
|
||||
private const val CHANNEL_DESCRIPTION = "Notifications about new app versions"
|
||||
|
||||
const val ACTION_UPDATE_YES = "com.futo.platformplayer.UPDATE_YES"
|
||||
const val ACTION_UPDATE_NO = "com.futo.platformplayer.UPDATE_NO"
|
||||
const val ACTION_UPDATE_NEVER = "com.futo.platformplayer.UPDATE_NEVER"
|
||||
const val ACTION_DOWNLOAD_CANCEL = "com.futo.platformplayer.UPDATE_CANCEL"
|
||||
const val ACTION_INSTALL_NOW = "com.futo.platformplayer.UPDATE_INSTALL"
|
||||
private const val REQUEST_CODE_INSTALL = 1001
|
||||
@@ -32,7 +29,6 @@ object UpdateNotificationManager {
|
||||
const val EXTRA_VERSION = "version"
|
||||
const val EXTRA_APK_PATH = "apk_path"
|
||||
|
||||
const val NOTIF_ID_AVAILABLE = 2001
|
||||
const val NOTIF_ID_DOWNLOADING = 2002
|
||||
const val NOTIF_ID_READY = 2003
|
||||
const val NOTIF_ID_INSTALL_FAILED = 2004
|
||||
@@ -84,43 +80,6 @@ object UpdateNotificationManager {
|
||||
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_SUCCEEDED, builder.build())
|
||||
}
|
||||
|
||||
fun showUpdateAvailableNotification(context: Context, version: Int) {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
|
||||
ensureChannel(context)
|
||||
|
||||
val yesIntent = Intent(context, UpdateActionReceiver::class.java).apply {
|
||||
action = ACTION_UPDATE_YES
|
||||
putExtra(EXTRA_VERSION, version)
|
||||
}
|
||||
val yesPendingIntent = getBroadcast(context, 0, yesIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
|
||||
val noIntent = Intent(context, UpdateActionReceiver::class.java).apply {
|
||||
action = ACTION_UPDATE_NO
|
||||
putExtra(EXTRA_VERSION, version)
|
||||
}
|
||||
val noPendingIntent = getBroadcast(context, 1, noIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
|
||||
val neverIntent = Intent(context, UpdateActionReceiver::class.java).apply {
|
||||
action = ACTION_UPDATE_NEVER
|
||||
putExtra(EXTRA_VERSION, version)
|
||||
}
|
||||
val neverPendingIntent = getBroadcast(context, 2, neverIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.foreground)
|
||||
.setContentTitle("Update available")
|
||||
.setContentText("A new version ($version) is available.")
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(yesPendingIntent)
|
||||
.setSilent(true)
|
||||
.addAction(0, "Never", neverPendingIntent)
|
||||
.addAction(0, "Not now", noPendingIntent)
|
||||
.addAction(0, "Download", yesPendingIntent)
|
||||
|
||||
NotificationManagerCompat.from(context).notify(NOTIF_ID_AVAILABLE, builder.build())
|
||||
}
|
||||
|
||||
fun buildDownloadProgressNotification(context: Context, version: Int, progress: Int, indeterminate: Boolean): Notification {
|
||||
ensureChannel(context)
|
||||
|
||||
@@ -223,11 +182,9 @@ object UpdateNotificationManager {
|
||||
}
|
||||
|
||||
fun cancelAll(context: Context) {
|
||||
NotificationManagerCompat.from(context).cancel(NOTIF_ID_AVAILABLE)
|
||||
NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING)
|
||||
NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY)
|
||||
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_FAILED)
|
||||
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_SUCCEEDED)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import java.time.Duration
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
open class ManagedHttpClient {
|
||||
@@ -89,10 +90,16 @@ open class ManagedHttpClient {
|
||||
return clonedClient;
|
||||
}
|
||||
|
||||
fun tryHead(url: String): Map<String, String>? {
|
||||
private fun applyModifier(url: String, headers: MutableMap<String, String>, modifier: IRequestModifier?): Pair<String, MutableMap<String, String>> {
|
||||
if (modifier == null) return Pair(url, headers)
|
||||
val modified = modifier.modifyRequest(url, headers)
|
||||
return Pair(modified.url ?: url, modified.headers.toMutableMap())
|
||||
}
|
||||
|
||||
fun tryHead(url: String, modifier: IRequestModifier? = null): Map<String, String>? {
|
||||
ensureNotMainThread()
|
||||
try {
|
||||
val result = head(url);
|
||||
val result = head(url, HashMap(), modifier);
|
||||
if(result.isOk)
|
||||
return result.getHeadersFlat();
|
||||
else
|
||||
@@ -141,12 +148,14 @@ open class ManagedHttpClient {
|
||||
return Socket(websocket);
|
||||
}
|
||||
|
||||
fun get(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
|
||||
return execute(Request(url, "GET", null, headers));
|
||||
fun get(url : String, headers : MutableMap<String, String> = HashMap<String, String>(), modifier: IRequestModifier? = null) : Response {
|
||||
val (finalUrl, finalHeaders) = applyModifier(url, headers, modifier)
|
||||
return execute(Request(finalUrl, "GET", null, finalHeaders));
|
||||
}
|
||||
|
||||
fun head(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
|
||||
return execute(Request(url, "HEAD", null, headers));
|
||||
fun head(url : String, headers : MutableMap<String, String> = HashMap<String, String>(), modifier: IRequestModifier? = null) : Response {
|
||||
val (finalUrl, finalHeaders) = applyModifier(url, headers, modifier)
|
||||
return execute(Request(finalUrl, "HEAD", null, finalHeaders));
|
||||
}
|
||||
|
||||
fun post(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ class SourcePluginDescriptor {
|
||||
@FormField(R.string.check_for_updates_setting, FieldForm.TOGGLE, R.string.check_for_updates_setting_description, -1)
|
||||
var checkForUpdates: Boolean = true;
|
||||
@FormField(R.string.automatic_update_setting, FieldForm.TOGGLE, R.string.automatic_update_setting_description, 0)
|
||||
var automaticUpdate: Boolean = false;
|
||||
var automaticUpdate: Boolean = true;
|
||||
|
||||
@FormField(R.string.visibility, "group", R.string.enable_where_this_plugins_content_are_visible, 2)
|
||||
var tabEnabled = TabEnabled();
|
||||
|
||||
@@ -920,6 +920,7 @@ class StateCasting {
|
||||
if (videoSource != null) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
|
||||
.withIRequestModifier((videoSource as? JSSource)?.getRequestModifier())
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
@@ -927,6 +928,7 @@ class StateCasting {
|
||||
if (audioSource != null) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
|
||||
.withIRequestModifier((audioSource as? JSSource)?.getRequestModifier())
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
@@ -968,8 +970,7 @@ class StateCasting {
|
||||
val headers = masterContext.headers.clone()
|
||||
headers["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||
|
||||
val req = requestModifier?.modifyRequest(sourceUrl, mapOf())
|
||||
val masterPlaylistResponse = _client.get(req?.url ?: sourceUrl, (req?.headers ?: mapOf()).toMutableMap())
|
||||
val masterPlaylistResponse = _client.get(sourceUrl, mutableMapOf(), requestModifier)
|
||||
|
||||
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
|
||||
|
||||
@@ -1022,7 +1023,7 @@ class StateCasting {
|
||||
val vpHeaders = vpContext.headers.clone()
|
||||
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||
|
||||
val response = _client.get(variantPlaylistRef.url)
|
||||
val response = _client.get(variantPlaylistRef.url, mutableMapOf(), requestModifier)
|
||||
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
|
||||
|
||||
val vpContent = response.body?.string()
|
||||
@@ -1059,7 +1060,7 @@ class StateCasting {
|
||||
val vpHeaders = vpContext.headers.clone()
|
||||
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||
|
||||
val response = _client.get(mediaRendition.uri)
|
||||
val response = _client.get(mediaRendition.uri, mutableMapOf(), requestModifier)
|
||||
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
|
||||
|
||||
val vpContent = response.body?.string()
|
||||
@@ -1190,6 +1191,7 @@ class StateCasting {
|
||||
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
|
||||
.withIRequestModifier((audioSource as? JSSource)?.getRequestModifier())
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castHlsIndirectVariant");
|
||||
@@ -1267,6 +1269,7 @@ class StateCasting {
|
||||
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
|
||||
.withIRequestModifier((videoSource as? JSSource)?.getRequestModifier())
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castHlsIndirectVariant");
|
||||
@@ -1350,6 +1353,7 @@ class StateCasting {
|
||||
if (videoSource != null) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
|
||||
.withIRequestModifier((videoSource as? JSSource)?.getRequestModifier())
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
@@ -1357,6 +1361,7 @@ class StateCasting {
|
||||
if (audioSource != null) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
|
||||
.withIRequestModifier((audioSource as? JSSource)?.getRequestModifier())
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
|
||||
@@ -71,16 +71,14 @@ class Event0() : EventBase<(()->Unit), (()->Boolean)>() {
|
||||
fun emit() : Boolean {
|
||||
var handled = false;
|
||||
|
||||
synchronized(_conditionalListeners) {
|
||||
for (conditional in _conditionalListeners)
|
||||
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
|
||||
for (conditional in condSnapshot)
|
||||
handled = handled || conditional.handler.invoke();
|
||||
}
|
||||
|
||||
synchronized(_listeners) {
|
||||
handled = handled || _listeners.isNotEmpty();
|
||||
for (handler in _listeners)
|
||||
val snapshot = synchronized(_listeners) { _listeners.toList() };
|
||||
handled = handled || snapshot.isNotEmpty();
|
||||
for (handler in snapshot)
|
||||
handler.handler.invoke();
|
||||
}
|
||||
|
||||
return handled;
|
||||
}
|
||||
@@ -88,16 +86,15 @@ class Event0() : EventBase<(()->Unit), (()->Boolean)>() {
|
||||
class Event1<T1>() : EventBase<((T1)->Unit), ((T1)->Boolean)>() {
|
||||
fun emit(value : T1): Boolean {
|
||||
var handled = false;
|
||||
synchronized(_conditionalListeners) {
|
||||
for (conditional in _conditionalListeners)
|
||||
handled = handled || conditional.handler.invoke(value);
|
||||
}
|
||||
|
||||
synchronized(_listeners) {
|
||||
handled = handled || _listeners.isNotEmpty();
|
||||
for (handler in _listeners)
|
||||
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
|
||||
for (conditional in condSnapshot)
|
||||
handled = handled || conditional.handler.invoke(value);
|
||||
|
||||
val snapshot = synchronized(_listeners) { _listeners.toList() };
|
||||
handled = handled || snapshot.isNotEmpty();
|
||||
for (handler in snapshot)
|
||||
handler.handler.invoke(value);
|
||||
}
|
||||
|
||||
return handled;
|
||||
}
|
||||
@@ -106,16 +103,14 @@ class Event2<T1, T2>() : EventBase<((T1, T2)->Unit), ((T1, T2)->Boolean)>() {
|
||||
fun emit(value1 : T1, value2 : T2): Boolean {
|
||||
var handled = false;
|
||||
|
||||
synchronized(_conditionalListeners) {
|
||||
for (conditional in _conditionalListeners)
|
||||
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
|
||||
for (conditional in condSnapshot)
|
||||
handled = handled || conditional.handler.invoke(value1, value2);
|
||||
}
|
||||
|
||||
synchronized(_listeners) {
|
||||
handled = handled || _listeners.isNotEmpty();
|
||||
for (handler in _listeners)
|
||||
val snapshot = synchronized(_listeners) { _listeners.toList() };
|
||||
handled = handled || snapshot.isNotEmpty();
|
||||
for (handler in snapshot)
|
||||
handler.handler.invoke(value1, value2);
|
||||
}
|
||||
|
||||
return handled;
|
||||
}
|
||||
@@ -125,16 +120,14 @@ class Event3<T1, T2, T3>() : EventBase<((T1, T2, T3)->Unit), ((T1, T2, T3)->Bool
|
||||
fun emit(value1 : T1, value2 : T2, value3 : T3): Boolean {
|
||||
var handled = false;
|
||||
|
||||
synchronized(_conditionalListeners) {
|
||||
for (conditional in _conditionalListeners)
|
||||
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
|
||||
for (conditional in condSnapshot)
|
||||
handled = handled || conditional.handler.invoke(value1, value2, value3);
|
||||
}
|
||||
|
||||
synchronized(_listeners) {
|
||||
handled = handled || _listeners.isNotEmpty();
|
||||
for (handler in _listeners)
|
||||
val snapshot = synchronized(_listeners) { _listeners.toList() };
|
||||
handled = handled || snapshot.isNotEmpty();
|
||||
for (handler in snapshot)
|
||||
handler.handler.invoke(value1, value2, value3);
|
||||
}
|
||||
|
||||
return handled;
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||
var inputStream: InputStream? = null;
|
||||
try {
|
||||
val client = ManagedHttpClient();
|
||||
val response = client.get(StateUpdate.APK_URL);
|
||||
val response = client.get(StateUpdate.getApkUrl(_maxVersion));
|
||||
if (response.isOk && response.body != null) {
|
||||
inputStream = response.body.byteStream();
|
||||
val dataLength = response.body.contentLength();
|
||||
|
||||
@@ -156,6 +156,18 @@ class VideoDownload {
|
||||
var hasVideoRequestModifier: Boolean = false;
|
||||
var hasAudioRequestModifier: Boolean = false;
|
||||
|
||||
// Transient: IRequestModifier is a runtime object from the JS plugin engine and cannot be
|
||||
// serialized. After deserialization these are null - DownloadService must re-prepare to
|
||||
// recapture them from the live plugin source (see needsReprepareForAuth).
|
||||
@kotlinx.serialization.Transient
|
||||
private var preparedVideoRequestModifier: IRequestModifier? = null;
|
||||
@kotlinx.serialization.Transient
|
||||
private var preparedAudioRequestModifier: IRequestModifier? = null;
|
||||
|
||||
val needsReprepareForAuth: Boolean get() =
|
||||
(hasVideoRequestModifier && preparedVideoRequestModifier == null && videoSourceLive == null) ||
|
||||
(hasAudioRequestModifier && preparedAudioRequestModifier == null && audioSourceLive == null);
|
||||
|
||||
var progress: Double = 0.0;
|
||||
var isCancelled = false;
|
||||
|
||||
@@ -211,7 +223,7 @@ class VideoDownload {
|
||||
this.requireAudioSource = targetBitrate != null; //TODO: May not be a valid check.. can only be determined after live fetch?
|
||||
this.requiredCheck = optionalSources;
|
||||
}
|
||||
constructor(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?) {
|
||||
constructor(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?, videoModifier: IRequestModifier? = null, audioModifier: IRequestModifier? = null) {
|
||||
this.video = SerializedPlatformVideo.fromVideo(video);
|
||||
this.videoDetails = SerializedPlatformVideoDetails.fromVideo(video, if (subtitleSource != null) listOf(subtitleSource) else listOf());
|
||||
this.videoSource = if(videoSource is IVideoUrlSource) VideoUrlSource.fromUrlSource(videoSource) else null;
|
||||
@@ -220,12 +232,22 @@ class VideoDownload {
|
||||
this.audioSourceLive = if(audioSource is JSSource) audioSource else null;
|
||||
this.subtitleSource = subtitleSource;
|
||||
this.prepareTime = OffsetDateTime.now();
|
||||
this.preparedVideoRequestModifier = videoModifier ?: (if (videoSource is JSSource && videoSource.hasRequestModifier) videoSource.getRequestModifier() else null);
|
||||
this.preparedAudioRequestModifier = audioModifier ?: (if (audioSource is JSSource && audioSource.hasRequestModifier) audioSource.getRequestModifier() else null);
|
||||
this.hasVideoRequestExecutor = videoSource is JSSource && videoSource.hasRequestExecutor;
|
||||
this.hasAudioRequestExecutor = audioSource is JSSource && audioSource.hasRequestExecutor;
|
||||
this.hasVideoRequestModifier = videoSource is JSSource && videoSource.hasRequestModifier;
|
||||
this.hasAudioRequestModifier = audioSource is JSSource && audioSource.hasRequestModifier;
|
||||
this.requiresLiveVideoSource = this.hasVideoRequestModifier || this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
|
||||
this.requiresLiveAudioSource = this.hasAudioRequestModifier || this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
|
||||
// Set modifier flags from either the source or an explicitly provided modifier
|
||||
// (e.g. from the HLS picker, where the source is an HLSVariant, not JSSource).
|
||||
// These flags are serialized and used by needsReprepareForAuth after restore.
|
||||
this.hasVideoRequestModifier = preparedVideoRequestModifier != null;
|
||||
this.hasAudioRequestModifier = preparedAudioRequestModifier != null;
|
||||
// requiresLiveVideoSource means a live JSSource is needed at download time (for executors
|
||||
// or DASH generation). Modifiers alone don't require a live source - they're already
|
||||
// captured in preparedVideoRequestModifier and recaptured via needsReprepareForAuth.
|
||||
val sourceHasVideoModifier = videoSource is JSSource && videoSource.hasRequestModifier;
|
||||
val sourceHasAudioModifier = audioSource is JSSource && audioSource.hasRequestModifier;
|
||||
this.requiresLiveVideoSource = sourceHasVideoModifier || this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
|
||||
this.requiresLiveAudioSource = sourceHasAudioModifier || this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
|
||||
this.targetVideoName = videoSource?.name;
|
||||
this.targetAudioName = audioSource?.name;
|
||||
this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null;
|
||||
@@ -321,8 +343,10 @@ class VideoDownload {
|
||||
val videoSources = arrayListOf<IVideoSource>()
|
||||
for (source in original.video.videoSources) {
|
||||
if (source is IHLSManifestSource) {
|
||||
val sourceModifier = if (source is JSSource && source.hasRequestModifier) source.getRequestModifier() else null
|
||||
if (sourceModifier != null) preparedVideoRequestModifier = sourceModifier
|
||||
try {
|
||||
val playlistResponse = client.get(source.url)
|
||||
val playlistResponse = client.get(source.url, HashMap(), sourceModifier)
|
||||
if (playlistResponse.isOk) {
|
||||
val resolvedPlaylistUrl = playlistResponse.url
|
||||
val playlistContent = playlistResponse.body?.string()
|
||||
@@ -349,6 +373,8 @@ class VideoDownload {
|
||||
if(vsource is JSSource) {
|
||||
this.hasVideoRequestExecutor = this.hasVideoRequestExecutor || vsource.hasRequestExecutor;
|
||||
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (vsource is JSDashManifestRawSource && vsource.hasGenerate);
|
||||
if (vsource.hasRequestModifier && preparedVideoRequestModifier == null)
|
||||
preparedVideoRequestModifier = vsource.getRequestModifier()
|
||||
}
|
||||
|
||||
if(vsource == null) {
|
||||
@@ -370,8 +396,10 @@ class VideoDownload {
|
||||
if (video is VideoUnMuxedSourceDescriptor) {
|
||||
for (source in video.audioSources) {
|
||||
if (source is IHLSManifestAudioSource) {
|
||||
val sourceModifier = if (source is JSSource && source.hasRequestModifier) source.getRequestModifier() else null
|
||||
if (sourceModifier != null) preparedAudioRequestModifier = sourceModifier
|
||||
try {
|
||||
val playlistResponse = client.get(source.url)
|
||||
val playlistResponse = client.get(source.url, HashMap(), sourceModifier)
|
||||
if (playlistResponse.isOk) {
|
||||
val resolvedPlaylistUrl = playlistResponse.url
|
||||
val playlistContent = playlistResponse.body?.string()
|
||||
@@ -406,6 +434,8 @@ class VideoDownload {
|
||||
if(asource is JSSource) {
|
||||
this.hasAudioRequestExecutor = this.hasAudioRequestExecutor || asource.hasRequestExecutor;
|
||||
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (asource is JSDashManifestRawSource && asource.hasGenerate);
|
||||
if (asource.hasRequestModifier && preparedAudioRequestModifier == null)
|
||||
preparedAudioRequestModifier = asource.getRequestModifier()
|
||||
}
|
||||
|
||||
if(asource == null) {
|
||||
@@ -502,10 +532,16 @@ class VideoDownload {
|
||||
}
|
||||
}
|
||||
|
||||
val videoModifier = preparedVideoRequestModifier
|
||||
if(actualVideoSource is IVideoUrlSource)
|
||||
videoFileSize = when (videoSource!!.container) {
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
else -> downloadFileSource("Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
"application/vnd.apple.mpegurl" -> {
|
||||
// HLS segments are concatenated into an MP4 file during download,
|
||||
// so override the container for local playback/casting
|
||||
videoOverrideContainer = "video/mp4";
|
||||
downloadHlsSource(context, "Video", client, videoModifier, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
}
|
||||
else -> downloadFileSource("Video", client, videoModifier, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
}
|
||||
else if(actualVideoSource is JSDashManifestRawSource) {
|
||||
if(actualAudioSource == null)
|
||||
@@ -546,10 +582,16 @@ class VideoDownload {
|
||||
}
|
||||
}
|
||||
|
||||
val audioModifier = preparedAudioRequestModifier
|
||||
if(actualAudioSource is IAudioUrlSource)
|
||||
audioFileSize = when (audioSource!!.container) {
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
else -> downloadFileSource("Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
"application/vnd.apple.mpegurl" -> {
|
||||
// HLS segments are concatenated into an MP4 file during download,
|
||||
// so override the container for local playback/casting
|
||||
audioOverrideContainer = "audio/mp4";
|
||||
downloadHlsSource(context, "Audio", client, audioModifier, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
}
|
||||
else -> downloadFileSource("Audio", client, audioModifier, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
}
|
||||
else if(actualAudioSource is JSDashManifestRawAudioSource) {
|
||||
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback, 2);
|
||||
@@ -663,15 +705,11 @@ class VideoDownload {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, source: JSSource?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, modifier: IRequestModifier?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
if (targetFile.exists())
|
||||
targetFile.delete()
|
||||
|
||||
var downloadedTotalLength = 0L
|
||||
val modifier = if (source is JSSource && source.hasRequestModifier)
|
||||
source.getRequestModifier()
|
||||
else
|
||||
null
|
||||
|
||||
fun downloadBytes(url: String, rangeStart: Long? = null, rangeLength: Long? = null): ByteArray {
|
||||
val headers = mutableMapOf<String, String>()
|
||||
@@ -685,17 +723,13 @@ class VideoDownload {
|
||||
}
|
||||
}
|
||||
|
||||
val modified = modifier?.modifyRequest(url, headers)
|
||||
val finalUrl = modified?.url ?: url
|
||||
val finalHeaders = modified?.headers?.toMutableMap() ?: headers
|
||||
|
||||
val resp = client.get(finalUrl, finalHeaders)
|
||||
val resp = client.get(url, headers, modifier)
|
||||
if (!resp.isOk) {
|
||||
resp.body?.close()
|
||||
throw IllegalStateException("Failed to download HLS resource ($finalUrl): HTTP ${resp.code}")
|
||||
throw IllegalStateException("Failed to download HLS resource ($url): HTTP ${resp.code}")
|
||||
}
|
||||
|
||||
val body = resp.body ?: throw IllegalStateException("Failed to download HLS resource ($finalUrl): Empty body")
|
||||
val body = resp.body ?: throw IllegalStateException("Failed to download HLS resource ($url): Empty body")
|
||||
val bytes = body.bytes()
|
||||
body.close()
|
||||
return bytes
|
||||
@@ -710,12 +744,7 @@ class VideoDownload {
|
||||
|
||||
val segmentFiles = arrayListOf<File>()
|
||||
try {
|
||||
val playlistHeaders = mutableMapOf<String, String>()
|
||||
val modifiedPlaylistReq = modifier?.modifyRequest(hlsUrl, playlistHeaders)
|
||||
val playlistResp = client.get(
|
||||
modifiedPlaylistReq?.url ?: hlsUrl,
|
||||
modifiedPlaylistReq?.headers?.toMutableMap() ?: playlistHeaders
|
||||
)
|
||||
val playlistResp = client.get(hlsUrl, mutableMapOf(), modifier)
|
||||
|
||||
check(playlistResp.isOk) { "Failed to get variant playlist: ${playlistResp.code}" }
|
||||
|
||||
@@ -964,16 +993,7 @@ class VideoDownload {
|
||||
|
||||
Logger.i(TAG, "Downloading cue ${indexCounter}")
|
||||
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
|
||||
val modified = modifier?.modifyRequest(url, mapOf());
|
||||
|
||||
val data = if(executor != null)
|
||||
executor.executeRequest("GET", modified?.url ?: url, null, modified?.headers ?: mapOf());
|
||||
else {
|
||||
val resp = client.get(modified?.url ?: url, modified?.headers?.toMutableMap() ?: mutableMapOf());
|
||||
if(!resp.isOk)
|
||||
throw IllegalStateException("Dash request failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
|
||||
resp.body!!.bytes()
|
||||
}
|
||||
val data = executeOrGet(client, executor, modifier, url)
|
||||
fileStream.write(data, 0, data.size);
|
||||
speedTracker.addWork(data.size.toLong());
|
||||
written += data.size;
|
||||
@@ -993,16 +1013,7 @@ class VideoDownload {
|
||||
val t2 = cue2.groupValues[1];
|
||||
val d2 = cue2.groupValues[2];
|
||||
val url2 = foundTemplateUrl2!!.replace("\$Number\$", (index2).toString());
|
||||
val modified2 = modifier?.modifyRequest(url, mapOf());
|
||||
|
||||
val data = if(executor != null)
|
||||
executor.executeRequest("GET", modified2?.url ?: url2, null, modified2?.headers ?: mapOf());
|
||||
else {
|
||||
val resp = client.get(modified2?.url ?: url, modified2?.headers?.toMutableMap() ?: mutableMapOf());
|
||||
if(!resp.isOk)
|
||||
throw IllegalStateException("Dash request2 failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
|
||||
resp.body!!.bytes()
|
||||
}
|
||||
val data = executeOrGet(client, executor, modifier, url2)
|
||||
fileStream2.write(data, 0, data.size);
|
||||
speedTracker.addWork(data.size.toLong());
|
||||
written2 += data.size;
|
||||
@@ -1071,7 +1082,7 @@ class VideoDownload {
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadFileSource(name: String, client: ManagedHttpClient, source: JSSource?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
private fun downloadFileSource(name: String, client: ManagedHttpClient, modifier: IRequestModifier?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
|
||||
@@ -1080,13 +1091,8 @@ class VideoDownload {
|
||||
val sourceLength: Long?;
|
||||
val fileStream = FileOutputStream(targetFile);
|
||||
|
||||
val modifier = if (source is JSSource && source.hasRequestModifier)
|
||||
source.getRequestModifier();
|
||||
else
|
||||
null;
|
||||
|
||||
try {
|
||||
val head = client.tryHead(videoUrl);
|
||||
val head = client.tryHead(videoUrl, modifier);
|
||||
val relatedPlugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null };
|
||||
if(Settings.instance.downloads.byteRangeDownload && head?.containsKey("accept-ranges") == true && head.containsKey("content-length"))
|
||||
{
|
||||
@@ -1161,12 +1167,7 @@ class VideoDownload {
|
||||
|
||||
var lastSpeed: Long = 0;
|
||||
|
||||
val result = if (modifier != null) {
|
||||
val modified = modifier.modifyRequest(url, mapOf())
|
||||
client.get(modified.url!!, modified.headers.toMutableMap())
|
||||
} else {
|
||||
client.get(url)
|
||||
}
|
||||
val result = client.get(url, HashMap(), modifier)
|
||||
if (!result.isOk) {
|
||||
result.body?.close()
|
||||
throw IllegalStateException("Failed to download source. Web[${result.code}] Error");
|
||||
@@ -1379,13 +1380,12 @@ class VideoDownload {
|
||||
var lastException: Throwable? = null;
|
||||
|
||||
val headers = mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}"));
|
||||
val modified = modifier?.modifyRequest(url, headers);
|
||||
|
||||
while (retryCount <= 3) {
|
||||
try {
|
||||
val toRead = rangeEnd - rangeStart;
|
||||
|
||||
val req = client.get(modified?.url ?: url, modified?.headers?.toMutableMap() ?: headers);
|
||||
val req = client.get(url, headers.toMutableMap(), modifier);
|
||||
if (!req.isOk) {
|
||||
val bodyString = req.body?.string()
|
||||
req.body?.close()
|
||||
@@ -1519,6 +1519,18 @@ class VideoDownload {
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeOrGet(client: ManagedHttpClient, executor: JSRequestExecutor?, modifier: IRequestModifier?, url: String, headers: Map<String, String> = mapOf()): ByteArray {
|
||||
if (executor != null) {
|
||||
val modified = modifier?.modifyRequest(url, headers)
|
||||
return executor.executeRequest("GET", modified?.url ?: url, null, modified?.headers ?: headers)
|
||||
} else {
|
||||
val resp = client.get(url, headers.toMutableMap(), modifier)
|
||||
if (!resp.isOk)
|
||||
throw IllegalStateException("Request failed for ($url) with code: ${resp.code}")
|
||||
return resp.body!!.bytes()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "VideoDownload";
|
||||
const val GROUP_PLAYLIST = "Playlist";
|
||||
|
||||
@@ -238,6 +238,16 @@ class DownloadService : Service() {
|
||||
download.targetBitrate = download.audioSource!!.bitrate.toLong();
|
||||
download.audioSource = null;
|
||||
}
|
||||
// Force re-prepare if auth modifiers are needed but lost (e.g. after deserialization,
|
||||
// since IRequestModifier is transient and cannot survive serialization).
|
||||
// Must also clear sources so prepare() enters the source selection branches where
|
||||
// modifiers are recaptured from the live plugin JSSource.
|
||||
if(download.needsReprepareForAuth) {
|
||||
Logger.w(TAG, "Video Download [${download.name}] needs re-prepare for auth modifiers");
|
||||
download.videoDetails = null;
|
||||
download.videoSource = null;
|
||||
download.audioSource = null;
|
||||
}
|
||||
if(download.videoDetails == null || (!download.isVideoDownloadReady || !download.isAudioDownloadReady))
|
||||
download.changeState(VideoDownload.State.PREPARING);
|
||||
notifyDownload(download);
|
||||
|
||||
@@ -668,6 +668,9 @@ class StateApp {
|
||||
if (Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
|
||||
if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
|
||||
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate Background]");
|
||||
scopeOrNull?.launch(Dispatchers.IO) {
|
||||
StateUpdate.instance.seedUiFromDisk(context)
|
||||
}
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build();
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.IWithResultLauncher
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
@@ -341,8 +342,8 @@ class StateDownloads {
|
||||
fun download(video: IPlatformVideo, targetPixelcount: Long?, targetBitrate: Long?) {
|
||||
download(VideoDownload(video, targetPixelcount, targetBitrate));
|
||||
}
|
||||
fun download(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?) {
|
||||
download(VideoDownload(video, videoSource, audioSource, subtitleSource));
|
||||
fun download(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?, videoModifier: IRequestModifier? = null, audioModifier: IRequestModifier? = null) {
|
||||
download(VideoDownload(video, videoSource, audioSource, subtitleSource, videoModifier, audioModifier));
|
||||
}
|
||||
|
||||
private fun download(videoState: VideoDownload, notify: Boolean = true) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.os.Build
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.copyToOutputStream
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -14,7 +15,97 @@ import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
enum class UpdateUiState { NONE, AVAILABLE, DOWNLOADING, READY, FAILED }
|
||||
|
||||
class StateUpdate {
|
||||
|
||||
@Volatile var uiState: UpdateUiState = UpdateUiState.NONE
|
||||
private set
|
||||
@Volatile var uiVersion: Int = 0
|
||||
private set
|
||||
@Volatile var uiProgress: Int = 0
|
||||
private set
|
||||
@Volatile var uiIndeterminate: Boolean = true
|
||||
private set
|
||||
@Volatile var uiApkFile: File? = null
|
||||
private set
|
||||
@Volatile var uiError: String? = null
|
||||
private set
|
||||
|
||||
val onUiChanged = Event0()
|
||||
|
||||
fun setUiAvailable(version: Int) {
|
||||
uiState = UpdateUiState.AVAILABLE
|
||||
uiVersion = version
|
||||
uiError = null
|
||||
onUiChanged.emit()
|
||||
}
|
||||
|
||||
fun setUiDownloading(version: Int, progress: Int, indeterminate: Boolean) {
|
||||
uiState = UpdateUiState.DOWNLOADING
|
||||
uiVersion = version
|
||||
uiProgress = progress
|
||||
uiIndeterminate = indeterminate
|
||||
uiError = null
|
||||
onUiChanged.emit()
|
||||
}
|
||||
|
||||
fun setUiReady(version: Int, apkFile: File) {
|
||||
uiState = UpdateUiState.READY
|
||||
uiVersion = version
|
||||
uiApkFile = apkFile
|
||||
uiError = null
|
||||
onUiChanged.emit()
|
||||
}
|
||||
|
||||
fun setUiFailed(version: Int, error: String?) {
|
||||
uiState = UpdateUiState.FAILED
|
||||
uiVersion = version
|
||||
uiError = error
|
||||
onUiChanged.emit()
|
||||
}
|
||||
|
||||
fun clearUi() {
|
||||
uiState = UpdateUiState.NONE
|
||||
uiVersion = 0
|
||||
uiProgress = 0
|
||||
uiIndeterminate = true
|
||||
uiApkFile = null
|
||||
uiError = null
|
||||
onUiChanged.emit()
|
||||
}
|
||||
|
||||
fun seedUiFromDisk(context: Context) {
|
||||
if (uiState != UpdateUiState.NONE) return
|
||||
try {
|
||||
val dir = File(context.filesDir, "updates")
|
||||
if (!dir.exists()) return
|
||||
val abi = try { DESIRED_ABI } catch (t: Throwable) { return }
|
||||
val prefix = "app-$abi-"
|
||||
val suffix = ".apk"
|
||||
val candidates = dir.listFiles { f ->
|
||||
f.isFile && f.name.startsWith(prefix) && f.name.endsWith(suffix)
|
||||
} ?: return
|
||||
var bestVersion = BuildConfig.VERSION_CODE
|
||||
var bestFile: File? = null
|
||||
for (f in candidates) {
|
||||
val versionStr = f.name.removePrefix(prefix).removeSuffix(suffix)
|
||||
val v = versionStr.toIntOrNull() ?: continue
|
||||
if (v > bestVersion && f.length() > 0L) {
|
||||
bestVersion = v
|
||||
bestFile = f
|
||||
}
|
||||
}
|
||||
val ready = bestFile
|
||||
if (ready != null) {
|
||||
Logger.i(TAG, "Seeding UI ready from disk: v=$bestVersion file=${ready.absolutePath}")
|
||||
setUiReady(bestVersion, ready)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Logger.w(TAG, "Failed to seed UI from disk", t)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun checkForUpdates(context: Context, showUpToDateToast: Boolean, hideExceptionButtons: Boolean = false) = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val client = ManagedHttpClient();
|
||||
@@ -97,16 +188,16 @@ class StateUpdate {
|
||||
throw Exception("App is not compatible. Supported ABIS: ${Build.SUPPORTED_ABIS.joinToString()}}.");
|
||||
};
|
||||
val VERSION_URL = if (BuildConfig.IS_UNSTABLE_BUILD) {
|
||||
"https://releases.grayjay.app/version-unstable.txt"
|
||||
"https://rel.grayjay.app/version-unstable.txt"
|
||||
} else {
|
||||
"https://releases.grayjay.app/version.txt"
|
||||
"https://rel.grayjay.app/version.txt"
|
||||
}
|
||||
val APK_URL = if (BuildConfig.IS_UNSTABLE_BUILD) {
|
||||
"https://releases.grayjay.app/app-$DESIRED_ABI-release-unstable.apk"
|
||||
fun getApkUrl(version: Int): String = if (BuildConfig.IS_UNSTABLE_BUILD) {
|
||||
"https://rel.grayjay.app/$version/app-$DESIRED_ABI-release-unstable.apk"
|
||||
} else {
|
||||
"https://releases.grayjay.app/app-$DESIRED_ABI-release.apk"
|
||||
"https://rel.grayjay.app/$version/app-$DESIRED_ABI-release.apk"
|
||||
}
|
||||
val CHANGELOG_BASE_URL = "https://releases.grayjay.app/changelogs";
|
||||
val CHANGELOG_BASE_URL = "https://rel.grayjay.app/changelogs";
|
||||
|
||||
fun getApkFile(context: Context, version: Int): File {
|
||||
val dir = File(context.filesDir, "updates");
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.futo.platformplayer.views.announcements
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UpdateDownloadService
|
||||
import com.futo.platformplayer.UpdateInstaller
|
||||
import com.futo.platformplayer.UpdateNotificationManager
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateUpdate
|
||||
import com.futo.platformplayer.states.UpdateUiState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class UpdateBannerView : LinearLayout {
|
||||
private val _root: FrameLayout
|
||||
private val _iconUpdate: ImageView
|
||||
private val _textTitle: TextView
|
||||
private val _progressBar: ProgressBar
|
||||
private val _buttonAction: FrameLayout
|
||||
private val _textAction: TextView
|
||||
|
||||
private val _scope: CoroutineScope?
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
|
||||
inflate(context, R.layout.view_update_banner, this)
|
||||
|
||||
_scope = findViewTreeLifecycleOwner()?.lifecycleScope ?: StateApp.instance.scopeOrNull
|
||||
|
||||
_root = findViewById(R.id.root)
|
||||
_iconUpdate = findViewById(R.id.icon_update)
|
||||
_textTitle = findViewById(R.id.text_title)
|
||||
_progressBar = findViewById(R.id.update_banner_progress)
|
||||
_buttonAction = findViewById(R.id.button_action)
|
||||
_textAction = findViewById(R.id.text_action)
|
||||
|
||||
_buttonAction.setOnClickListener {
|
||||
onActionClicked()
|
||||
}
|
||||
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
StateUpdate.instance.onUiChanged.subscribe(this) {
|
||||
_scope?.launch(Dispatchers.Main) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
StateUpdate.instance.onUiChanged.remove(this)
|
||||
}
|
||||
|
||||
private fun onActionClicked() {
|
||||
val st = StateUpdate.instance
|
||||
when (st.uiState) {
|
||||
UpdateUiState.READY -> {
|
||||
val apk = st.uiApkFile ?: return
|
||||
UpdateNotificationManager.cancelAll(context)
|
||||
UpdateInstaller.startInstall(context, st.uiVersion, apk)
|
||||
}
|
||||
UpdateUiState.FAILED -> {
|
||||
if (st.uiVersion == 0) return
|
||||
val intent = Intent(context, UpdateDownloadService::class.java).apply {
|
||||
putExtra(UpdateDownloadService.EXTRA_VERSION, st.uiVersion)
|
||||
}
|
||||
try {
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
} catch (t: Throwable) {
|
||||
Logger.w(TAG, "Retry start service failed", t)
|
||||
}
|
||||
}
|
||||
UpdateUiState.AVAILABLE -> {
|
||||
if (st.uiVersion == 0) return
|
||||
val intent = Intent(context, UpdateDownloadService::class.java).apply {
|
||||
putExtra(UpdateDownloadService.EXTRA_VERSION, st.uiVersion)
|
||||
}
|
||||
try {
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
} catch (t: Throwable) {
|
||||
Logger.w(TAG, "Download start service failed", t)
|
||||
}
|
||||
}
|
||||
UpdateUiState.DOWNLOADING -> {}
|
||||
UpdateUiState.NONE -> {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
val st = StateUpdate.instance
|
||||
val gateOpen = Settings.instance.autoUpdate.shouldBackgroundDownload
|
||||
val visible = gateOpen && st.uiState != UpdateUiState.NONE
|
||||
|
||||
if (!visible) {
|
||||
_root.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
_root.visibility = View.VISIBLE
|
||||
|
||||
when (st.uiState) {
|
||||
UpdateUiState.AVAILABLE -> {
|
||||
_textTitle.text = "Update v${st.uiVersion}"
|
||||
_progressBar.visibility = View.GONE
|
||||
_textAction.text = "Download"
|
||||
_buttonAction.visibility = View.VISIBLE
|
||||
}
|
||||
UpdateUiState.DOWNLOADING -> {
|
||||
if (st.uiIndeterminate) {
|
||||
_textTitle.text = "Downloading v${st.uiVersion}"
|
||||
_progressBar.isIndeterminate = true
|
||||
} else {
|
||||
_textTitle.text = "Downloading v${st.uiVersion} - ${st.uiProgress}%"
|
||||
_progressBar.isIndeterminate = false
|
||||
_progressBar.progress = st.uiProgress
|
||||
}
|
||||
_progressBar.visibility = View.VISIBLE
|
||||
_buttonAction.visibility = View.GONE
|
||||
}
|
||||
UpdateUiState.READY -> {
|
||||
_textTitle.text = "Ready v${st.uiVersion}"
|
||||
_progressBar.visibility = View.GONE
|
||||
_textAction.text = "Install"
|
||||
_buttonAction.visibility = View.VISIBLE
|
||||
}
|
||||
UpdateUiState.FAILED -> {
|
||||
_textTitle.text = "Update failed"
|
||||
_progressBar.visibility = View.GONE
|
||||
_textAction.text = "Retry"
|
||||
_buttonAction.visibility = View.VISIBLE
|
||||
}
|
||||
UpdateUiState.NONE -> {
|
||||
_root.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "UpdateBannerView"
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,20 @@
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
tools:layout="@layout/fragment_overview_top_bar" />
|
||||
|
||||
<com.futo.platformplayer.views.announcements.UpdateBannerView
|
||||
android:id="@+id/update_banner"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintTop_toBottomOf="@id/fragment_top_bar"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent" />
|
||||
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/fragment_main"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/fragment_top_bar"
|
||||
app:layout_constraintTop_toBottomOf="@id/update_banner"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
android:paddingEnd="12dp"
|
||||
android:background="@drawable/background_pill"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:layout_marginTop="17dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:id="@+id/root">
|
||||
<LinearLayout
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:id="@+id/root">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/background_16_round_4dp"
|
||||
android:layout_marginLeft="10dp"
|
||||
android:layout_marginTop="6dp"
|
||||
android:layout_marginRight="10dp"
|
||||
android:layout_marginBottom="0dp"
|
||||
android:minHeight="40dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingRight="8dp"
|
||||
android:paddingBottom="6dp">
|
||||
|
||||
<ImageView android:id="@+id/icon_update"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:src="@drawable/ic_update"
|
||||
android:layout_marginRight="10dp"
|
||||
android:alpha="0.9"
|
||||
android:importantForAccessibility="no" />
|
||||
|
||||
<TextView android:id="@+id/text_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
tools:text="Downloading v123 - 42%"
|
||||
android:fontFamily="@font/inter_semibold"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/white"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1" />
|
||||
|
||||
<ProgressBar android:id="@+id/update_banner_progress"
|
||||
style="?android:attr/progressBarStyleHorizontal"
|
||||
android:layout_width="78dp"
|
||||
android:layout_height="4dp"
|
||||
android:layout_marginLeft="10dp"
|
||||
android:layout_marginRight="4dp"
|
||||
android:max="100"
|
||||
android:progressDrawable="@drawable/progress_update_banner"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<FrameLayout android:id="@+id/button_action"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="28dp"
|
||||
android:layout_marginLeft="10dp"
|
||||
android:background="@drawable/background_button_primary_round_4dp">
|
||||
|
||||
<TextView android:id="@+id/text_action"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
tools:text="Install"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/white"
|
||||
android:paddingLeft="13dp"
|
||||
android:paddingRight="13dp" />
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
Submodule app/src/stable/assets/sources/apple-podcasts updated: 9c65475be1...8d9dee8a49
Submodule app/src/stable/assets/sources/bilibili updated: 9186672f0f...c63c69beec
Submodule app/src/stable/assets/sources/bitchute updated: b213f91c0b...deed10c077
Submodule app/src/stable/assets/sources/crunchyroll updated: a1714790c5...499ab8b438
Submodule app/src/stable/assets/sources/curiositystream updated: 1ebf5da236...68f85a0d62
Submodule app/src/stable/assets/sources/dailymotion updated: 70f625a3bd...256b8433e0
Submodule app/src/stable/assets/sources/fosdem updated: 2231fbec11...e8fe3b4bb5
Submodule app/src/stable/assets/sources/mixcloud updated: 1b801553b3...c107d15296
Submodule app/src/stable/assets/sources/nebula updated: 090cd76dfa...84e920f378
Submodule app/src/stable/assets/sources/odysee updated: 1c7a8a4974...c6e462db9b
Submodule app/src/stable/assets/sources/patreon updated: 52154f36c2...87b168a7cb
Submodule app/src/stable/assets/sources/peertube updated: 7b52405ad0...c955d8ed56
Submodule app/src/stable/assets/sources/redbull-tv updated: 179b7a6e22...7f4317f5c7
Submodule app/src/stable/assets/sources/soundcloud updated: e785c5d8c9...8ed7c19c45
Submodule app/src/stable/assets/sources/tedtalks updated: 292e459eef...f7f31a4f9a
Submodule app/src/stable/assets/sources/twitch updated: cebdad37a3...3a46d407de
Submodule app/src/stable/assets/sources/youtube updated: fb90a44f83...746b390387
Submodule app/src/unstable/assets/sources/apple-podcasts updated: 9c65475be1...8d9dee8a49
Submodule app/src/unstable/assets/sources/bilibili updated: 9186672f0f...c63c69beec
Submodule app/src/unstable/assets/sources/bitchute updated: b213f91c0b...deed10c077
Submodule app/src/unstable/assets/sources/crunchyroll updated: a1714790c5...499ab8b438
Submodule app/src/unstable/assets/sources/curiositystream updated: 1ebf5da236...68f85a0d62
Submodule app/src/unstable/assets/sources/dailymotion updated: 70f625a3bd...256b8433e0
Submodule app/src/unstable/assets/sources/fosdem updated: 2231fbec11...e8fe3b4bb5
Submodule app/src/unstable/assets/sources/mixcloud updated: 1b801553b3...c107d15296
Submodule app/src/unstable/assets/sources/nebula updated: 090cd76dfa...84e920f378
Submodule app/src/unstable/assets/sources/odysee updated: 1c7a8a4974...c6e462db9b
Submodule app/src/unstable/assets/sources/patreon updated: 52154f36c2...87b168a7cb
Submodule app/src/unstable/assets/sources/peertube updated: 7b52405ad0...c955d8ed56
Submodule app/src/unstable/assets/sources/redbull-tv updated: 179b7a6e22...7f4317f5c7
Submodule app/src/unstable/assets/sources/soundcloud updated: e785c5d8c9...8ed7c19c45
Submodule app/src/unstable/assets/sources/tedtalks updated: 292e459eef...f7f31a4f9a
Submodule app/src/unstable/assets/sources/twitch updated: cebdad37a3...3a46d407de
Submodule app/src/unstable/assets/sources/youtube updated: fb90a44f83...746b390387
Reference in New Issue
Block a user