mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 04:52:39 +02:00
Compare commits
13 Commits
379
..
livestream
| Author | SHA1 | Date | |
|---|---|---|---|
| b4e0356069 | |||
| c65cee86b1 | |||
| 845c6b0031 | |||
| 985bd433a2 | |||
| ed6270552b | |||
| 9639c2a167 | |||
| b5caea6556 | |||
| 4bb09ec92e | |||
| 7a6b185e9d | |||
| cf3fc61f6a | |||
| d03019f0b7 | |||
| f1ce0078fd | |||
| cd90497a59 |
+8
-1
@@ -1,3 +1,6 @@
|
|||||||
|
variables:
|
||||||
|
GIT_SUBMODULE_STRATEGY: recursive
|
||||||
|
|
||||||
buildAndDeployApkUnstable:
|
buildAndDeployApkUnstable:
|
||||||
stage: build
|
stage: build
|
||||||
script:
|
script:
|
||||||
@@ -31,7 +34,7 @@ buildAndDeployPlaystore:
|
|||||||
stage: deploy
|
stage: deploy
|
||||||
script:
|
script:
|
||||||
- sh build-playstore.sh
|
- sh build-playstore.sh
|
||||||
- bash tools/venv_playstore.sh
|
- bash venv-playstore.sh
|
||||||
- . .venv-playstore/bin/activate
|
- . .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
|
- 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:
|
only:
|
||||||
@@ -53,5 +56,9 @@ updateFdroidRepo:
|
|||||||
needs:
|
needs:
|
||||||
- job: buildAndDeployApkStable
|
- job: buildAndDeployApkStable
|
||||||
artifacts: true
|
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:
|
script:
|
||||||
- python3 update_fdroid_index.py
|
- python3 update_fdroid_index.py
|
||||||
|
|||||||
@@ -251,6 +251,11 @@ fun String.fixHtmlWhitespace(): Spanned {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun Long.formatDuration(): String {
|
fun Long.formatDuration(): String {
|
||||||
|
// Negative durations show up for live streams seeked behind the seek-window start, or
|
||||||
|
// briefly while the player is reseating. Recurse on the absolute value so we get a clean
|
||||||
|
// `-MM:SS` instead of garbage like `00:-49`.
|
||||||
|
if (this < 0) return "-" + (-this).formatDuration()
|
||||||
|
|
||||||
val hours = this / 3600000
|
val hours = this / 3600000
|
||||||
val minutes = (this % 3600000) / 60000
|
val minutes = (this % 3600000) / 60000
|
||||||
val seconds = (this % 60000) / 1000
|
val seconds = (this % 60000) / 1000
|
||||||
|
|||||||
@@ -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.IPlatformVideoDetails
|
||||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
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.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.JSDashManifestRawAudioSource
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
|
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
|
||||||
import com.futo.platformplayer.downloads.VideoLocal
|
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)
|
val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
|
||||||
|
|
||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
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}" }
|
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
|
||||||
|
|
||||||
val masterPlaylistContent = masterPlaylistResponse.body?.string()
|
val masterPlaylistContent = masterPlaylistResponse.body?.string()
|
||||||
@@ -515,7 +517,7 @@ class UISlideOverlays {
|
|||||||
|
|
||||||
slideUpMenuOverlay.onOK.subscribe {
|
slideUpMenuOverlay.onOK.subscribe {
|
||||||
//TODO: Fix SubtitleRawSource issue
|
//TODO: Fix SubtitleRawSource issue
|
||||||
StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null);
|
StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null, videoModifier = modifier, audioModifier = modifier);
|
||||||
slideUpMenuOverlay.hide()
|
slideUpMenuOverlay.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,11 +528,11 @@ class UISlideOverlays {
|
|||||||
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
|
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (source is IHLSManifestSource) {
|
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")
|
UIDialogs.toast(container.context, "Variant video HLS playlist download started")
|
||||||
slideUpMenuOverlay.hide()
|
slideUpMenuOverlay.hide()
|
||||||
} else if (source is IHLSManifestAudioSource) {
|
} 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")
|
UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
|
||||||
slideUpMenuOverlay.hide()
|
slideUpMenuOverlay.hide()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -5,50 +5,14 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.content.ContextCompat
|
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() {
|
class UpdateActionReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
when (intent.action) {
|
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)
|
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) {
|
private fun handleDownloadCancel(context: Context, intent: Intent) {
|
||||||
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
package com.futo.platformplayer
|
package com.futo.platformplayer
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StateApp
|
|
||||||
import com.futo.platformplayer.states.StateUpdate
|
import com.futo.platformplayer.states.StateUpdate
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -35,18 +36,16 @@ class UpdateCheckWorker(appContext: Context, workerParams: WorkerParameters) : C
|
|||||||
return@withContext Result.success()
|
return@withContext Result.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateNotificationManager.showUpdateAvailableNotification(applicationContext, latestVersion)
|
StateUpdate.Companion.instance.setUiAvailable(latestVersion)
|
||||||
|
|
||||||
if (StateApp.instance.isMainActive) {
|
try {
|
||||||
withContext(Dispatchers.Main) {
|
val serviceIntent = Intent(applicationContext, UpdateDownloadService::class.java).apply {
|
||||||
StateApp.withContext { ctx ->
|
putExtra(UpdateDownloadService.EXTRA_VERSION, latestVersion)
|
||||||
try {
|
|
||||||
UIDialogs.showUpdateAvailableDialog(ctx, latestVersion, false)
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to show in-app update dialog from worker", t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
ContextCompat.startForegroundService(applicationContext, serviceIntent)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Logger.w(TAG, "Failed to start UpdateDownloadService", t)
|
||||||
|
StateUpdate.Companion.instance.setUiFailed(latestVersion, t.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
Result.success()
|
Result.success()
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
package com.futo.platformplayer
|
package com.futo.platformplayer
|
||||||
|
|
||||||
import android.app.Dialog
|
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.os.SystemClock
|
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.logging.Logger
|
||||||
import com.futo.platformplayer.models.ImageVariable
|
import com.futo.platformplayer.models.ImageVariable
|
||||||
import com.futo.platformplayer.states.AnnouncementType
|
import com.futo.platformplayer.states.AnnouncementType
|
||||||
import com.futo.platformplayer.states.SessionAnnouncement
|
import com.futo.platformplayer.states.SessionAnnouncement
|
||||||
import com.futo.platformplayer.states.StateAnnouncement
|
import com.futo.platformplayer.states.StateAnnouncement
|
||||||
import com.futo.platformplayer.states.StateApp
|
|
||||||
import com.futo.platformplayer.states.StateUpdate
|
import com.futo.platformplayer.states.StateUpdate
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -30,8 +28,6 @@ class UpdateDownloadService : Service() {
|
|||||||
private const val INITIAL_BACKOFF_MS = 5_000L
|
private const val INITIAL_BACKOFF_MS = 5_000L
|
||||||
private const val BUFFER_SIZE = 8 * 1024
|
private const val BUFFER_SIZE = 8 * 1024
|
||||||
private const val MIN_PROGRESS_UPDATE_INTERVAL_MS = 500L
|
private const val MIN_PROGRESS_UPDATE_INTERVAL_MS = 500L
|
||||||
|
|
||||||
var updateDownloadedDialog: Dialog? = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val job = SupervisorJob()
|
private val job = SupervisorJob()
|
||||||
@@ -56,6 +52,7 @@ class UpdateDownloadService : Service() {
|
|||||||
if (intent.getBooleanExtra(EXTRA_CANCEL, false)) {
|
if (intent.getBooleanExtra(EXTRA_CANCEL, false)) {
|
||||||
cancelRequested = true
|
cancelRequested = true
|
||||||
Logger.i(TAG, "Download cancel requested")
|
Logger.i(TAG, "Download cancel requested")
|
||||||
|
StateUpdate.Companion.instance.clearUi()
|
||||||
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
@@ -75,6 +72,10 @@ class UpdateDownloadService : Service() {
|
|||||||
isDownloading = true
|
isDownloading = true
|
||||||
cancelRequested = false
|
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)
|
val notification = UpdateNotificationManager.buildDownloadProgressNotification(this, version, 0, true)
|
||||||
startForeground(UpdateNotificationManager.NOTIF_ID_DOWNLOADING, notification)
|
startForeground(UpdateNotificationManager.NOTIF_ID_DOWNLOADING, notification)
|
||||||
|
|
||||||
@@ -97,6 +98,7 @@ class UpdateDownloadService : Service() {
|
|||||||
if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) {
|
if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) {
|
||||||
lastProgressUpdateElapsedMs = now
|
lastProgressUpdateElapsedMs = now
|
||||||
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate);
|
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate);
|
||||||
|
StateUpdate.Companion.instance.setUiDownloading(version, progress, indeterminate)
|
||||||
|
|
||||||
if(onProgress != null)
|
if(onProgress != null)
|
||||||
onProgress.invoke(progress);
|
onProgress.invoke(progress);
|
||||||
@@ -132,7 +134,7 @@ class UpdateDownloadService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
performDownload(StateUpdate.APK_URL, partialFile, version, {
|
performDownload(StateUpdate.getApkUrl(version), partialFile, version, {
|
||||||
try {
|
try {
|
||||||
if (announcement != null)
|
if (announcement != null)
|
||||||
announcement?.setProgress(it);
|
announcement?.setProgress(it);
|
||||||
@@ -159,6 +161,7 @@ class UpdateDownloadService : Service() {
|
|||||||
if (attempt == MAX_RETRIES - 1) {
|
if (attempt == MAX_RETRIES - 1) {
|
||||||
Logger.e(TAG, "Download failed after ${attempt + 1} attempts", t)
|
Logger.e(TAG, "Download failed after ${attempt + 1} attempts", t)
|
||||||
UpdateNotificationManager.showDownloadFailedNotification(this, version, t)
|
UpdateNotificationManager.showDownloadFailedNotification(this, version, t)
|
||||||
|
StateUpdate.Companion.instance.setUiFailed(version, t.message)
|
||||||
break
|
break
|
||||||
} else {
|
} else {
|
||||||
Logger.w(TAG, "Download attempt ${attempt + 1} failed, retrying in ${backoffMs / 1000}s", t)
|
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) {
|
private fun onDownloadComplete(version: Int, apkFile: File) {
|
||||||
Logger.i(TAG, "Download complete for version=$version, file=${apkFile.absolutePath}")
|
Logger.i(TAG, "Download complete for version=$version, file=${apkFile.absolutePath}")
|
||||||
UpdateNotificationManager.showDownloadCompleteNotification(this, version, apkFile)
|
UpdateNotificationManager.showDownloadCompleteNotification(this, version, apkFile)
|
||||||
|
StateUpdate.Companion.instance.setUiReady(version, apkFile)
|
||||||
|
|
||||||
if (StateApp.instance.isMainActive) {
|
try {
|
||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
val ctx = applicationContext
|
||||||
StateApp.withContext { ctx ->
|
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") {
|
||||||
try {
|
UpdateNotificationManager.cancelAll(ctx)
|
||||||
updateDownloadedDialog = UIDialogs.showDialog(ctx, R.drawable.foreground,
|
UpdateInstaller.startInstall(ctx, version, apkFile)
|
||||||
"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));
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} 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 androidx.core.net.toUri
|
||||||
import com.futo.platformplayer.dialogs.AutoUpdateDialog
|
import com.futo.platformplayer.dialogs.AutoUpdateDialog
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StateUpdate
|
||||||
|
|
||||||
object UpdateInstaller {
|
object UpdateInstaller {
|
||||||
private const val TAG = "UpdateInstaller"
|
private const val TAG = "UpdateInstaller"
|
||||||
@@ -61,6 +62,17 @@ object UpdateInstaller {
|
|||||||
var inputStream: InputStream? = null
|
var inputStream: InputStream? = null
|
||||||
var session: PackageInstaller.Session? = null
|
var session: PackageInstaller.Session? = null
|
||||||
try {
|
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 packageInstaller: PackageInstaller = context.packageManager.packageInstaller
|
||||||
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||||
@@ -68,7 +80,6 @@ object UpdateInstaller {
|
|||||||
session = packageInstaller.openSession(sessionId)
|
session = packageInstaller.openSession(sessionId)
|
||||||
|
|
||||||
inputStream = apkFile.inputStream()
|
inputStream = apkFile.inputStream()
|
||||||
val dataLength = apkFile.length()
|
|
||||||
|
|
||||||
session.openWrite("package", 0, dataLength).use { sessionStream ->
|
session.openWrite("package", 0, dataLength).use { sessionStream ->
|
||||||
inputStream.copyToOutputStream(dataLength, sessionStream) { _ -> }
|
inputStream.copyToOutputStream(dataLength, sessionStream) { _ -> }
|
||||||
@@ -91,11 +102,18 @@ object UpdateInstaller {
|
|||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.w(TAG, "Exception while installing update", e)
|
Logger.w(TAG, "Exception while installing update", e)
|
||||||
session?.abandon()
|
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) {
|
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 {
|
} finally {
|
||||||
session?.close()
|
session?.close()
|
||||||
inputStream?.close()
|
inputStream?.close()
|
||||||
@@ -110,10 +128,12 @@ object UpdateInstaller {
|
|||||||
if (result.isNullOrEmpty()) {
|
if (result.isNullOrEmpty()) {
|
||||||
Logger.i(TAG, "Update install finished successfully")
|
Logger.i(TAG, "Update install finished successfully")
|
||||||
UpdateNotificationManager.showInstallSucceededNotification(context, version)
|
UpdateNotificationManager.showInstallSucceededNotification(context, version)
|
||||||
|
StateUpdate.instance.clearUi()
|
||||||
} else {
|
} else {
|
||||||
Logger.w(TAG, "Update install failed: $result")
|
Logger.w(TAG, "Update install failed: $result")
|
||||||
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, result)
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, result)
|
||||||
UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n$result")
|
UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n$result")
|
||||||
|
StateUpdate.instance.setUiReady(version, apkFile)
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.e(TAG, "Failed to handle install result", e)
|
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_NAME = "App updates"
|
||||||
private const val CHANNEL_DESCRIPTION = "Notifications about new app versions"
|
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_DOWNLOAD_CANCEL = "com.futo.platformplayer.UPDATE_CANCEL"
|
||||||
const val ACTION_INSTALL_NOW = "com.futo.platformplayer.UPDATE_INSTALL"
|
const val ACTION_INSTALL_NOW = "com.futo.platformplayer.UPDATE_INSTALL"
|
||||||
private const val REQUEST_CODE_INSTALL = 1001
|
private const val REQUEST_CODE_INSTALL = 1001
|
||||||
@@ -32,7 +29,6 @@ object UpdateNotificationManager {
|
|||||||
const val EXTRA_VERSION = "version"
|
const val EXTRA_VERSION = "version"
|
||||||
const val EXTRA_APK_PATH = "apk_path"
|
const val EXTRA_APK_PATH = "apk_path"
|
||||||
|
|
||||||
const val NOTIF_ID_AVAILABLE = 2001
|
|
||||||
const val NOTIF_ID_DOWNLOADING = 2002
|
const val NOTIF_ID_DOWNLOADING = 2002
|
||||||
const val NOTIF_ID_READY = 2003
|
const val NOTIF_ID_READY = 2003
|
||||||
const val NOTIF_ID_INSTALL_FAILED = 2004
|
const val NOTIF_ID_INSTALL_FAILED = 2004
|
||||||
@@ -84,43 +80,6 @@ object UpdateNotificationManager {
|
|||||||
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_SUCCEEDED, builder.build())
|
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 {
|
fun buildDownloadProgressNotification(context: Context, version: Int, progress: Int, indeterminate: Boolean): Notification {
|
||||||
ensureChannel(context)
|
ensureChannel(context)
|
||||||
|
|
||||||
@@ -223,11 +182,9 @@ object UpdateNotificationManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun cancelAll(context: Context) {
|
fun cancelAll(context: Context) {
|
||||||
NotificationManagerCompat.from(context).cancel(NOTIF_ID_AVAILABLE)
|
|
||||||
NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING)
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING)
|
||||||
NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY)
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY)
|
||||||
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_FAILED)
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_FAILED)
|
||||||
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_SUCCEEDED)
|
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.SSLContext
|
||||||
import javax.net.ssl.TrustManager
|
import javax.net.ssl.TrustManager
|
||||||
import javax.net.ssl.X509TrustManager
|
import javax.net.ssl.X509TrustManager
|
||||||
|
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
|
||||||
import kotlin.system.measureTimeMillis
|
import kotlin.system.measureTimeMillis
|
||||||
|
|
||||||
open class ManagedHttpClient {
|
open class ManagedHttpClient {
|
||||||
@@ -89,10 +90,16 @@ open class ManagedHttpClient {
|
|||||||
return clonedClient;
|
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()
|
ensureNotMainThread()
|
||||||
try {
|
try {
|
||||||
val result = head(url);
|
val result = head(url, HashMap(), modifier);
|
||||||
if(result.isOk)
|
if(result.isOk)
|
||||||
return result.getHeadersFlat();
|
return result.getHeadersFlat();
|
||||||
else
|
else
|
||||||
@@ -141,12 +148,14 @@ open class ManagedHttpClient {
|
|||||||
return Socket(websocket);
|
return Socket(websocket);
|
||||||
}
|
}
|
||||||
|
|
||||||
fun get(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
|
fun get(url : String, headers : MutableMap<String, String> = HashMap<String, String>(), modifier: IRequestModifier? = null) : Response {
|
||||||
return execute(Request(url, "GET", null, headers));
|
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 {
|
fun head(url : String, headers : MutableMap<String, String> = HashMap<String, String>(), modifier: IRequestModifier? = null) : Response {
|
||||||
return execute(Request(url, "HEAD", null, headers));
|
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 {
|
fun post(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
|
||||||
|
|||||||
@@ -920,6 +920,7 @@ class StateCasting {
|
|||||||
if (videoSource != null) {
|
if (videoSource != null) {
|
||||||
_castServer.addHandlerWithAllowAllOptions(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
|
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
|
||||||
|
.withIRequestModifier((videoSource as? JSSource)?.getRequestModifier())
|
||||||
.withInjectedHost()
|
.withInjectedHost()
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
@@ -927,6 +928,7 @@ class StateCasting {
|
|||||||
if (audioSource != null) {
|
if (audioSource != null) {
|
||||||
_castServer.addHandlerWithAllowAllOptions(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
|
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
|
||||||
|
.withIRequestModifier((audioSource as? JSSource)?.getRequestModifier())
|
||||||
.withInjectedHost()
|
.withInjectedHost()
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
@@ -968,8 +970,7 @@ class StateCasting {
|
|||||||
val headers = masterContext.headers.clone()
|
val headers = masterContext.headers.clone()
|
||||||
headers["Content-Type"] = "application/vnd.apple.mpegurl";
|
headers["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||||
|
|
||||||
val req = requestModifier?.modifyRequest(sourceUrl, mapOf())
|
val masterPlaylistResponse = _client.get(sourceUrl, mutableMapOf(), requestModifier)
|
||||||
val masterPlaylistResponse = _client.get(req?.url ?: sourceUrl, (req?.headers ?: mapOf()).toMutableMap())
|
|
||||||
|
|
||||||
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
|
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
|
||||||
|
|
||||||
@@ -1022,7 +1023,7 @@ class StateCasting {
|
|||||||
val vpHeaders = vpContext.headers.clone()
|
val vpHeaders = vpContext.headers.clone()
|
||||||
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
|
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}" }
|
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
|
||||||
|
|
||||||
val vpContent = response.body?.string()
|
val vpContent = response.body?.string()
|
||||||
@@ -1059,7 +1060,7 @@ class StateCasting {
|
|||||||
val vpHeaders = vpContext.headers.clone()
|
val vpHeaders = vpContext.headers.clone()
|
||||||
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
|
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}" }
|
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
|
||||||
|
|
||||||
val vpContent = response.body?.string()
|
val vpContent = response.body?.string()
|
||||||
@@ -1190,6 +1191,7 @@ class StateCasting {
|
|||||||
|
|
||||||
_castServer.addHandlerWithAllowAllOptions(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
|
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
|
||||||
|
.withIRequestModifier((audioSource as? JSSource)?.getRequestModifier())
|
||||||
.withInjectedHost()
|
.withInjectedHost()
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("castHlsIndirectVariant");
|
).withTag("castHlsIndirectVariant");
|
||||||
@@ -1267,6 +1269,7 @@ class StateCasting {
|
|||||||
|
|
||||||
_castServer.addHandlerWithAllowAllOptions(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
|
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
|
||||||
|
.withIRequestModifier((videoSource as? JSSource)?.getRequestModifier())
|
||||||
.withInjectedHost()
|
.withInjectedHost()
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("castHlsIndirectVariant");
|
).withTag("castHlsIndirectVariant");
|
||||||
@@ -1350,6 +1353,7 @@ class StateCasting {
|
|||||||
if (videoSource != null) {
|
if (videoSource != null) {
|
||||||
_castServer.addHandlerWithAllowAllOptions(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
|
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
|
||||||
|
.withIRequestModifier((videoSource as? JSSource)?.getRequestModifier())
|
||||||
.withInjectedHost()
|
.withInjectedHost()
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
@@ -1357,6 +1361,7 @@ class StateCasting {
|
|||||||
if (audioSource != null) {
|
if (audioSource != null) {
|
||||||
_castServer.addHandlerWithAllowAllOptions(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
|
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
|
||||||
|
.withIRequestModifier((audioSource as? JSSource)?.getRequestModifier())
|
||||||
.withInjectedHost()
|
.withInjectedHost()
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
|
|||||||
@@ -71,16 +71,14 @@ class Event0() : EventBase<(()->Unit), (()->Boolean)>() {
|
|||||||
fun emit() : Boolean {
|
fun emit() : Boolean {
|
||||||
var handled = false;
|
var handled = false;
|
||||||
|
|
||||||
synchronized(_conditionalListeners) {
|
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
|
||||||
for (conditional in _conditionalListeners)
|
for (conditional in condSnapshot)
|
||||||
handled = handled || conditional.handler.invoke();
|
handled = handled || conditional.handler.invoke();
|
||||||
}
|
|
||||||
|
|
||||||
synchronized(_listeners) {
|
val snapshot = synchronized(_listeners) { _listeners.toList() };
|
||||||
handled = handled || _listeners.isNotEmpty();
|
handled = handled || snapshot.isNotEmpty();
|
||||||
for (handler in _listeners)
|
for (handler in snapshot)
|
||||||
handler.handler.invoke();
|
handler.handler.invoke();
|
||||||
}
|
|
||||||
|
|
||||||
return handled;
|
return handled;
|
||||||
}
|
}
|
||||||
@@ -88,16 +86,15 @@ class Event0() : EventBase<(()->Unit), (()->Boolean)>() {
|
|||||||
class Event1<T1>() : EventBase<((T1)->Unit), ((T1)->Boolean)>() {
|
class Event1<T1>() : EventBase<((T1)->Unit), ((T1)->Boolean)>() {
|
||||||
fun emit(value : T1): Boolean {
|
fun emit(value : T1): Boolean {
|
||||||
var handled = false;
|
var handled = false;
|
||||||
synchronized(_conditionalListeners) {
|
|
||||||
for (conditional in _conditionalListeners)
|
|
||||||
handled = handled || conditional.handler.invoke(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized(_listeners) {
|
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
|
||||||
handled = handled || _listeners.isNotEmpty();
|
for (conditional in condSnapshot)
|
||||||
for (handler in _listeners)
|
handled = handled || conditional.handler.invoke(value);
|
||||||
handler.handler.invoke(value);
|
|
||||||
}
|
val snapshot = synchronized(_listeners) { _listeners.toList() };
|
||||||
|
handled = handled || snapshot.isNotEmpty();
|
||||||
|
for (handler in snapshot)
|
||||||
|
handler.handler.invoke(value);
|
||||||
|
|
||||||
return handled;
|
return handled;
|
||||||
}
|
}
|
||||||
@@ -106,16 +103,14 @@ class Event2<T1, T2>() : EventBase<((T1, T2)->Unit), ((T1, T2)->Boolean)>() {
|
|||||||
fun emit(value1 : T1, value2 : T2): Boolean {
|
fun emit(value1 : T1, value2 : T2): Boolean {
|
||||||
var handled = false;
|
var handled = false;
|
||||||
|
|
||||||
synchronized(_conditionalListeners) {
|
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
|
||||||
for (conditional in _conditionalListeners)
|
for (conditional in condSnapshot)
|
||||||
handled = handled || conditional.handler.invoke(value1, value2);
|
handled = handled || conditional.handler.invoke(value1, value2);
|
||||||
}
|
|
||||||
|
|
||||||
synchronized(_listeners) {
|
val snapshot = synchronized(_listeners) { _listeners.toList() };
|
||||||
handled = handled || _listeners.isNotEmpty();
|
handled = handled || snapshot.isNotEmpty();
|
||||||
for (handler in _listeners)
|
for (handler in snapshot)
|
||||||
handler.handler.invoke(value1, value2);
|
handler.handler.invoke(value1, value2);
|
||||||
}
|
|
||||||
|
|
||||||
return handled;
|
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 {
|
fun emit(value1 : T1, value2 : T2, value3 : T3): Boolean {
|
||||||
var handled = false;
|
var handled = false;
|
||||||
|
|
||||||
synchronized(_conditionalListeners) {
|
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
|
||||||
for (conditional in _conditionalListeners)
|
for (conditional in condSnapshot)
|
||||||
handled = handled || conditional.handler.invoke(value1, value2, value3);
|
handled = handled || conditional.handler.invoke(value1, value2, value3);
|
||||||
}
|
|
||||||
|
|
||||||
synchronized(_listeners) {
|
val snapshot = synchronized(_listeners) { _listeners.toList() };
|
||||||
handled = handled || _listeners.isNotEmpty();
|
handled = handled || snapshot.isNotEmpty();
|
||||||
for (handler in _listeners)
|
for (handler in snapshot)
|
||||||
handler.handler.invoke(value1, value2, value3);
|
handler.handler.invoke(value1, value2, value3);
|
||||||
}
|
|
||||||
|
|
||||||
return handled;
|
return handled;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
|||||||
var inputStream: InputStream? = null;
|
var inputStream: InputStream? = null;
|
||||||
try {
|
try {
|
||||||
val client = ManagedHttpClient();
|
val client = ManagedHttpClient();
|
||||||
val response = client.get(StateUpdate.APK_URL);
|
val response = client.get(StateUpdate.getApkUrl(_maxVersion));
|
||||||
if (response.isOk && response.body != null) {
|
if (response.isOk && response.body != null) {
|
||||||
inputStream = response.body.byteStream();
|
inputStream = response.body.byteStream();
|
||||||
val dataLength = response.body.contentLength();
|
val dataLength = response.body.contentLength();
|
||||||
|
|||||||
@@ -156,6 +156,18 @@ class VideoDownload {
|
|||||||
var hasVideoRequestModifier: Boolean = false;
|
var hasVideoRequestModifier: Boolean = false;
|
||||||
var hasAudioRequestModifier: 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 progress: Double = 0.0;
|
||||||
var isCancelled = false;
|
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.requireAudioSource = targetBitrate != null; //TODO: May not be a valid check.. can only be determined after live fetch?
|
||||||
this.requiredCheck = optionalSources;
|
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.video = SerializedPlatformVideo.fromVideo(video);
|
||||||
this.videoDetails = SerializedPlatformVideoDetails.fromVideo(video, if (subtitleSource != null) listOf(subtitleSource) else listOf());
|
this.videoDetails = SerializedPlatformVideoDetails.fromVideo(video, if (subtitleSource != null) listOf(subtitleSource) else listOf());
|
||||||
this.videoSource = if(videoSource is IVideoUrlSource) VideoUrlSource.fromUrlSource(videoSource) else null;
|
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.audioSourceLive = if(audioSource is JSSource) audioSource else null;
|
||||||
this.subtitleSource = subtitleSource;
|
this.subtitleSource = subtitleSource;
|
||||||
this.prepareTime = OffsetDateTime.now();
|
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.hasVideoRequestExecutor = videoSource is JSSource && videoSource.hasRequestExecutor;
|
||||||
this.hasAudioRequestExecutor = audioSource is JSSource && audioSource.hasRequestExecutor;
|
this.hasAudioRequestExecutor = audioSource is JSSource && audioSource.hasRequestExecutor;
|
||||||
this.hasVideoRequestModifier = videoSource is JSSource && videoSource.hasRequestModifier;
|
// Set modifier flags from either the source or an explicitly provided modifier
|
||||||
this.hasAudioRequestModifier = audioSource is JSSource && audioSource.hasRequestModifier;
|
// (e.g. from the HLS picker, where the source is an HLSVariant, not JSSource).
|
||||||
this.requiresLiveVideoSource = this.hasVideoRequestModifier || this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
|
// These flags are serialized and used by needsReprepareForAuth after restore.
|
||||||
this.requiresLiveAudioSource = this.hasAudioRequestModifier || this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
|
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.targetVideoName = videoSource?.name;
|
||||||
this.targetAudioName = audioSource?.name;
|
this.targetAudioName = audioSource?.name;
|
||||||
this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null;
|
this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null;
|
||||||
@@ -321,8 +343,10 @@ class VideoDownload {
|
|||||||
val videoSources = arrayListOf<IVideoSource>()
|
val videoSources = arrayListOf<IVideoSource>()
|
||||||
for (source in original.video.videoSources) {
|
for (source in original.video.videoSources) {
|
||||||
if (source is IHLSManifestSource) {
|
if (source is IHLSManifestSource) {
|
||||||
|
val sourceModifier = if (source is JSSource && source.hasRequestModifier) source.getRequestModifier() else null
|
||||||
|
if (sourceModifier != null) preparedVideoRequestModifier = sourceModifier
|
||||||
try {
|
try {
|
||||||
val playlistResponse = client.get(source.url)
|
val playlistResponse = client.get(source.url, HashMap(), sourceModifier)
|
||||||
if (playlistResponse.isOk) {
|
if (playlistResponse.isOk) {
|
||||||
val resolvedPlaylistUrl = playlistResponse.url
|
val resolvedPlaylistUrl = playlistResponse.url
|
||||||
val playlistContent = playlistResponse.body?.string()
|
val playlistContent = playlistResponse.body?.string()
|
||||||
@@ -349,6 +373,8 @@ class VideoDownload {
|
|||||||
if(vsource is JSSource) {
|
if(vsource is JSSource) {
|
||||||
this.hasVideoRequestExecutor = this.hasVideoRequestExecutor || vsource.hasRequestExecutor;
|
this.hasVideoRequestExecutor = this.hasVideoRequestExecutor || vsource.hasRequestExecutor;
|
||||||
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (vsource is JSDashManifestRawSource && vsource.hasGenerate);
|
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (vsource is JSDashManifestRawSource && vsource.hasGenerate);
|
||||||
|
if (vsource.hasRequestModifier && preparedVideoRequestModifier == null)
|
||||||
|
preparedVideoRequestModifier = vsource.getRequestModifier()
|
||||||
}
|
}
|
||||||
|
|
||||||
if(vsource == null) {
|
if(vsource == null) {
|
||||||
@@ -370,8 +396,10 @@ class VideoDownload {
|
|||||||
if (video is VideoUnMuxedSourceDescriptor) {
|
if (video is VideoUnMuxedSourceDescriptor) {
|
||||||
for (source in video.audioSources) {
|
for (source in video.audioSources) {
|
||||||
if (source is IHLSManifestAudioSource) {
|
if (source is IHLSManifestAudioSource) {
|
||||||
|
val sourceModifier = if (source is JSSource && source.hasRequestModifier) source.getRequestModifier() else null
|
||||||
|
if (sourceModifier != null) preparedAudioRequestModifier = sourceModifier
|
||||||
try {
|
try {
|
||||||
val playlistResponse = client.get(source.url)
|
val playlistResponse = client.get(source.url, HashMap(), sourceModifier)
|
||||||
if (playlistResponse.isOk) {
|
if (playlistResponse.isOk) {
|
||||||
val resolvedPlaylistUrl = playlistResponse.url
|
val resolvedPlaylistUrl = playlistResponse.url
|
||||||
val playlistContent = playlistResponse.body?.string()
|
val playlistContent = playlistResponse.body?.string()
|
||||||
@@ -406,6 +434,8 @@ class VideoDownload {
|
|||||||
if(asource is JSSource) {
|
if(asource is JSSource) {
|
||||||
this.hasAudioRequestExecutor = this.hasAudioRequestExecutor || asource.hasRequestExecutor;
|
this.hasAudioRequestExecutor = this.hasAudioRequestExecutor || asource.hasRequestExecutor;
|
||||||
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (asource is JSDashManifestRawSource && asource.hasGenerate);
|
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (asource is JSDashManifestRawSource && asource.hasGenerate);
|
||||||
|
if (asource.hasRequestModifier && preparedAudioRequestModifier == null)
|
||||||
|
preparedAudioRequestModifier = asource.getRequestModifier()
|
||||||
}
|
}
|
||||||
|
|
||||||
if(asource == null) {
|
if(asource == null) {
|
||||||
@@ -502,10 +532,16 @@ class VideoDownload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val videoModifier = preparedVideoRequestModifier
|
||||||
if(actualVideoSource is IVideoUrlSource)
|
if(actualVideoSource is IVideoUrlSource)
|
||||||
videoFileSize = when (videoSource!!.container) {
|
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)
|
"application/vnd.apple.mpegurl" -> {
|
||||||
else -> downloadFileSource("Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
// 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) {
|
else if(actualVideoSource is JSDashManifestRawSource) {
|
||||||
if(actualAudioSource == null)
|
if(actualAudioSource == null)
|
||||||
@@ -546,10 +582,16 @@ class VideoDownload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val audioModifier = preparedAudioRequestModifier
|
||||||
if(actualAudioSource is IAudioUrlSource)
|
if(actualAudioSource is IAudioUrlSource)
|
||||||
audioFileSize = when (audioSource!!.container) {
|
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)
|
"application/vnd.apple.mpegurl" -> {
|
||||||
else -> downloadFileSource("Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
// 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) {
|
else if(actualAudioSource is JSDashManifestRawAudioSource) {
|
||||||
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback, 2);
|
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())
|
if (targetFile.exists())
|
||||||
targetFile.delete()
|
targetFile.delete()
|
||||||
|
|
||||||
var downloadedTotalLength = 0L
|
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 {
|
fun downloadBytes(url: String, rangeStart: Long? = null, rangeLength: Long? = null): ByteArray {
|
||||||
val headers = mutableMapOf<String, String>()
|
val headers = mutableMapOf<String, String>()
|
||||||
@@ -685,17 +723,13 @@ class VideoDownload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val modified = modifier?.modifyRequest(url, headers)
|
val resp = client.get(url, headers, modifier)
|
||||||
val finalUrl = modified?.url ?: url
|
|
||||||
val finalHeaders = modified?.headers?.toMutableMap() ?: headers
|
|
||||||
|
|
||||||
val resp = client.get(finalUrl, finalHeaders)
|
|
||||||
if (!resp.isOk) {
|
if (!resp.isOk) {
|
||||||
resp.body?.close()
|
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()
|
val bytes = body.bytes()
|
||||||
body.close()
|
body.close()
|
||||||
return bytes
|
return bytes
|
||||||
@@ -710,12 +744,7 @@ class VideoDownload {
|
|||||||
|
|
||||||
val segmentFiles = arrayListOf<File>()
|
val segmentFiles = arrayListOf<File>()
|
||||||
try {
|
try {
|
||||||
val playlistHeaders = mutableMapOf<String, String>()
|
val playlistResp = client.get(hlsUrl, mutableMapOf(), modifier)
|
||||||
val modifiedPlaylistReq = modifier?.modifyRequest(hlsUrl, playlistHeaders)
|
|
||||||
val playlistResp = client.get(
|
|
||||||
modifiedPlaylistReq?.url ?: hlsUrl,
|
|
||||||
modifiedPlaylistReq?.headers?.toMutableMap() ?: playlistHeaders
|
|
||||||
)
|
|
||||||
|
|
||||||
check(playlistResp.isOk) { "Failed to get variant playlist: ${playlistResp.code}" }
|
check(playlistResp.isOk) { "Failed to get variant playlist: ${playlistResp.code}" }
|
||||||
|
|
||||||
@@ -964,16 +993,7 @@ class VideoDownload {
|
|||||||
|
|
||||||
Logger.i(TAG, "Downloading cue ${indexCounter}")
|
Logger.i(TAG, "Downloading cue ${indexCounter}")
|
||||||
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
|
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
|
||||||
val modified = modifier?.modifyRequest(url, mapOf());
|
val data = executeOrGet(client, executor, modifier, url)
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
fileStream.write(data, 0, data.size);
|
fileStream.write(data, 0, data.size);
|
||||||
speedTracker.addWork(data.size.toLong());
|
speedTracker.addWork(data.size.toLong());
|
||||||
written += data.size;
|
written += data.size;
|
||||||
@@ -993,16 +1013,7 @@ class VideoDownload {
|
|||||||
val t2 = cue2.groupValues[1];
|
val t2 = cue2.groupValues[1];
|
||||||
val d2 = cue2.groupValues[2];
|
val d2 = cue2.groupValues[2];
|
||||||
val url2 = foundTemplateUrl2!!.replace("\$Number\$", (index2).toString());
|
val url2 = foundTemplateUrl2!!.replace("\$Number\$", (index2).toString());
|
||||||
val modified2 = modifier?.modifyRequest(url, mapOf());
|
val data = executeOrGet(client, executor, modifier, url2)
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
fileStream2.write(data, 0, data.size);
|
fileStream2.write(data, 0, data.size);
|
||||||
speedTracker.addWork(data.size.toLong());
|
speedTracker.addWork(data.size.toLong());
|
||||||
written2 += data.size;
|
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())
|
if(targetFile.exists())
|
||||||
targetFile.delete();
|
targetFile.delete();
|
||||||
|
|
||||||
@@ -1080,13 +1091,8 @@ class VideoDownload {
|
|||||||
val sourceLength: Long?;
|
val sourceLength: Long?;
|
||||||
val fileStream = FileOutputStream(targetFile);
|
val fileStream = FileOutputStream(targetFile);
|
||||||
|
|
||||||
val modifier = if (source is JSSource && source.hasRequestModifier)
|
|
||||||
source.getRequestModifier();
|
|
||||||
else
|
|
||||||
null;
|
|
||||||
|
|
||||||
try {
|
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 };
|
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"))
|
if(Settings.instance.downloads.byteRangeDownload && head?.containsKey("accept-ranges") == true && head.containsKey("content-length"))
|
||||||
{
|
{
|
||||||
@@ -1161,12 +1167,7 @@ class VideoDownload {
|
|||||||
|
|
||||||
var lastSpeed: Long = 0;
|
var lastSpeed: Long = 0;
|
||||||
|
|
||||||
val result = if (modifier != null) {
|
val result = client.get(url, HashMap(), modifier)
|
||||||
val modified = modifier.modifyRequest(url, mapOf())
|
|
||||||
client.get(modified.url!!, modified.headers.toMutableMap())
|
|
||||||
} else {
|
|
||||||
client.get(url)
|
|
||||||
}
|
|
||||||
if (!result.isOk) {
|
if (!result.isOk) {
|
||||||
result.body?.close()
|
result.body?.close()
|
||||||
throw IllegalStateException("Failed to download source. Web[${result.code}] Error");
|
throw IllegalStateException("Failed to download source. Web[${result.code}] Error");
|
||||||
@@ -1379,13 +1380,12 @@ class VideoDownload {
|
|||||||
var lastException: Throwable? = null;
|
var lastException: Throwable? = null;
|
||||||
|
|
||||||
val headers = mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}"));
|
val headers = mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}"));
|
||||||
val modified = modifier?.modifyRequest(url, headers);
|
|
||||||
|
|
||||||
while (retryCount <= 3) {
|
while (retryCount <= 3) {
|
||||||
try {
|
try {
|
||||||
val toRead = rangeEnd - rangeStart;
|
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) {
|
if (!req.isOk) {
|
||||||
val bodyString = req.body?.string()
|
val bodyString = req.body?.string()
|
||||||
req.body?.close()
|
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 {
|
companion object {
|
||||||
const val TAG = "VideoDownload";
|
const val TAG = "VideoDownload";
|
||||||
const val GROUP_PLAYLIST = "Playlist";
|
const val GROUP_PLAYLIST = "Playlist";
|
||||||
|
|||||||
@@ -238,6 +238,16 @@ class DownloadService : Service() {
|
|||||||
download.targetBitrate = download.audioSource!!.bitrate.toLong();
|
download.targetBitrate = download.audioSource!!.bitrate.toLong();
|
||||||
download.audioSource = null;
|
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))
|
if(download.videoDetails == null || (!download.isVideoDownloadReady || !download.isAudioDownloadReady))
|
||||||
download.changeState(VideoDownload.State.PREPARING);
|
download.changeState(VideoDownload.State.PREPARING);
|
||||||
notifyDownload(download);
|
notifyDownload(download);
|
||||||
|
|||||||
@@ -668,6 +668,9 @@ class StateApp {
|
|||||||
if (Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
|
if (Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
|
||||||
if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
|
if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
|
||||||
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate Background]");
|
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate Background]");
|
||||||
|
scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
|
StateUpdate.instance.seedUiFromDisk(context)
|
||||||
|
}
|
||||||
val constraints = Constraints.Builder()
|
val constraints = Constraints.Builder()
|
||||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.futo.platformplayer.UIDialogs
|
|||||||
import com.futo.platformplayer.activities.IWithResultLauncher
|
import com.futo.platformplayer.activities.IWithResultLauncher
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.api.media.PlatformID
|
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.IAudioSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||||
@@ -341,8 +342,8 @@ class StateDownloads {
|
|||||||
fun download(video: IPlatformVideo, targetPixelcount: Long?, targetBitrate: Long?) {
|
fun download(video: IPlatformVideo, targetPixelcount: Long?, targetBitrate: Long?) {
|
||||||
download(VideoDownload(video, targetPixelcount, targetBitrate));
|
download(VideoDownload(video, targetPixelcount, targetBitrate));
|
||||||
}
|
}
|
||||||
fun download(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?) {
|
fun download(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?, videoModifier: IRequestModifier? = null, audioModifier: IRequestModifier? = null) {
|
||||||
download(VideoDownload(video, videoSource, audioSource, subtitleSource));
|
download(VideoDownload(video, videoSource, audioSource, subtitleSource, videoModifier, audioModifier));
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun download(videoState: VideoDownload, notify: Boolean = true) {
|
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.BuildConfig
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.copyToOutputStream
|
import com.futo.platformplayer.copyToOutputStream
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -14,7 +15,113 @@ import java.io.File
|
|||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
|
||||||
|
enum class UpdateUiState { NONE, AVAILABLE, DOWNLOADING, READY, FAILED }
|
||||||
|
|
||||||
class StateUpdate {
|
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
|
||||||
|
@Volatile var uiDismissed: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
val onUiChanged = Event0()
|
||||||
|
|
||||||
|
fun setUiAvailable(version: Int) {
|
||||||
|
val transitioned = uiState != UpdateUiState.AVAILABLE
|
||||||
|
uiState = UpdateUiState.AVAILABLE
|
||||||
|
uiVersion = version
|
||||||
|
uiError = null
|
||||||
|
if (transitioned) uiDismissed = false
|
||||||
|
onUiChanged.emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUiDownloading(version: Int, progress: Int, indeterminate: Boolean) {
|
||||||
|
val transitioned = uiState != UpdateUiState.DOWNLOADING
|
||||||
|
uiState = UpdateUiState.DOWNLOADING
|
||||||
|
uiVersion = version
|
||||||
|
uiProgress = progress
|
||||||
|
uiIndeterminate = indeterminate
|
||||||
|
uiError = null
|
||||||
|
if (transitioned) uiDismissed = false
|
||||||
|
onUiChanged.emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUiReady(version: Int, apkFile: File) {
|
||||||
|
val transitioned = uiState != UpdateUiState.READY
|
||||||
|
uiState = UpdateUiState.READY
|
||||||
|
uiVersion = version
|
||||||
|
uiApkFile = apkFile
|
||||||
|
uiError = null
|
||||||
|
if (transitioned) uiDismissed = false
|
||||||
|
onUiChanged.emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUiFailed(version: Int, error: String?) {
|
||||||
|
val transitioned = uiState != UpdateUiState.FAILED
|
||||||
|
uiState = UpdateUiState.FAILED
|
||||||
|
uiVersion = version
|
||||||
|
uiError = error
|
||||||
|
if (transitioned) uiDismissed = false
|
||||||
|
onUiChanged.emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearUi() {
|
||||||
|
uiState = UpdateUiState.NONE
|
||||||
|
uiVersion = 0
|
||||||
|
uiProgress = 0
|
||||||
|
uiIndeterminate = true
|
||||||
|
uiApkFile = null
|
||||||
|
uiError = null
|
||||||
|
uiDismissed = false
|
||||||
|
onUiChanged.emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissUi() {
|
||||||
|
uiDismissed = true
|
||||||
|
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) {
|
suspend fun checkForUpdates(context: Context, showUpToDateToast: Boolean, hideExceptionButtons: Boolean = false) = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val client = ManagedHttpClient();
|
val client = ManagedHttpClient();
|
||||||
@@ -97,16 +204,16 @@ class StateUpdate {
|
|||||||
throw Exception("App is not compatible. Supported ABIS: ${Build.SUPPORTED_ABIS.joinToString()}}.");
|
throw Exception("App is not compatible. Supported ABIS: ${Build.SUPPORTED_ABIS.joinToString()}}.");
|
||||||
};
|
};
|
||||||
val VERSION_URL = if (BuildConfig.IS_UNSTABLE_BUILD) {
|
val VERSION_URL = if (BuildConfig.IS_UNSTABLE_BUILD) {
|
||||||
"https://releases.grayjay.app/version-unstable.txt"
|
"https://rel.grayjay.app/version-unstable.txt"
|
||||||
} else {
|
} else {
|
||||||
"https://releases.grayjay.app/version.txt"
|
"https://rel.grayjay.app/version.txt"
|
||||||
}
|
}
|
||||||
val APK_URL = if (BuildConfig.IS_UNSTABLE_BUILD) {
|
fun getApkUrl(version: Int): String = if (BuildConfig.IS_UNSTABLE_BUILD) {
|
||||||
"https://releases.grayjay.app/app-$DESIRED_ABI-release-unstable.apk"
|
"https://rel.grayjay.app/$version/app-$DESIRED_ABI-release-unstable.apk"
|
||||||
} else {
|
} 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 {
|
fun getApkFile(context: Context, version: Int): File {
|
||||||
val dir = File(context.filesDir, "updates");
|
val dir = File(context.filesDir, "updates");
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
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 _textBody: TextView
|
||||||
|
private val _progressBar: ProgressBar
|
||||||
|
private val _buttonAction: FrameLayout
|
||||||
|
private val _textAction: TextView
|
||||||
|
private val _buttonClose: ImageView
|
||||||
|
|
||||||
|
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)
|
||||||
|
_textBody = findViewById(R.id.text_body)
|
||||||
|
_progressBar = findViewById(R.id.update_banner_progress)
|
||||||
|
_buttonAction = findViewById(R.id.button_action)
|
||||||
|
_textAction = findViewById(R.id.text_action)
|
||||||
|
_buttonClose = findViewById(R.id.button_close)
|
||||||
|
|
||||||
|
_buttonClose.setOnClickListener {
|
||||||
|
StateUpdate.instance.dismissUi()
|
||||||
|
}
|
||||||
|
|
||||||
|
_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.DOWNLOADING -> {
|
||||||
|
val intent = Intent(context, UpdateDownloadService::class.java).apply {
|
||||||
|
putExtra(UpdateDownloadService.EXTRA_VERSION, st.uiVersion)
|
||||||
|
putExtra(UpdateDownloadService.EXTRA_CANCEL, true)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Logger.w(TAG, "Cancel 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.NONE -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refresh() {
|
||||||
|
val st = StateUpdate.instance
|
||||||
|
val gateOpen = Settings.instance.autoUpdate.shouldBackgroundDownload
|
||||||
|
val visible = gateOpen && !st.uiDismissed && st.uiState != UpdateUiState.NONE
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
_root.visibility = View.GONE
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_root.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
when (st.uiState) {
|
||||||
|
UpdateUiState.AVAILABLE -> {
|
||||||
|
_textTitle.text = "Update available (v${st.uiVersion})"
|
||||||
|
_textBody.text = "A new Grayjay version is available."
|
||||||
|
_textBody.visibility = View.VISIBLE
|
||||||
|
_progressBar.visibility = View.GONE
|
||||||
|
_textAction.text = "Download"
|
||||||
|
_buttonAction.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
UpdateUiState.DOWNLOADING -> {
|
||||||
|
_textTitle.text = "Downloading update (v${st.uiVersion})"
|
||||||
|
if (st.uiIndeterminate) {
|
||||||
|
_textBody.text = "Starting download…"
|
||||||
|
_progressBar.isIndeterminate = true
|
||||||
|
} else {
|
||||||
|
_textBody.text = "${st.uiProgress}% downloaded"
|
||||||
|
_progressBar.isIndeterminate = false
|
||||||
|
_progressBar.progress = st.uiProgress
|
||||||
|
}
|
||||||
|
_textBody.visibility = View.VISIBLE
|
||||||
|
_progressBar.visibility = View.VISIBLE
|
||||||
|
_textAction.text = "Cancel"
|
||||||
|
_buttonAction.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
UpdateUiState.READY -> {
|
||||||
|
_textTitle.text = "Update v${st.uiVersion} ready"
|
||||||
|
_textBody.text = "Tap install to apply the update."
|
||||||
|
_textBody.visibility = View.VISIBLE
|
||||||
|
_progressBar.visibility = View.GONE
|
||||||
|
_textAction.text = "Install"
|
||||||
|
_buttonAction.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
UpdateUiState.FAILED -> {
|
||||||
|
_textTitle.text = "Update failed"
|
||||||
|
val err = st.uiError
|
||||||
|
_textBody.text = if (err.isNullOrBlank()) "Could not download v${st.uiVersion}." else err
|
||||||
|
_textBody.visibility = View.VISIBLE
|
||||||
|
_progressBar.visibility = View.GONE
|
||||||
|
_textAction.text = "Retry"
|
||||||
|
_buttonAction.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
UpdateUiState.NONE -> {
|
||||||
|
_root.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "UpdateBannerView"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import android.view.LayoutInflater
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.LinearLayout
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
@@ -119,6 +120,15 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
private val _control_duration_fullscreen: TextView;
|
private val _control_duration_fullscreen: TextView;
|
||||||
private val _control_pause_fullscreen: ImageButton;
|
private val _control_pause_fullscreen: ImageButton;
|
||||||
|
|
||||||
|
// LIVE pill: shown only when current media item is live; dot color reflects live-edge proximity.
|
||||||
|
private val _live_pill: LinearLayout
|
||||||
|
private val _live_pill_dot: View
|
||||||
|
private val _live_pill_fullscreen: LinearLayout
|
||||||
|
private val _live_pill_dot_fullscreen: View
|
||||||
|
private val _text_divider: TextView
|
||||||
|
private val _text_divider_fullscreen: TextView
|
||||||
|
private var _wasAtLiveEdge: Boolean = true
|
||||||
|
|
||||||
private val _title_fullscreen: TextView;
|
private val _title_fullscreen: TextView;
|
||||||
private val _author_fullscreen: TextView;
|
private val _author_fullscreen: TextView;
|
||||||
private var _shouldRestartHideJobOnPlaybackStateChange: Boolean = false;
|
private var _shouldRestartHideJobOnPlaybackStateChange: Boolean = false;
|
||||||
@@ -189,6 +199,9 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
_buttonPrevious = videoControls.findViewById(R.id.button_previous);
|
_buttonPrevious = videoControls.findViewById(R.id.button_previous);
|
||||||
_control_time = videoControls.findViewById(R.id.text_position);
|
_control_time = videoControls.findViewById(R.id.text_position);
|
||||||
_control_duration = videoControls.findViewById(R.id.text_duration);
|
_control_duration = videoControls.findViewById(R.id.text_duration);
|
||||||
|
_live_pill = videoControls.findViewById(R.id.live_pill_container)
|
||||||
|
_live_pill_dot = videoControls.findViewById(R.id.live_pill_dot)
|
||||||
|
_text_divider = videoControls.findViewById(R.id.text_divider)
|
||||||
|
|
||||||
_videoControls_fullscreen = findViewById(R.id.video_player_controller_fullscreen);
|
_videoControls_fullscreen = findViewById(R.id.video_player_controller_fullscreen);
|
||||||
_control_autoplay_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_autoplay);
|
_control_autoplay_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_autoplay);
|
||||||
@@ -206,6 +219,9 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
_control_time_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_position);
|
_control_time_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_position);
|
||||||
_control_duration_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_duration);
|
_control_duration_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_duration);
|
||||||
_control_pause_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_pause);
|
_control_pause_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_pause);
|
||||||
|
_live_pill_fullscreen = _videoControls_fullscreen.findViewById(R.id.live_pill_container)
|
||||||
|
_live_pill_dot_fullscreen = _videoControls_fullscreen.findViewById(R.id.live_pill_dot)
|
||||||
|
_text_divider_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_divider)
|
||||||
|
|
||||||
_loaderGame = findViewById(R.id.loader_overlay)
|
_loaderGame = findViewById(R.id.loader_overlay)
|
||||||
_loaderGame.visibility = View.GONE
|
_loaderGame.visibility = View.GONE
|
||||||
@@ -225,24 +241,26 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
_buttonNext.setOnClickListener { onNext.emit() };
|
_buttonNext.setOnClickListener { onNext.emit() };
|
||||||
_buttonPrevious_fullscreen.setOnClickListener { onPrevious.emit() };
|
_buttonPrevious_fullscreen.setOnClickListener { onPrevious.emit() };
|
||||||
_buttonNext_fullscreen.setOnClickListener { onNext.emit() };
|
_buttonNext_fullscreen.setOnClickListener { onNext.emit() };
|
||||||
_control_play.setOnClickListener {
|
val playClickHandler = View.OnClickListener {
|
||||||
exoPlayer?.player?.let {
|
// Order matters:
|
||||||
if (it.contentPosition >= it.duration) {
|
// 1. If the player is stuck (STATE_IDLE after error, STATE_ENDED on a slipped live
|
||||||
it.seekTo(0)
|
// window) plain play() is a no-op until we re-prepare. Recover first.
|
||||||
|
// 2. Otherwise, if a VOD has played to its end, rewind to start (replay).
|
||||||
|
// 3. Then start playback.
|
||||||
|
val recovered = recoverFromStuck()
|
||||||
|
if (!recovered) {
|
||||||
|
exoPlayer?.player?.let {
|
||||||
|
val dur = it.duration
|
||||||
|
if (dur > 0 && it.contentPosition >= dur) {
|
||||||
|
it.seekTo(0)
|
||||||
|
}
|
||||||
|
it.play()
|
||||||
}
|
}
|
||||||
exoPlayer?.player?.play();
|
|
||||||
}
|
}
|
||||||
updatePlayPause();
|
updatePlayPause()
|
||||||
};
|
}
|
||||||
_control_play_fullscreen.setOnClickListener {
|
_control_play.setOnClickListener(playClickHandler)
|
||||||
exoPlayer?.player?.let {
|
_control_play_fullscreen.setOnClickListener(playClickHandler)
|
||||||
if (it.contentPosition >= it.duration) {
|
|
||||||
it.seekTo(0)
|
|
||||||
}
|
|
||||||
exoPlayer?.player?.play();
|
|
||||||
}
|
|
||||||
updatePlayPause();
|
|
||||||
};
|
|
||||||
_control_pause.setOnClickListener {
|
_control_pause.setOnClickListener {
|
||||||
exoPlayer?.player?.pause();
|
exoPlayer?.player?.pause();
|
||||||
updatePlayPause();
|
updatePlayPause();
|
||||||
@@ -460,7 +478,17 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
updateAutoplayButton()
|
updateAutoplayButton()
|
||||||
|
|
||||||
val progressUpdateListener = { position: Long, bufferedPosition: Long ->
|
val progressUpdateListener = { position: Long, bufferedPosition: Long ->
|
||||||
val currentTime = position.formatDuration()
|
// For live streams that have been seeked behind, replace the running position with
|
||||||
|
// a -MM:SS "behind live" indicator (the videojs/HLS convention). At the live edge
|
||||||
|
// we keep showing the running position; this matches YouTube's web behaviour where
|
||||||
|
// the LIVE pill alone (red "caught up" / gray "behind") + a clear offset readout
|
||||||
|
// tell the whole story.
|
||||||
|
val behindMs = if (isLive) behindLiveMs else null
|
||||||
|
val currentTime = if (behindMs != null && behindMs > 0) {
|
||||||
|
"-" + behindMs.formatDuration()
|
||||||
|
} else {
|
||||||
|
position.formatDuration()
|
||||||
|
}
|
||||||
val currentDuration = duration.formatDuration()
|
val currentDuration = duration.formatDuration()
|
||||||
_control_time.text = currentTime;
|
_control_time.text = currentTime;
|
||||||
_control_time_fullscreen.text = currentTime;
|
_control_time_fullscreen.text = currentTime;
|
||||||
@@ -473,6 +501,12 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
_time_bar_fullscreen.setBufferedPosition(bufferedPosition);
|
_time_bar_fullscreen.setBufferedPosition(bufferedPosition);
|
||||||
_time_bar.setBufferedPosition(bufferedPosition);
|
_time_bar.setBufferedPosition(bufferedPosition);
|
||||||
|
|
||||||
|
// While live, refresh the LIVE pill's edge state so the dot reflects whether the user
|
||||||
|
// is at the live edge or seeked behind. Cheap and only updates when state actually changes.
|
||||||
|
if (isLive) {
|
||||||
|
updateLiveEdgeState()
|
||||||
|
}
|
||||||
|
|
||||||
onTimeBarChanged.emit(position, bufferedPosition);
|
onTimeBarChanged.emit(position, bufferedPosition);
|
||||||
|
|
||||||
if(!_currentChapterLoopActive)
|
if(!_currentChapterLoopActive)
|
||||||
@@ -499,6 +533,23 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toggle LIVE pill / time UI when the underlying media item changes liveness.
|
||||||
|
// The base class emits this on Timeline / MediaItem transitions.
|
||||||
|
onLiveChanged.subscribe { live ->
|
||||||
|
CoroutineScope(Dispatchers.Main).launch(Dispatchers.Main) {
|
||||||
|
applyLiveUI(live)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val jumpToLiveListener = View.OnClickListener {
|
||||||
|
seekToLiveEdge()
|
||||||
|
}
|
||||||
|
_live_pill.setOnClickListener(jumpToLiveListener)
|
||||||
|
_live_pill_fullscreen.setOnClickListener(jumpToLiveListener)
|
||||||
|
|
||||||
|
// Apply once at construction in case we attach to an already-live media item.
|
||||||
|
applyLiveUI(isLive)
|
||||||
|
|
||||||
updateLoopVideoUI();
|
updateLoopVideoUI();
|
||||||
|
|
||||||
if(!isInEditMode) {
|
if(!isInEditMode) {
|
||||||
@@ -895,6 +946,58 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies (or reverts) live-stream-specific control affordances:
|
||||||
|
* - shows/hides the LIVE pill
|
||||||
|
* - hides the duration text + divider when live (duration shown by the pill)
|
||||||
|
* - hides the loop button (looping a live stream is meaningless)
|
||||||
|
* - hides the chapter text (live streams from the source plugins do not provide chapters)
|
||||||
|
*
|
||||||
|
* Position text is kept visible because for HLS DVR streams it shows offset within the
|
||||||
|
* available seek window, which is useful information.
|
||||||
|
*/
|
||||||
|
private fun applyLiveUI(live: Boolean) {
|
||||||
|
val pillVis = if (live) View.VISIBLE else View.GONE
|
||||||
|
val timeVis = if (live) View.GONE else View.VISIBLE
|
||||||
|
_live_pill.visibility = pillVis
|
||||||
|
_live_pill_fullscreen.visibility = pillVis
|
||||||
|
_text_divider.visibility = timeVis
|
||||||
|
_text_divider_fullscreen.visibility = timeVis
|
||||||
|
_control_duration.visibility = timeVis
|
||||||
|
_control_duration_fullscreen.visibility = timeVis
|
||||||
|
|
||||||
|
if (live) {
|
||||||
|
// Loop / chapter UI is meaningless on live; hide and reset.
|
||||||
|
_control_loop.visibility = View.GONE
|
||||||
|
_control_loop_fullscreen.visibility = View.GONE
|
||||||
|
_control_chapter.visibility = View.GONE
|
||||||
|
_control_chapter_fullscreen.visibility = View.GONE
|
||||||
|
updateLiveEdgeState()
|
||||||
|
} else {
|
||||||
|
_control_loop.visibility = View.VISIBLE
|
||||||
|
_control_loop_fullscreen.visibility = View.VISIBLE
|
||||||
|
_control_chapter.visibility = View.VISIBLE
|
||||||
|
_control_chapter_fullscreen.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the LIVE pill's dot + background to reflect whether playback is at the live edge.
|
||||||
|
* Idempotent: only mutates when state changes to avoid invalidations on every progress tick.
|
||||||
|
*/
|
||||||
|
private fun updateLiveEdgeState() {
|
||||||
|
val atEdge = isAtLiveEdge
|
||||||
|
if (atEdge == _wasAtLiveEdge) return
|
||||||
|
_wasAtLiveEdge = atEdge
|
||||||
|
Logger.i(TAG, "LIVE pill -> ${if (atEdge) "AT EDGE" else "BEHIND"} (offset=${liveOffsetMs}ms target=${targetLiveOffsetMs}ms)")
|
||||||
|
val bg = if (atEdge) R.drawable.background_live_pill else R.drawable.background_live_pill_behind
|
||||||
|
val dot = if (atEdge) R.drawable.dot_live_edge else R.drawable.dot_live_behind
|
||||||
|
_live_pill.setBackgroundResource(bg)
|
||||||
|
_live_pill_fullscreen.setBackgroundResource(bg)
|
||||||
|
_live_pill_dot.setBackgroundResource(dot)
|
||||||
|
_live_pill_dot_fullscreen.setBackgroundResource(dot)
|
||||||
|
}
|
||||||
|
|
||||||
fun setGestureSoundFactor(soundFactor: Float) {
|
fun setGestureSoundFactor(soundFactor: Float) {
|
||||||
gestureControl.setSoundFactor(soundFactor);
|
gestureControl.setSoundFactor(soundFactor);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import androidx.media3.common.C
|
|||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.Timeline
|
||||||
import androidx.media3.common.VideoSize
|
import androidx.media3.common.VideoSize
|
||||||
import androidx.media3.common.text.CueGroup
|
import androidx.media3.common.text.CueGroup
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
@@ -129,6 +130,64 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
|||||||
val position: Long get() = exoPlayer?.player?.currentPosition ?: 0;
|
val position: Long get() = exoPlayer?.player?.currentPosition ?: 0;
|
||||||
val duration: Long get() = exoPlayer?.player?.duration ?: 0;
|
val duration: Long get() = exoPlayer?.player?.duration ?: 0;
|
||||||
|
|
||||||
|
/** True when the current media item is a live stream. */
|
||||||
|
val isLive: Boolean get() = exoPlayer?.player?.isCurrentMediaItemLive ?: false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live offset reported by the player in ms (ms behind live edge, 0 == at edge).
|
||||||
|
* Returns null when not live or when offset is unavailable.
|
||||||
|
*/
|
||||||
|
val liveOffsetMs: Long? get() {
|
||||||
|
val player = exoPlayer?.player ?: return null
|
||||||
|
if (!player.isCurrentMediaItemLive) return null
|
||||||
|
val offset = player.currentLiveOffset
|
||||||
|
return if (offset == C.TIME_UNSET) null else offset
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Target live offset (ms) the player wants to maintain behind the wall-clock edge.
|
||||||
|
* Comes from the manifest's [MediaItem.LiveConfiguration]; YouTube HLS typically reports
|
||||||
|
* 15-30s. Returns null when not live or when no target is configured.
|
||||||
|
*/
|
||||||
|
val targetLiveOffsetMs: Long? get() {
|
||||||
|
val player = exoPlayer?.player ?: return null
|
||||||
|
if (!player.isCurrentMediaItemLive) return null
|
||||||
|
val target = player.currentMediaItem?.liveConfiguration?.targetOffsetMs
|
||||||
|
?: return null
|
||||||
|
return if (target == C.TIME_UNSET) null else target
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the player is at the live edge from a user perspective: current offset is
|
||||||
|
* within [LIVE_EDGE_TOLERANCE_MS] of the manifest's target offset (or, if no target is
|
||||||
|
* known, within [LIVE_EDGE_FALLBACK_THRESHOLD_MS] of wall-clock).
|
||||||
|
*
|
||||||
|
* The naive "offset <= 5s" check fails for YouTube HLS, which sets target offsets of
|
||||||
|
* ~18-30s -- after [Player.seekToDefaultPosition] the player snaps to the target, not
|
||||||
|
* to wall clock, so a tighter threshold reports "behind" forever.
|
||||||
|
*/
|
||||||
|
val isAtLiveEdge: Boolean get() {
|
||||||
|
val offset = liveOffsetMs ?: return false
|
||||||
|
val target = targetLiveOffsetMs
|
||||||
|
return if (target != null) {
|
||||||
|
offset - target <= LIVE_EDGE_TOLERANCE_MS
|
||||||
|
} else {
|
||||||
|
offset <= LIVE_EDGE_FALLBACK_THRESHOLD_MS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the player is behind the live edge from a user perspective, in ms. Subtracts the
|
||||||
|
* manifest's natural live offset (or the [LIVE_EDGE_FALLBACK_THRESHOLD_MS] when unknown) so
|
||||||
|
* the value reflects the user-perceptible delay rather than the inherent HLS/DASH latency.
|
||||||
|
* Returns null when not live or the offset is unknown; returns 0 when at the live edge.
|
||||||
|
*/
|
||||||
|
val behindLiveMs: Long? get() {
|
||||||
|
val offset = liveOffsetMs ?: return null
|
||||||
|
val baseline = targetLiveOffsetMs ?: LIVE_EDGE_FALLBACK_THRESHOLD_MS
|
||||||
|
return (offset - baseline).coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
|
||||||
var isAudioMode: Boolean = false
|
var isAudioMode: Boolean = false
|
||||||
private set;
|
private set;
|
||||||
|
|
||||||
@@ -136,6 +195,8 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
|||||||
val onStateChange = Event1<Int>();
|
val onStateChange = Event1<Int>();
|
||||||
val onPositionDiscontinuity = Event1<Long>();
|
val onPositionDiscontinuity = Event1<Long>();
|
||||||
val onDatasourceError = Event1<Throwable>();
|
val onDatasourceError = Event1<Throwable>();
|
||||||
|
/** Emits when live state (live vs not) of the current media item changes. */
|
||||||
|
val onLiveChanged = Event1<Boolean>();
|
||||||
|
|
||||||
val onReloadRequired = Event0();
|
val onReloadRequired = Event0();
|
||||||
|
|
||||||
@@ -150,6 +211,21 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
|||||||
|
|
||||||
private var _toResume = false;
|
private var _toResume = false;
|
||||||
|
|
||||||
|
private var _wasLive: Boolean = false
|
||||||
|
/**
|
||||||
|
* Sticky 'live session' flag. Goes true when the player observes a live media item, and
|
||||||
|
* stays true through transient timeline-empty events (e.g. while a reload is in flight).
|
||||||
|
* Only cleared when the source actually changes (swapSourceInternal / clear). Without this,
|
||||||
|
* `isCurrentMediaItemLive` flips to false during a reload and the second error in a chain
|
||||||
|
* skips the live-recovery branch -- breaking the auto-reload retry sequence.
|
||||||
|
*/
|
||||||
|
private var _isLiveSession: Boolean = false
|
||||||
|
|
||||||
|
/** Timestamp (ms) of last live auto-reload, used to back off duplicate reloads. */
|
||||||
|
private var _lastLiveReloadAt_ms: Long = 0
|
||||||
|
/** Consecutive live auto-reload attempts; resets on successful playback. */
|
||||||
|
private var _liveReloadAttempts: Int = 0
|
||||||
|
|
||||||
private val _playerEventListener = object: Player.Listener {
|
private val _playerEventListener = object: Player.Listener {
|
||||||
override fun onPlaybackSuppressionReasonChanged(playbackSuppressionReason: Int) {
|
override fun onPlaybackSuppressionReasonChanged(playbackSuppressionReason: Int) {
|
||||||
super.onPlaybackSuppressionReasonChanged(playbackSuppressionReason)
|
super.onPlaybackSuppressionReasonChanged(playbackSuppressionReason)
|
||||||
@@ -217,6 +293,30 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
|||||||
Logger.i(TAG, "CUE GROUP: ${cueGroup.cues.firstOrNull()?.text}");
|
Logger.i(TAG, "CUE GROUP: ${cueGroup.cues.firstOrNull()?.text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||||
|
super.onTimelineChanged(timeline, reason)
|
||||||
|
checkLiveStateChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||||
|
super.onMediaItemTransition(mediaItem, reason)
|
||||||
|
checkLiveStateChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkLiveStateChanged() {
|
||||||
|
val nowLive = exoPlayer?.player?.isCurrentMediaItemLive ?: false
|
||||||
|
if (nowLive) {
|
||||||
|
// Sticky: any observation of a live item locks the session in until the source
|
||||||
|
// is replaced. Survives transient timeline-empty events during reloads.
|
||||||
|
_isLiveSession = true
|
||||||
|
}
|
||||||
|
if (nowLive != _wasLive) {
|
||||||
|
_wasLive = nowLive
|
||||||
|
Logger.i(TAG, "isCurrentMediaItemLive changed -> $nowLive (session=$_isLiveSession)")
|
||||||
|
onLiveChanged.emit(nowLive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onPlayerError(error: PlaybackException) {
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
super.onPlayerError(error);
|
super.onPlayerError(error);
|
||||||
this@FutoVideoPlayerBase.onPlayerError(error);
|
this@FutoVideoPlayerBase.onPlayerError(error);
|
||||||
@@ -315,6 +415,91 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
|||||||
exoPlayer?.player?.seekTo(Math.min(to, exoPlayer?.player?.duration ?: to));
|
exoPlayer?.player?.seekTo(Math.min(to, exoPlayer?.player?.duration ?: to));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeks to the live edge of the current dynamic window. No-op if not live.
|
||||||
|
* Uses [Player.seekToDefaultPosition] which targets the live edge in HLS/DASH dynamic windows.
|
||||||
|
*/
|
||||||
|
fun seekToLiveEdge() {
|
||||||
|
val player = exoPlayer?.player ?: return
|
||||||
|
if (!player.isCurrentMediaItemLive) return
|
||||||
|
Logger.i(TAG, "seekToLiveEdge (offset=${player.currentLiveOffset}ms)")
|
||||||
|
player.seekToDefaultPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recovers playback when the player is stuck in a non-recoverable state.
|
||||||
|
* Returns true if recovery was attempted (caller should not also call play()).
|
||||||
|
*
|
||||||
|
* STATE_IDLE happens after an unrecoverable error; STATE_ENDED happens on live when the
|
||||||
|
* window slips past the player. For both, plain play() is a no-op until we re-prepare; for
|
||||||
|
* live we additionally seek to the live edge so the user lands where YouTube's UI would.
|
||||||
|
*
|
||||||
|
* Non-live STATE_ENDED is *not* stuck -- it's the user pressing replay on a finished VOD --
|
||||||
|
* so we deliberately fall through to the caller, which seeks to 0 and plays. Without this
|
||||||
|
* guard the play button would re-prepare and seek to the saved end position, immediately
|
||||||
|
* re-entering STATE_ENDED, and the replay icon set by [setIsReplay] would never replay.
|
||||||
|
* We key off the sticky [_isLiveSession] rather than [Player.isCurrentMediaItemLive] because
|
||||||
|
* the latter can flip false during a live-window slip even though the user *is* watching live.
|
||||||
|
*/
|
||||||
|
fun recoverFromStuck(): Boolean {
|
||||||
|
val player = exoPlayer?.player ?: return false
|
||||||
|
val state = player.playbackState
|
||||||
|
if (state != Player.STATE_IDLE && state != Player.STATE_ENDED) return false
|
||||||
|
if (state == Player.STATE_ENDED && !_isLiveSession) return false
|
||||||
|
Logger.i(TAG, "recoverFromStuck state=$state isLive=${player.isCurrentMediaItemLive}")
|
||||||
|
// Reload the current source if available; preserves position via reloadMediaSource(resume=true)
|
||||||
|
// but for live we want to land at the edge.
|
||||||
|
if (_mediaSource != null) {
|
||||||
|
val wasLive = player.isCurrentMediaItemLive
|
||||||
|
reloadMediaSource(play = true, resume = !wasLive)
|
||||||
|
if (wasLive) {
|
||||||
|
exoPlayer?.player?.seekToDefaultPosition()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No media source cached yet; just re-prepare what's loaded.
|
||||||
|
player.prepare()
|
||||||
|
player.playWhenReady = true
|
||||||
|
if (player.isCurrentMediaItemLive) {
|
||||||
|
player.seekToDefaultPosition()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort auto-reload for a live stream after a transient HLS/IO error.
|
||||||
|
* Debounces consecutive reloads (min [LIVE_RELOAD_MIN_INTERVAL_MS] apart) and caps
|
||||||
|
* attempts at [LIVE_RELOAD_MAX_ATTEMPTS] before giving up so we don't spin on a
|
||||||
|
* permanently-broken source.
|
||||||
|
*
|
||||||
|
* Returns true if a reload was actually issued.
|
||||||
|
*/
|
||||||
|
private fun tryLiveAutoReload(reason: String): Boolean {
|
||||||
|
if (!_isLiveSession) return false
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val sinceLast = now - _lastLiveReloadAt_ms
|
||||||
|
if (sinceLast < LIVE_RELOAD_MIN_INTERVAL_MS) {
|
||||||
|
Logger.i(TAG, "tryLiveAutoReload skipped ($reason): last=${sinceLast}ms ago")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (_liveReloadAttempts >= LIVE_RELOAD_MAX_ATTEMPTS) {
|
||||||
|
Logger.w(TAG, "tryLiveAutoReload giving up ($reason): attempts=$_liveReloadAttempts")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_lastLiveReloadAt_ms = now
|
||||||
|
_liveReloadAttempts += 1
|
||||||
|
Logger.i(TAG, "tryLiveAutoReload ($reason): attempt=$_liveReloadAttempts")
|
||||||
|
if (_mediaSource != null) {
|
||||||
|
reloadMediaSource(play = true, resume = false)
|
||||||
|
exoPlayer?.player?.seekToDefaultPosition()
|
||||||
|
} else {
|
||||||
|
exoPlayer?.player?.prepare()
|
||||||
|
exoPlayer?.player?.playWhenReady = true
|
||||||
|
exoPlayer?.player?.seekToDefaultPosition()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
fun changePlayer(newPlayer: PlayerManager?) {
|
fun changePlayer(newPlayer: PlayerManager?) {
|
||||||
exoPlayer?.modifyState(exoPlayerStateName, {state -> state.listener = null});
|
exoPlayer?.modifyState(exoPlayerStateName, {state -> state.listener = null});
|
||||||
newPlayer?.modifyState(exoPlayerStateName, {state -> state.listener = _playerEventListener});
|
newPlayer?.modifyState(exoPlayerStateName, {state -> state.listener = _playerEventListener});
|
||||||
@@ -476,6 +661,12 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
|||||||
|
|
||||||
|
|
||||||
private fun swapSourceInternal(videoSource: IVideoSource?, play: Boolean, resume: Boolean): Boolean {
|
private fun swapSourceInternal(videoSource: IVideoSource?, play: Boolean, resume: Boolean): Boolean {
|
||||||
|
// The video source is what defines a playback session in this player. Audio/subtitle
|
||||||
|
// swaps within an existing session must NOT reset live-state, so the audio/subtitle
|
||||||
|
// overloads deliberately do not duplicate this block.
|
||||||
|
_isLiveSession = false
|
||||||
|
_liveReloadAttempts = 0
|
||||||
|
_lastLiveReloadAt_ms = 0
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
val swapId = _swapIdVideo.incrementAndGet()
|
val swapId = _swapIdVideo.incrementAndGet()
|
||||||
_lastGeneratedDash = null;
|
_lastGeneratedDash = null;
|
||||||
@@ -989,6 +1180,9 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
|||||||
_lastAudioMediaSource = null;
|
_lastAudioMediaSource = null;
|
||||||
_lastSubtitleMediaSource = null;
|
_lastSubtitleMediaSource = null;
|
||||||
_mediaSource = null;
|
_mediaSource = null;
|
||||||
|
_isLiveSession = false
|
||||||
|
_liveReloadAttempts = 0
|
||||||
|
_lastLiveReloadAt_ms = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop(){
|
fun stop(){
|
||||||
@@ -1013,18 +1207,34 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
|||||||
protected open fun onPlayerError(error: PlaybackException) {
|
protected open fun onPlayerError(error: PlaybackException) {
|
||||||
Logger.i(TAG, "onPlayerError error=$error error.errorCode=${error.errorCode} connectivityLoss, cause=${error.cause}");
|
Logger.i(TAG, "onPlayerError error=$error error.errorCode=${error.errorCode} connectivityLoss, cause=${error.cause}");
|
||||||
|
|
||||||
if(error is BehindLiveWindowException) {
|
// BehindLiveWindowException is wrapped as the *cause* of an ExoPlaybackException, so
|
||||||
|
// checking `error is BehindLiveWindowException` is always false (compiler warns). Use
|
||||||
|
// both the cause and the dedicated error code 1002 (ERROR_CODE_BEHIND_LIVE_WINDOW)
|
||||||
|
// so we recover whether the exception bubbled up wrapped or as an error code only.
|
||||||
|
if (error.cause is BehindLiveWindowException
|
||||||
|
|| error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW) {
|
||||||
Logger.e(TAG, "BehindLiveWindowException, " + error.message);
|
Logger.e(TAG, "BehindLiveWindowException, " + error.message);
|
||||||
reloadMediaSource(true, true);
|
reloadMediaSource(true, true);
|
||||||
|
exoPlayer?.player?.seekToDefaultPosition();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(error != null && error.cause is HlsPlaylistTracker.PlaylistStuckException) {
|
if(error != null && error.cause is HlsPlaylistTracker.PlaylistStuckException) {
|
||||||
Logger.e(TAG, "PlaylistStuckException");
|
Logger.e(TAG, "PlaylistStuckException");
|
||||||
reloadMediaSource(true, true);
|
reloadMediaSource(true, true);
|
||||||
|
exoPlayer?.player?.seekToDefaultPosition();
|
||||||
UIDialogs.toast("Live playback error, reloading..");
|
UIDialogs.toast("Live playback error, reloading..");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For live streams, transient HLS/IO errors usually mean a segment expired or
|
||||||
|
// the manifest tracker fell behind. Re-prepare and snap to the live edge so the
|
||||||
|
// user does not have to back out of the video to recover.
|
||||||
|
if (_isLiveSession && isLiveAutoRecoverableError(error)) {
|
||||||
|
if (tryLiveAutoReload("onPlayerError code=${error.errorCode}")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
when (error.errorCode) {
|
when (error.errorCode) {
|
||||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> {
|
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> {
|
||||||
Logger.w(TAG, "ERROR_CODE_IO_BAD_HTTP_STATUS ${error.cause?.javaClass?.simpleName}");
|
Logger.w(TAG, "ERROR_CODE_IO_BAD_HTTP_STATUS ${error.cause?.javaClass?.simpleName}");
|
||||||
@@ -1043,7 +1253,6 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
|||||||
//PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED,
|
//PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED,
|
||||||
//PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND,
|
//PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND,
|
||||||
//PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE,
|
//PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE,
|
||||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
|
|
||||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT,
|
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT,
|
||||||
//PlaybackException.ERROR_CODE_IO_NO_PERMISSION,
|
//PlaybackException.ERROR_CODE_IO_NO_PERMISSION,
|
||||||
//PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE,
|
//PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE,
|
||||||
@@ -1058,6 +1267,23 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a player error is the kind we should silently auto-reload on for live streams.
|
||||||
|
* Excludes hard failures (DRM, decoder, malformed) where reloading won't help.
|
||||||
|
*/
|
||||||
|
private fun isLiveAutoRecoverableError(error: PlaybackException): Boolean {
|
||||||
|
return when (error.errorCode) {
|
||||||
|
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
|
||||||
|
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT,
|
||||||
|
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS,
|
||||||
|
PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE,
|
||||||
|
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
|
||||||
|
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
||||||
|
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected open fun onVideoSizeChanged(videoSize: VideoSize) {
|
protected open fun onVideoSizeChanged(videoSize: VideoSize) {
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1074,6 +1300,17 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
|||||||
Logger.i(TAG, "_shouldPlaybackRestartOnConnectivity=false");
|
Logger.i(TAG, "_shouldPlaybackRestartOnConnectivity=false");
|
||||||
_shouldPlaybackRestartOnConnectivity = false;
|
_shouldPlaybackRestartOnConnectivity = false;
|
||||||
}
|
}
|
||||||
|
if (playbackState == ExoPlayer.STATE_READY) {
|
||||||
|
// Successful prepare cycle; reset the attempts counter so future hiccups get a
|
||||||
|
// fresh budget. The debounce timestamp does not need clearing -- a new error after
|
||||||
|
// any non-trivial play interval will be well past LIVE_RELOAD_MIN_INTERVAL_MS.
|
||||||
|
_liveReloadAttempts = 0
|
||||||
|
}
|
||||||
|
if (playbackState == ExoPlayer.STATE_ENDED && _isLiveSession) {
|
||||||
|
// A live stream that 'ends' from the player's perspective is almost always a window
|
||||||
|
// slip or stalled tracker. Try to silently rejoin at the live edge.
|
||||||
|
tryLiveAutoReload("STATE_ENDED on live")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected open fun setLoading(isLoading: Boolean) { }
|
protected open fun setLoading(isLoading: Boolean) { }
|
||||||
@@ -1092,6 +1329,21 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
|||||||
val PREFERED_AUDIO_CONTAINERS: Array<String> get() { return if(Settings.instance.playback.preferWebmAudio)
|
val PREFERED_AUDIO_CONTAINERS: Array<String> get() { return if(Settings.instance.playback.preferWebmAudio)
|
||||||
PREFERED_AUDIO_CONTAINERS_WEBMPref else PREFERED_AUDIO_CONTAINERS_MP4Pref }
|
PREFERED_AUDIO_CONTAINERS_WEBMPref else PREFERED_AUDIO_CONTAINERS_MP4Pref }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tolerance (ms) for being "at the live edge" relative to the manifest's target offset.
|
||||||
|
* Slack accounts for normal network jitter and the player drifting around the target.
|
||||||
|
*/
|
||||||
|
const val LIVE_EDGE_TOLERANCE_MS = 5_000L
|
||||||
|
/**
|
||||||
|
* Fallback threshold (ms) used when the manifest does not declare a target live offset:
|
||||||
|
* generous enough to cover typical HLS/DASH live latencies (15-30s).
|
||||||
|
*/
|
||||||
|
const val LIVE_EDGE_FALLBACK_THRESHOLD_MS = 45_000L
|
||||||
|
/** Min interval between live auto-reload attempts (debounce). */
|
||||||
|
const val LIVE_RELOAD_MIN_INTERVAL_MS = 3_000L
|
||||||
|
/** Max consecutive auto-reloads before giving up to avoid loops on broken sources. */
|
||||||
|
const val LIVE_RELOAD_MAX_ATTEMPTS = 5
|
||||||
|
|
||||||
val SUPPORTED_SUBTITLES = hashSetOf("text/vtt", "application/x-subrip");
|
val SUPPORTED_SUBTITLES = hashSetOf("text/vtt", "application/x-subrip");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- LIVE pill background: solid red when at the live edge. -->
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<solid android:color="#DDFF0000" />
|
||||||
|
<corners android:radius="3dp" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- LIVE pill background while behind the live edge: muted gray, signaling tap-to-jump. -->
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<solid android:color="#88555555" />
|
||||||
|
<corners android:radius="3dp" />
|
||||||
|
<stroke android:width="1dp" android:color="#FFAAAAAA" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Hollow / muted dot rendered while seeked behind the live edge. -->
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<solid android:color="#FFAAAAAA" />
|
||||||
|
<size android:width="6dp" android:height="6dp" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- White dot rendered on top of the red LIVE pill while at the edge. -->
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<solid android:color="#FFFFFFFF" />
|
||||||
|
<size android:width="6dp" android:height="6dp" />
|
||||||
|
</shape>
|
||||||
@@ -20,11 +20,20 @@
|
|||||||
app:layout_constraintRight_toRightOf="parent"
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
tools:layout="@layout/fragment_overview_top_bar" />
|
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
|
<androidx.fragment.app.FragmentContainerView
|
||||||
android:id="@+id/fragment_main"
|
android:id="@+id/fragment_main"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="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_constraintLeft_toLeftOf="parent"
|
||||||
app:layout_constraintRight_toRightOf="parent"
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
|||||||
@@ -195,6 +195,47 @@
|
|||||||
app:layout_constraintTop_toTopOf="@id/text_position"
|
app:layout_constraintTop_toTopOf="@id/text_position"
|
||||||
app:layout_constraintBottom_toBottomOf="@id/text_position"/>
|
app:layout_constraintBottom_toBottomOf="@id/text_position"/>
|
||||||
|
|
||||||
|
<!-- LIVE pill: hidden by default; shown by FutoVideoPlayer when isLive=true.
|
||||||
|
Constrained next to the duration text so it occupies the same row but does not
|
||||||
|
reflow when toggled (alpha/visibility only). Tap to jump to live edge. -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/live_pill_container"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:background="@drawable/background_live_pill"
|
||||||
|
android:paddingStart="6dp"
|
||||||
|
android:paddingEnd="6dp"
|
||||||
|
android:paddingTop="2dp"
|
||||||
|
android:paddingBottom="2dp"
|
||||||
|
android:layout_marginStart="6dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true"
|
||||||
|
android:contentDescription="@string/cd_button_jump_to_live"
|
||||||
|
app:layout_constraintLeft_toRightOf="@id/text_duration"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/text_position"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/text_position">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:id="@+id/live_pill_dot"
|
||||||
|
android:layout_width="6dp"
|
||||||
|
android:layout_height="6dp"
|
||||||
|
android:layout_marginEnd="4dp"
|
||||||
|
android:background="@drawable/dot_live_edge" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/live_pill_text"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:text="@string/live_capitalized"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/text_chapter_current"
|
android:id="@+id/text_chapter_current"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
@@ -204,7 +245,7 @@
|
|||||||
android:paddingRight="10dp"
|
android:paddingRight="10dp"
|
||||||
android:textSize="11sp"
|
android:textSize="11sp"
|
||||||
android:gravity="left"
|
android:gravity="left"
|
||||||
app:layout_constraintLeft_toRightOf="@id/text_duration"
|
app:layout_constraintLeft_toRightOf="@id/live_pill_container"
|
||||||
app:layout_constraintTop_toTopOf="@id/text_duration"
|
app:layout_constraintTop_toTopOf="@id/text_duration"
|
||||||
app:layout_constraintBottom_toBottomOf="@id/text_duration"
|
app:layout_constraintBottom_toBottomOf="@id/text_duration"
|
||||||
app:layout_constraintRight_toLeftOf="@id/button_fullscreen"
|
app:layout_constraintRight_toLeftOf="@id/button_fullscreen"
|
||||||
|
|||||||
@@ -225,6 +225,45 @@
|
|||||||
app:layout_constraintBottom_toBottomOf="@id/text_position"/>
|
app:layout_constraintBottom_toBottomOf="@id/text_position"/>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- LIVE pill: hidden by default; shown by FutoVideoPlayer when isLive=true. -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/live_pill_container"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:background="@drawable/background_live_pill"
|
||||||
|
android:paddingStart="6dp"
|
||||||
|
android:paddingEnd="6dp"
|
||||||
|
android:paddingTop="2dp"
|
||||||
|
android:paddingBottom="2dp"
|
||||||
|
android:layout_marginStart="6dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true"
|
||||||
|
android:contentDescription="@string/cd_button_jump_to_live"
|
||||||
|
app:layout_constraintLeft_toRightOf="@id/text_duration"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/text_position"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/text_position">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:id="@+id/live_pill_dot"
|
||||||
|
android:layout_width="6dp"
|
||||||
|
android:layout_height="6dp"
|
||||||
|
android:layout_marginEnd="4dp"
|
||||||
|
android:background="@drawable/dot_live_edge" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/live_pill_text"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:text="@string/live_capitalized"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/text_chapter_current"
|
android:id="@+id/text_chapter_current"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
@@ -234,7 +273,7 @@
|
|||||||
android:layout_marginTop="-2dp"
|
android:layout_marginTop="-2dp"
|
||||||
android:textSize="11sp"
|
android:textSize="11sp"
|
||||||
android:gravity="left"
|
android:gravity="left"
|
||||||
app:layout_constraintLeft_toRightOf="@id/text_duration"
|
app:layout_constraintLeft_toRightOf="@id/live_pill_container"
|
||||||
app:layout_constraintTop_toTopOf="@id/text_duration"
|
app:layout_constraintTop_toTopOf="@id/text_duration"
|
||||||
app:layout_constraintBottom_toBottomOf="@id/text_duration"
|
app:layout_constraintBottom_toBottomOf="@id/text_duration"
|
||||||
app:layout_constraintRight_toLeftOf="@id/button_fullscreen"
|
app:layout_constraintRight_toLeftOf="@id/button_fullscreen"
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout 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:id="@+id/root">
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/background_16_round_4dp"
|
||||||
|
android:paddingLeft="10dp"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingRight="10dp"
|
||||||
|
android:paddingBottom="10dp"
|
||||||
|
android:layout_margin="10dp">
|
||||||
|
|
||||||
|
<ImageView android:id="@+id/icon_update"
|
||||||
|
android:layout_width="20dp"
|
||||||
|
android:layout_height="20dp"
|
||||||
|
android:src="@drawable/ic_update"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/text_title"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/text_title" />
|
||||||
|
|
||||||
|
<TextView android:id="@+id/text_title"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
tools:text="Downloading update v123"
|
||||||
|
android:fontFamily="@font/inter_semibold"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
app:layout_constraintLeft_toRightOf="@id/icon_update"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintRight_toLeftOf="@id/button_close" />
|
||||||
|
|
||||||
|
<ImageView android:id="@+id/button_close"
|
||||||
|
android:layout_width="32dp"
|
||||||
|
android:layout_height="32dp"
|
||||||
|
android:padding="6dp"
|
||||||
|
android:src="@drawable/ic_close"
|
||||||
|
android:contentDescription="@string/dismiss"
|
||||||
|
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintRight_toRightOf="parent" />
|
||||||
|
|
||||||
|
<TextView android:id="@+id/text_body"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
tools:text="42% downloaded"
|
||||||
|
android:fontFamily="@font/inter_light"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="#9D9D9D"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintRight_toLeftOf="@id/button_close"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/text_title" />
|
||||||
|
|
||||||
|
<ProgressBar android:id="@+id/update_banner_progress"
|
||||||
|
style="?android:attr/progressBarStyleHorizontal"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:max="100"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/text_body" />
|
||||||
|
|
||||||
|
<FrameLayout android:id="@+id/button_action"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:background="@drawable/background_button_primary_round_4dp"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/update_banner_progress"
|
||||||
|
app:layout_constraintRight_toRightOf="parent">
|
||||||
|
|
||||||
|
<TextView android:id="@+id/text_action"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
tools:text="Install"
|
||||||
|
android:fontFamily="@font/inter_regular"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingBottom="10dp"
|
||||||
|
android:paddingLeft="20dp"
|
||||||
|
android:paddingRight="20dp" />
|
||||||
|
</FrameLayout>
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
</FrameLayout>
|
||||||
@@ -952,6 +952,7 @@
|
|||||||
<string name="cd_button_stop">Stop</string>
|
<string name="cd_button_stop">Stop</string>
|
||||||
<string name="cd_button_scan_qr">Scan QR code</string>
|
<string name="cd_button_scan_qr">Scan QR code</string>
|
||||||
<string name="cd_button_help">Help</string>
|
<string name="cd_button_help">Help</string>
|
||||||
|
<string name="cd_button_jump_to_live">Jump to live edge</string>
|
||||||
<string name="cd_image_polycentric">Change Polycentric profile picture</string>
|
<string name="cd_image_polycentric">Change Polycentric profile picture</string>
|
||||||
<string-array name="home_screen_array">
|
<string-array name="home_screen_array">
|
||||||
<item>Recommendations</item>
|
<item>Recommendations</item>
|
||||||
|
|||||||
Reference in New Issue
Block a user