Compare commits

..

13 Commits

Author SHA1 Message Date
Koen J b4e0356069 Merge branch 'pr/improve-live-functionality' of https://github.com/Titaniumtown/grayjay-android 2026-05-08 11:04:50 +02:00
Koen J c65cee86b1 Implemented feedback for update flow. 2026-05-07 22:53:26 +02:00
Simon Gardling 845c6b0031 Show offset behind live edge as -MM:SS in time display
When a live stream has been seeked behind, replace the running position
with a -MM:SS 'behind live' indicator (the videojs/HLS convention; matches
the offset readout commonly seen in third-party YouTube live tooling).
At the live edge we keep showing the running position.

The offset uses [behindLiveMs], which subtracts the manifest's natural
live offset (or LIVE_EDGE_FALLBACK_THRESHOLD_MS when the manifest doesn't
declare one) so the value reflects the user-perceptible delay rather
than the inherent ~25-30s HLS latency.

The 'show as behind' boundary always agrees with the 'pill turns gray'
boundary (both keyed on the same baseline), so the readout and the LIVE
pill cannot disagree.
2026-05-01 01:15:43 -04:00
Simon Gardling 985bd433a2 Add LIVE pill with at-edge indicator and tap-to-jump
LIVE pill rendered next to the time display (regular + fullscreen
layouts):

  - red filled with a white dot when at the live edge
  - gray bordered with a muted dot when behind
  - tap to jump to the live edge

While live, the duration text + divider, loop button, and chapter view
are hidden -- they're meaningless on a live stream and were just visual
noise. Position text stays visible since for HLS DVR streams it shows
offset within the available seek window.

Pill state updates in the existing PlayerControlView progress tick;
applyLiveUI() switches the surrounding UI on the onLiveChanged event.
At construction the current isLive value is applied once so that
attaching to an already-live media item starts in the right state.

The chapter view's left-constraint now chains through live_pill_container,
which collapses to width 0 when the pill is GONE on VOD -- so the chapter
view sits in the same place it always did when live is off.
2026-05-01 01:15:42 -04:00
Simon Gardling ed6270552b Recover stuck live playback when play is pressed
Adds recoverFromStuck() and rewires the play-button click handler to
call it before falling through to the normal play path.

When the player has dropped into STATE_IDLE after a fatal error or
STATE_ENDED on a slipped live window, plain play() is a no-op until we
re-prepare. recoverFromStuck() reloads the cached media source and, on
live, snaps to the live edge -- so pressing play recovers in place
instead of forcing the user to back out of the video and reopen it.

Non-live STATE_ENDED is left alone so the existing seek-to-0 replay
path that VideoDetailView's onSourceEnded handler primes via
setIsReplay(true) keeps working.

The play handler also tightens the existing replay rewind: the previous
'contentPosition >= duration' check would seekTo(0) on live streams
because Player.duration is C.TIME_UNSET (a large negative value); the
new 'dur > 0 && pos >= dur' guard skips that for live and only rewinds
finished VODs.
2026-05-01 01:15:42 -04:00
Simon Gardling 9639c2a167 Auto-reload live streams on transient errors
Adds a silent retry path for live HLS playback when the player raises a
transient I/O or parsing error: tryLiveAutoReload() reloads the source
and snaps to the live edge, with a 3s debounce and a 5-attempt cap so a
permanently broken source does not spin in a loop.

Triggered from:
  - onPlayerError, for the error codes a transient outage typically
    produces (IO_NETWORK_*, IO_BAD_HTTP_STATUS, IO_INVALID_HTTP_*,
    IO_UNSPECIFIED, PARSING_CONTAINER_MALFORMED, PARSING_MANIFEST_MALFORMED)
  - onPlaybackStateChanged, when STATE_ENDED hits on a live session
    (covers cases where the player cannot raise an error before the
    timeline empties)

On STATE_READY the attempts counter resets so future hiccups get a fresh
budget. The counters also reset on swapSourceInternal/clear so a previous
session's attempts cannot bleed into the next.

The dispatch is gated on the sticky _isLiveSession flag rather than the
dynamic isCurrentMediaItemLive: the latter flips false during the
transient empty timeline that a reload produces, which would otherwise
break the retry chain after the first attempt.
2026-05-01 01:15:42 -04:00
Simon Gardling b5caea6556 Expose live-stream state and edge detection
Foundational hooks for follow-up live-recovery and UI work; no behaviour
change on its own.

  - isLive: whether the current media item is live
  - liveOffsetMs: offset behind wall-clock live edge
  - targetLiveOffsetMs: manifest's intended offset (null if not declared)
  - isAtLiveEdge: target-aware boundary, with a 45s fallback for sources
    (e.g. YouTube HLS) that do not declare targetOffsetMs
  - seekToLiveEdge(): wraps Player.seekToDefaultPosition() for live items
  - onLiveChanged event, fired from onTimelineChanged/onMediaItemTransition
  - _isLiveSession sticky flag: stays true through the transient empty
    timeline the player goes through during a reload, so consumers can
    distinguish 'a live source is loaded' from the dynamic isLive bit

LIVE_EDGE_TOLERANCE_MS = 5s and LIVE_EDGE_FALLBACK_THRESHOLD_MS = 45s
are tuned to match what YouTube's HLS player reports (currentLiveOffset
sits at ~25-30s natively even at the edge, so a tighter threshold would
report 'behind' forever).
2026-05-01 01:15:42 -04:00
Simon Gardling 4bb09ec92e Fix BehindLiveWindowException recovery on live HLS
The 'error is BehindLiveWindowException' check in onPlayerError was
always false (Kotlin compiler had been warning about it). The actual
exception is wrapped as the *cause* of an ExoPlaybackException, with
errorCode 1002 (ERROR_CODE_BEHIND_LIVE_WINDOW). This made the existing
recovery branch dead code, so when the live window slipped past the
player it would silently drop into STATE_IDLE and the user had to back
out of the video and reopen it.

Test the cause and the error code so the branch actually fires, and
snap to the live edge with seekToDefaultPosition() after both the
BehindLiveWindow and PlaylistStuckException reloads so the user lands
where the player would naturally play.
2026-05-01 01:15:42 -04:00
Simon Gardling 7a6b185e9d Format negative durations correctly
formatDuration() previously emitted strings like '00:-49' for negative
values because the modulo operations propagated the sign. This shows up
on live streams that briefly report a negative position during reload,
and as the basis for a planned 'behind live edge' indicator.

Recurse on the absolute value when negative so we get a clean -MM:SS
(or -HH:MM:SS) format.
2026-05-01 01:15:42 -04:00
Koen cf3fc61f6a Merge branch 'request-modifiers' into 'master'
Improve request modifier support for casting and downloads

See merge request videostreaming/grayjay!171
2026-04-30 15:32:54 +00:00
Koen J d03019f0b7 Fixes for fdroid and playstore automation. 2026-04-30 12:24:50 +02:00
Koen J f1ce0078fd Switch to rel.grayjay.app 2026-04-30 11:03:44 +02:00
Stefan cd90497a59 Improve request modifier support for casting and downloads 2026-04-08 13:32:39 +01:00
29 changed files with 1102 additions and 276 deletions
+8 -1
View File
@@ -1,3 +1,6 @@
variables:
GIT_SUBMODULE_STRATEGY: recursive
buildAndDeployApkUnstable:
stage: build
script:
@@ -31,7 +34,7 @@ buildAndDeployPlaystore:
stage: deploy
script:
- sh build-playstore.sh
- bash tools/venv_playstore.sh
- bash venv-playstore.sh
- . .venv-playstore/bin/activate
- python publish_playstore.py --sa /root/grayjay.json --package com.futo.platformplayer.playstore --aab ./app/build/outputs/bundle/playstoreRelease/app-playstore-release.aab --track production --status completed
only:
@@ -53,5 +56,9 @@ updateFdroidRepo:
needs:
- job: buildAndDeployApkStable
artifacts: true
before_script:
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- touch ~/.ssh/known_hosts && chmod 644 ~/.ssh/known_hosts
- ssh-keygen -F gitlab.futo.org >/dev/null 2>&1 || ssh-keyscan -t rsa,ecdsa,ed25519 gitlab.futo.org >> ~/.ssh/known_hosts
script:
- python3 update_fdroid_index.py
@@ -251,6 +251,11 @@ fun String.fixHtmlWhitespace(): Spanned {
}
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 minutes = (this % 3600000) / 60000
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.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
import com.futo.platformplayer.downloads.VideoLocal
@@ -382,7 +383,8 @@ class UISlideOverlays {
val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
val masterPlaylistResponse = ManagedHttpClient().get(sourceUrl)
val modifier = if (source is JSSource && source.hasRequestModifier) source.getRequestModifier() else null
val masterPlaylistResponse = ManagedHttpClient().get(sourceUrl, HashMap(), modifier)
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
val masterPlaylistContent = masterPlaylistResponse.body?.string()
@@ -515,7 +517,7 @@ class UISlideOverlays {
slideUpMenuOverlay.onOK.subscribe {
//TODO: Fix SubtitleRawSource issue
StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null);
StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null, videoModifier = modifier, audioModifier = modifier);
slideUpMenuOverlay.hide()
}
@@ -526,11 +528,11 @@ class UISlideOverlays {
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
withContext(Dispatchers.Main) {
if (source is IHLSManifestSource) {
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedPlaylistUrl), null, null)
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedPlaylistUrl), null, null, videoModifier = modifier)
UIDialogs.toast(container.context, "Variant video HLS playlist download started")
slideUpMenuOverlay.hide()
} else if (source is IHLSManifestAudioSource) {
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, resolvedPlaylistUrl), null)
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, resolvedPlaylistUrl), null, audioModifier = modifier)
UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
slideUpMenuOverlay.hide()
} else {
@@ -5,50 +5,14 @@ import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.states.StateApp
import java.io.File
class UpdateActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
UpdateNotificationManager.ACTION_UPDATE_YES -> handleUpdateYes(context, intent)
UpdateNotificationManager.ACTION_UPDATE_NO -> handleUpdateNo(context)
UpdateNotificationManager.ACTION_UPDATE_NEVER -> handleUpdateNever(context)
UpdateNotificationManager.ACTION_DOWNLOAD_CANCEL -> handleDownloadCancel(context, intent)
}
}
private fun handleUpdateYes(context: Context, intent: Intent) {
AutoUpdateDialog.currentDialog?.dismiss()
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
if (version == 0) {
return
}
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
val serviceIntent = Intent(context, UpdateDownloadService::class.java).apply {
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
}
ContextCompat.startForegroundService(context, serviceIntent)
}
private fun handleUpdateNo(context: Context) {
AutoUpdateDialog.currentDialog?.dismiss()
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
}
private fun handleUpdateNever(context: Context) {
AutoUpdateDialog.currentDialog?.dismiss()
Settings.instance.autoUpdate.check = 1
Settings.instance.save()
UpdateNotificationManager.cancelAll(context)
}
private fun handleDownloadCancel(context: Context, intent: Intent) {
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
@@ -1,11 +1,12 @@
package com.futo.platformplayer
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -35,18 +36,16 @@ class UpdateCheckWorker(appContext: Context, workerParams: WorkerParameters) : C
return@withContext Result.success()
}
UpdateNotificationManager.showUpdateAvailableNotification(applicationContext, latestVersion)
StateUpdate.Companion.instance.setUiAvailable(latestVersion)
if (StateApp.instance.isMainActive) {
withContext(Dispatchers.Main) {
StateApp.withContext { ctx ->
try {
UIDialogs.showUpdateAvailableDialog(ctx, latestVersion, false)
} catch (t: Throwable) {
Logger.w(TAG, "Failed to show in-app update dialog from worker", t)
}
}
try {
val serviceIntent = Intent(applicationContext, UpdateDownloadService::class.java).apply {
putExtra(UpdateDownloadService.EXTRA_VERSION, latestVersion)
}
ContextCompat.startForegroundService(applicationContext, serviceIntent)
} catch (t: Throwable) {
Logger.w(TAG, "Failed to start UpdateDownloadService", t)
StateUpdate.Companion.instance.setUiFailed(latestVersion, t.message)
}
Result.success()
@@ -1,17 +1,15 @@
package com.futo.platformplayer
import android.app.Dialog
import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.os.SystemClock
import com.futo.platformplayer.UIDialogs.ActionStyle
import androidx.core.app.NotificationManagerCompat
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.SessionAnnouncement
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
import kotlinx.coroutines.*
import java.io.File
@@ -30,8 +28,6 @@ class UpdateDownloadService : Service() {
private const val INITIAL_BACKOFF_MS = 5_000L
private const val BUFFER_SIZE = 8 * 1024
private const val MIN_PROGRESS_UPDATE_INTERVAL_MS = 500L
var updateDownloadedDialog: Dialog? = null
}
private val job = SupervisorJob()
@@ -56,6 +52,7 @@ class UpdateDownloadService : Service() {
if (intent.getBooleanExtra(EXTRA_CANCEL, false)) {
cancelRequested = true
Logger.i(TAG, "Download cancel requested")
StateUpdate.Companion.instance.clearUi()
stopForeground(Service.STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
@@ -75,6 +72,10 @@ class UpdateDownloadService : Service() {
isDownloading = true
cancelRequested = false
StateUpdate.Companion.instance.setUiDownloading(version, 0, indeterminate = true)
NotificationManagerCompat.from(this).cancel(UpdateNotificationManager.NOTIF_ID_READY)
val notification = UpdateNotificationManager.buildDownloadProgressNotification(this, version, 0, true)
startForeground(UpdateNotificationManager.NOTIF_ID_DOWNLOADING, notification)
@@ -97,6 +98,7 @@ class UpdateDownloadService : Service() {
if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) {
lastProgressUpdateElapsedMs = now
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate);
StateUpdate.Companion.instance.setUiDownloading(version, progress, indeterminate)
if(onProgress != null)
onProgress.invoke(progress);
@@ -132,7 +134,7 @@ class UpdateDownloadService : Service() {
}
try {
performDownload(StateUpdate.APK_URL, partialFile, version, {
performDownload(StateUpdate.getApkUrl(version), partialFile, version, {
try {
if (announcement != null)
announcement?.setProgress(it);
@@ -159,6 +161,7 @@ class UpdateDownloadService : Service() {
if (attempt == MAX_RETRIES - 1) {
Logger.e(TAG, "Download failed after ${attempt + 1} attempts", t)
UpdateNotificationManager.showDownloadFailedNotification(this, version, t)
StateUpdate.Companion.instance.setUiFailed(version, t.message)
break
} else {
Logger.w(TAG, "Download attempt ${attempt + 1} failed, retrying in ${backoffMs / 1000}s", t)
@@ -264,39 +267,16 @@ class UpdateDownloadService : Service() {
private fun onDownloadComplete(version: Int, apkFile: File) {
Logger.i(TAG, "Download complete for version=$version, file=${apkFile.absolutePath}")
UpdateNotificationManager.showDownloadCompleteNotification(this, version, apkFile)
StateUpdate.Companion.instance.setUiReady(version, apkFile)
if (StateApp.instance.isMainActive) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
StateApp.withContext { ctx ->
try {
updateDownloadedDialog = UIDialogs.showDialog(ctx, R.drawable.foreground,
"Update downloaded",
"Would you like to install it now?", null, 0,
UIDialogs.Action("Not now", {
updateDownloadedDialog = null
}, ActionStyle.NONE, true),
UIDialogs.Action("Install", {
UpdateNotificationManager.cancelAll(ctx)
UpdateInstaller.startInstall(ctx, version, apkFile)
}, ActionStyle.PRIMARY, true));
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
}
}
try {
val ctx = applicationContext
StateAnnouncement.instance.registerAnnouncement("install-update-apk", "Grayjay v${version} is ready!", "You can now install the new Grayjay version.", AnnouncementType.SESSION, OffsetDateTime.now(), "update", "Install") {
UpdateNotificationManager.cancelAll(ctx)
UpdateInstaller.startInstall(ctx, version, apkFile)
}
} catch (ex: Throwable) {
Logger.w(TAG, "Failed to register install announcement", ex)
}
}
}
@@ -21,6 +21,7 @@ import java.io.InputStream
import androidx.core.net.toUri
import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
object UpdateInstaller {
private const val TAG = "UpdateInstaller"
@@ -61,6 +62,17 @@ object UpdateInstaller {
var inputStream: InputStream? = null
var session: PackageInstaller.Session? = null
try {
val dataLength = apkFile.length()
val usable = try { context.filesDir.usableSpace } catch (_: Throwable) { -1L }
if (usable in 0 until dataLength) {
val msg = "Not enough storage to install update. Need ${dataLength / 1_048_576L}MB, have ${usable / 1_048_576L}MB free."
Logger.w(TAG, msg)
withContext(Dispatchers.Main) {
UIDialogs.toast(context, msg)
}
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, msg)
return@launch
}
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
@@ -68,7 +80,6 @@ object UpdateInstaller {
session = packageInstaller.openSession(sessionId)
inputStream = apkFile.inputStream()
val dataLength = apkFile.length()
session.openWrite("package", 0, dataLength).use { sessionStream ->
inputStream.copyToOutputStream(dataLength, sessionStream) { _ -> }
@@ -91,11 +102,18 @@ object UpdateInstaller {
} catch (e: Throwable) {
Logger.w(TAG, "Exception while installing update", e)
session?.abandon()
val raw = e.message ?: ""
val friendly = if (raw.contains("Failed to allocate") || raw.contains("allocatable") || raw.contains("ENOSPC", ignoreCase = true)) {
"Not enough storage to install update. Free up some space and try again."
} else {
"Failed to install update: $raw"
}
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to install update: ${e.message}")
UIDialogs.toast(context, friendly)
}
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, e.message)
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, friendly)
} finally {
session?.close()
inputStream?.close()
@@ -110,10 +128,12 @@ object UpdateInstaller {
if (result.isNullOrEmpty()) {
Logger.i(TAG, "Update install finished successfully")
UpdateNotificationManager.showInstallSucceededNotification(context, version)
StateUpdate.instance.clearUi()
} else {
Logger.w(TAG, "Update install failed: $result")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, result)
UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n$result")
StateUpdate.instance.setUiReady(version, apkFile)
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle install result", e)
@@ -22,9 +22,6 @@ object UpdateNotificationManager {
private const val CHANNEL_NAME = "App updates"
private const val CHANNEL_DESCRIPTION = "Notifications about new app versions"
const val ACTION_UPDATE_YES = "com.futo.platformplayer.UPDATE_YES"
const val ACTION_UPDATE_NO = "com.futo.platformplayer.UPDATE_NO"
const val ACTION_UPDATE_NEVER = "com.futo.platformplayer.UPDATE_NEVER"
const val ACTION_DOWNLOAD_CANCEL = "com.futo.platformplayer.UPDATE_CANCEL"
const val ACTION_INSTALL_NOW = "com.futo.platformplayer.UPDATE_INSTALL"
private const val REQUEST_CODE_INSTALL = 1001
@@ -32,7 +29,6 @@ object UpdateNotificationManager {
const val EXTRA_VERSION = "version"
const val EXTRA_APK_PATH = "apk_path"
const val NOTIF_ID_AVAILABLE = 2001
const val NOTIF_ID_DOWNLOADING = 2002
const val NOTIF_ID_READY = 2003
const val NOTIF_ID_INSTALL_FAILED = 2004
@@ -84,43 +80,6 @@ object UpdateNotificationManager {
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_SUCCEEDED, builder.build())
}
fun showUpdateAvailableNotification(context: Context, version: Int) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
ensureChannel(context)
val yesIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_UPDATE_YES
putExtra(EXTRA_VERSION, version)
}
val yesPendingIntent = getBroadcast(context, 0, yesIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val noIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_UPDATE_NO
putExtra(EXTRA_VERSION, version)
}
val noPendingIntent = getBroadcast(context, 1, noIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val neverIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_UPDATE_NEVER
putExtra(EXTRA_VERSION, version)
}
val neverPendingIntent = getBroadcast(context, 2, neverIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Update available")
.setContentText("A new version ($version) is available.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setContentIntent(yesPendingIntent)
.setSilent(true)
.addAction(0, "Never", neverPendingIntent)
.addAction(0, "Not now", noPendingIntent)
.addAction(0, "Download", yesPendingIntent)
NotificationManagerCompat.from(context).notify(NOTIF_ID_AVAILABLE, builder.build())
}
fun buildDownloadProgressNotification(context: Context, version: Int, progress: Int, indeterminate: Boolean): Notification {
ensureChannel(context)
@@ -223,11 +182,9 @@ object UpdateNotificationManager {
}
fun cancelAll(context: Context) {
NotificationManagerCompat.from(context).cancel(NOTIF_ID_AVAILABLE)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_FAILED)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_SUCCEEDED)
}
}
@@ -23,6 +23,7 @@ import java.time.Duration
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
import kotlin.system.measureTimeMillis
open class ManagedHttpClient {
@@ -89,10 +90,16 @@ open class ManagedHttpClient {
return clonedClient;
}
fun tryHead(url: String): Map<String, String>? {
private fun applyModifier(url: String, headers: MutableMap<String, String>, modifier: IRequestModifier?): Pair<String, MutableMap<String, String>> {
if (modifier == null) return Pair(url, headers)
val modified = modifier.modifyRequest(url, headers)
return Pair(modified.url ?: url, modified.headers.toMutableMap())
}
fun tryHead(url: String, modifier: IRequestModifier? = null): Map<String, String>? {
ensureNotMainThread()
try {
val result = head(url);
val result = head(url, HashMap(), modifier);
if(result.isOk)
return result.getHeadersFlat();
else
@@ -141,12 +148,14 @@ open class ManagedHttpClient {
return Socket(websocket);
}
fun get(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
return execute(Request(url, "GET", null, headers));
fun get(url : String, headers : MutableMap<String, String> = HashMap<String, String>(), modifier: IRequestModifier? = null) : Response {
val (finalUrl, finalHeaders) = applyModifier(url, headers, modifier)
return execute(Request(finalUrl, "GET", null, finalHeaders));
}
fun head(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
return execute(Request(url, "HEAD", null, headers));
fun head(url : String, headers : MutableMap<String, String> = HashMap<String, String>(), modifier: IRequestModifier? = null) : Response {
val (finalUrl, finalHeaders) = applyModifier(url, headers, modifier)
return execute(Request(finalUrl, "HEAD", null, finalHeaders));
}
fun post(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
@@ -920,6 +920,7 @@ class StateCasting {
if (videoSource != null) {
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
.withIRequestModifier((videoSource as? JSSource)?.getRequestModifier())
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
@@ -927,6 +928,7 @@ class StateCasting {
if (audioSource != null) {
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
.withIRequestModifier((audioSource as? JSSource)?.getRequestModifier())
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
@@ -968,8 +970,7 @@ class StateCasting {
val headers = masterContext.headers.clone()
headers["Content-Type"] = "application/vnd.apple.mpegurl";
val req = requestModifier?.modifyRequest(sourceUrl, mapOf())
val masterPlaylistResponse = _client.get(req?.url ?: sourceUrl, (req?.headers ?: mapOf()).toMutableMap())
val masterPlaylistResponse = _client.get(sourceUrl, mutableMapOf(), requestModifier)
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
@@ -1022,7 +1023,7 @@ class StateCasting {
val vpHeaders = vpContext.headers.clone()
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
val response = _client.get(variantPlaylistRef.url)
val response = _client.get(variantPlaylistRef.url, mutableMapOf(), requestModifier)
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
val vpContent = response.body?.string()
@@ -1059,7 +1060,7 @@ class StateCasting {
val vpHeaders = vpContext.headers.clone()
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
val response = _client.get(mediaRendition.uri)
val response = _client.get(mediaRendition.uri, mutableMapOf(), requestModifier)
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
val vpContent = response.body?.string()
@@ -1190,6 +1191,7 @@ class StateCasting {
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
.withIRequestModifier((audioSource as? JSSource)?.getRequestModifier())
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
@@ -1267,6 +1269,7 @@ class StateCasting {
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
.withIRequestModifier((videoSource as? JSSource)?.getRequestModifier())
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
@@ -1350,6 +1353,7 @@ class StateCasting {
if (videoSource != null) {
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
.withIRequestModifier((videoSource as? JSSource)?.getRequestModifier())
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
@@ -1357,6 +1361,7 @@ class StateCasting {
if (audioSource != null) {
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
.withIRequestModifier((audioSource as? JSSource)?.getRequestModifier())
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
@@ -71,16 +71,14 @@ class Event0() : EventBase<(()->Unit), (()->Boolean)>() {
fun emit() : Boolean {
var handled = false;
synchronized(_conditionalListeners) {
for (conditional in _conditionalListeners)
handled = handled || conditional.handler.invoke();
}
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
for (conditional in condSnapshot)
handled = handled || conditional.handler.invoke();
synchronized(_listeners) {
handled = handled || _listeners.isNotEmpty();
for (handler in _listeners)
handler.handler.invoke();
}
val snapshot = synchronized(_listeners) { _listeners.toList() };
handled = handled || snapshot.isNotEmpty();
for (handler in snapshot)
handler.handler.invoke();
return handled;
}
@@ -88,16 +86,15 @@ class Event0() : EventBase<(()->Unit), (()->Boolean)>() {
class Event1<T1>() : EventBase<((T1)->Unit), ((T1)->Boolean)>() {
fun emit(value : T1): Boolean {
var handled = false;
synchronized(_conditionalListeners) {
for (conditional in _conditionalListeners)
handled = handled || conditional.handler.invoke(value);
}
synchronized(_listeners) {
handled = handled || _listeners.isNotEmpty();
for (handler in _listeners)
handler.handler.invoke(value);
}
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
for (conditional in condSnapshot)
handled = handled || conditional.handler.invoke(value);
val snapshot = synchronized(_listeners) { _listeners.toList() };
handled = handled || snapshot.isNotEmpty();
for (handler in snapshot)
handler.handler.invoke(value);
return handled;
}
@@ -106,16 +103,14 @@ class Event2<T1, T2>() : EventBase<((T1, T2)->Unit), ((T1, T2)->Boolean)>() {
fun emit(value1 : T1, value2 : T2): Boolean {
var handled = false;
synchronized(_conditionalListeners) {
for (conditional in _conditionalListeners)
handled = handled || conditional.handler.invoke(value1, value2);
}
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
for (conditional in condSnapshot)
handled = handled || conditional.handler.invoke(value1, value2);
synchronized(_listeners) {
handled = handled || _listeners.isNotEmpty();
for (handler in _listeners)
handler.handler.invoke(value1, value2);
}
val snapshot = synchronized(_listeners) { _listeners.toList() };
handled = handled || snapshot.isNotEmpty();
for (handler in snapshot)
handler.handler.invoke(value1, value2);
return handled;
}
@@ -125,16 +120,14 @@ class Event3<T1, T2, T3>() : EventBase<((T1, T2, T3)->Unit), ((T1, T2, T3)->Bool
fun emit(value1 : T1, value2 : T2, value3 : T3): Boolean {
var handled = false;
synchronized(_conditionalListeners) {
for (conditional in _conditionalListeners)
handled = handled || conditional.handler.invoke(value1, value2, value3);
}
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
for (conditional in condSnapshot)
handled = handled || conditional.handler.invoke(value1, value2, value3);
synchronized(_listeners) {
handled = handled || _listeners.isNotEmpty();
for (handler in _listeners)
handler.handler.invoke(value1, value2, value3);
}
val snapshot = synchronized(_listeners) { _listeners.toList() };
handled = handled || snapshot.isNotEmpty();
for (handler in snapshot)
handler.handler.invoke(value1, value2, value3);
return handled;
}
@@ -134,7 +134,7 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
var inputStream: InputStream? = null;
try {
val client = ManagedHttpClient();
val response = client.get(StateUpdate.APK_URL);
val response = client.get(StateUpdate.getApkUrl(_maxVersion));
if (response.isOk && response.body != null) {
inputStream = response.body.byteStream();
val dataLength = response.body.contentLength();
@@ -156,6 +156,18 @@ class VideoDownload {
var hasVideoRequestModifier: Boolean = false;
var hasAudioRequestModifier: Boolean = false;
// Transient: IRequestModifier is a runtime object from the JS plugin engine and cannot be
// serialized. After deserialization these are null - DownloadService must re-prepare to
// recapture them from the live plugin source (see needsReprepareForAuth).
@kotlinx.serialization.Transient
private var preparedVideoRequestModifier: IRequestModifier? = null;
@kotlinx.serialization.Transient
private var preparedAudioRequestModifier: IRequestModifier? = null;
val needsReprepareForAuth: Boolean get() =
(hasVideoRequestModifier && preparedVideoRequestModifier == null && videoSourceLive == null) ||
(hasAudioRequestModifier && preparedAudioRequestModifier == null && audioSourceLive == null);
var progress: Double = 0.0;
var isCancelled = false;
@@ -211,7 +223,7 @@ class VideoDownload {
this.requireAudioSource = targetBitrate != null; //TODO: May not be a valid check.. can only be determined after live fetch?
this.requiredCheck = optionalSources;
}
constructor(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?) {
constructor(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?, videoModifier: IRequestModifier? = null, audioModifier: IRequestModifier? = null) {
this.video = SerializedPlatformVideo.fromVideo(video);
this.videoDetails = SerializedPlatformVideoDetails.fromVideo(video, if (subtitleSource != null) listOf(subtitleSource) else listOf());
this.videoSource = if(videoSource is IVideoUrlSource) VideoUrlSource.fromUrlSource(videoSource) else null;
@@ -220,12 +232,22 @@ class VideoDownload {
this.audioSourceLive = if(audioSource is JSSource) audioSource else null;
this.subtitleSource = subtitleSource;
this.prepareTime = OffsetDateTime.now();
this.preparedVideoRequestModifier = videoModifier ?: (if (videoSource is JSSource && videoSource.hasRequestModifier) videoSource.getRequestModifier() else null);
this.preparedAudioRequestModifier = audioModifier ?: (if (audioSource is JSSource && audioSource.hasRequestModifier) audioSource.getRequestModifier() else null);
this.hasVideoRequestExecutor = videoSource is JSSource && videoSource.hasRequestExecutor;
this.hasAudioRequestExecutor = audioSource is JSSource && audioSource.hasRequestExecutor;
this.hasVideoRequestModifier = videoSource is JSSource && videoSource.hasRequestModifier;
this.hasAudioRequestModifier = audioSource is JSSource && audioSource.hasRequestModifier;
this.requiresLiveVideoSource = this.hasVideoRequestModifier || this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
this.requiresLiveAudioSource = this.hasAudioRequestModifier || this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
// Set modifier flags from either the source or an explicitly provided modifier
// (e.g. from the HLS picker, where the source is an HLSVariant, not JSSource).
// These flags are serialized and used by needsReprepareForAuth after restore.
this.hasVideoRequestModifier = preparedVideoRequestModifier != null;
this.hasAudioRequestModifier = preparedAudioRequestModifier != null;
// requiresLiveVideoSource means a live JSSource is needed at download time (for executors
// or DASH generation). Modifiers alone don't require a live source - they're already
// captured in preparedVideoRequestModifier and recaptured via needsReprepareForAuth.
val sourceHasVideoModifier = videoSource is JSSource && videoSource.hasRequestModifier;
val sourceHasAudioModifier = audioSource is JSSource && audioSource.hasRequestModifier;
this.requiresLiveVideoSource = sourceHasVideoModifier || this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
this.requiresLiveAudioSource = sourceHasAudioModifier || this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
this.targetVideoName = videoSource?.name;
this.targetAudioName = audioSource?.name;
this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null;
@@ -321,8 +343,10 @@ class VideoDownload {
val videoSources = arrayListOf<IVideoSource>()
for (source in original.video.videoSources) {
if (source is IHLSManifestSource) {
val sourceModifier = if (source is JSSource && source.hasRequestModifier) source.getRequestModifier() else null
if (sourceModifier != null) preparedVideoRequestModifier = sourceModifier
try {
val playlistResponse = client.get(source.url)
val playlistResponse = client.get(source.url, HashMap(), sourceModifier)
if (playlistResponse.isOk) {
val resolvedPlaylistUrl = playlistResponse.url
val playlistContent = playlistResponse.body?.string()
@@ -349,6 +373,8 @@ class VideoDownload {
if(vsource is JSSource) {
this.hasVideoRequestExecutor = this.hasVideoRequestExecutor || vsource.hasRequestExecutor;
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (vsource is JSDashManifestRawSource && vsource.hasGenerate);
if (vsource.hasRequestModifier && preparedVideoRequestModifier == null)
preparedVideoRequestModifier = vsource.getRequestModifier()
}
if(vsource == null) {
@@ -370,8 +396,10 @@ class VideoDownload {
if (video is VideoUnMuxedSourceDescriptor) {
for (source in video.audioSources) {
if (source is IHLSManifestAudioSource) {
val sourceModifier = if (source is JSSource && source.hasRequestModifier) source.getRequestModifier() else null
if (sourceModifier != null) preparedAudioRequestModifier = sourceModifier
try {
val playlistResponse = client.get(source.url)
val playlistResponse = client.get(source.url, HashMap(), sourceModifier)
if (playlistResponse.isOk) {
val resolvedPlaylistUrl = playlistResponse.url
val playlistContent = playlistResponse.body?.string()
@@ -406,6 +434,8 @@ class VideoDownload {
if(asource is JSSource) {
this.hasAudioRequestExecutor = this.hasAudioRequestExecutor || asource.hasRequestExecutor;
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (asource is JSDashManifestRawSource && asource.hasGenerate);
if (asource.hasRequestModifier && preparedAudioRequestModifier == null)
preparedAudioRequestModifier = asource.getRequestModifier()
}
if(asource == null) {
@@ -502,10 +532,16 @@ class VideoDownload {
}
}
val videoModifier = preparedVideoRequestModifier
if(actualVideoSource is IVideoUrlSource)
videoFileSize = when (videoSource!!.container) {
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
else -> downloadFileSource("Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
"application/vnd.apple.mpegurl" -> {
// HLS segments are concatenated into an MP4 file during download,
// so override the container for local playback/casting
videoOverrideContainer = "video/mp4";
downloadHlsSource(context, "Video", client, videoModifier, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
}
else -> downloadFileSource("Video", client, videoModifier, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
}
else if(actualVideoSource is JSDashManifestRawSource) {
if(actualAudioSource == null)
@@ -546,10 +582,16 @@ class VideoDownload {
}
}
val audioModifier = preparedAudioRequestModifier
if(actualAudioSource is IAudioUrlSource)
audioFileSize = when (audioSource!!.container) {
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
else -> downloadFileSource("Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
"application/vnd.apple.mpegurl" -> {
// HLS segments are concatenated into an MP4 file during download,
// so override the container for local playback/casting
audioOverrideContainer = "audio/mp4";
downloadHlsSource(context, "Audio", client, audioModifier, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
}
else -> downloadFileSource("Audio", client, audioModifier, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
}
else if(actualAudioSource is JSDashManifestRawAudioSource) {
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback, 2);
@@ -663,15 +705,11 @@ class VideoDownload {
}
}
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, source: JSSource?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, modifier: IRequestModifier?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if (targetFile.exists())
targetFile.delete()
var downloadedTotalLength = 0L
val modifier = if (source is JSSource && source.hasRequestModifier)
source.getRequestModifier()
else
null
fun downloadBytes(url: String, rangeStart: Long? = null, rangeLength: Long? = null): ByteArray {
val headers = mutableMapOf<String, String>()
@@ -685,17 +723,13 @@ class VideoDownload {
}
}
val modified = modifier?.modifyRequest(url, headers)
val finalUrl = modified?.url ?: url
val finalHeaders = modified?.headers?.toMutableMap() ?: headers
val resp = client.get(finalUrl, finalHeaders)
val resp = client.get(url, headers, modifier)
if (!resp.isOk) {
resp.body?.close()
throw IllegalStateException("Failed to download HLS resource ($finalUrl): HTTP ${resp.code}")
throw IllegalStateException("Failed to download HLS resource ($url): HTTP ${resp.code}")
}
val body = resp.body ?: throw IllegalStateException("Failed to download HLS resource ($finalUrl): Empty body")
val body = resp.body ?: throw IllegalStateException("Failed to download HLS resource ($url): Empty body")
val bytes = body.bytes()
body.close()
return bytes
@@ -710,12 +744,7 @@ class VideoDownload {
val segmentFiles = arrayListOf<File>()
try {
val playlistHeaders = mutableMapOf<String, String>()
val modifiedPlaylistReq = modifier?.modifyRequest(hlsUrl, playlistHeaders)
val playlistResp = client.get(
modifiedPlaylistReq?.url ?: hlsUrl,
modifiedPlaylistReq?.headers?.toMutableMap() ?: playlistHeaders
)
val playlistResp = client.get(hlsUrl, mutableMapOf(), modifier)
check(playlistResp.isOk) { "Failed to get variant playlist: ${playlistResp.code}" }
@@ -964,16 +993,7 @@ class VideoDownload {
Logger.i(TAG, "Downloading cue ${indexCounter}")
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
val modified = modifier?.modifyRequest(url, mapOf());
val data = if(executor != null)
executor.executeRequest("GET", modified?.url ?: url, null, modified?.headers ?: mapOf());
else {
val resp = client.get(modified?.url ?: url, modified?.headers?.toMutableMap() ?: mutableMapOf());
if(!resp.isOk)
throw IllegalStateException("Dash request failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
resp.body!!.bytes()
}
val data = executeOrGet(client, executor, modifier, url)
fileStream.write(data, 0, data.size);
speedTracker.addWork(data.size.toLong());
written += data.size;
@@ -993,16 +1013,7 @@ class VideoDownload {
val t2 = cue2.groupValues[1];
val d2 = cue2.groupValues[2];
val url2 = foundTemplateUrl2!!.replace("\$Number\$", (index2).toString());
val modified2 = modifier?.modifyRequest(url, mapOf());
val data = if(executor != null)
executor.executeRequest("GET", modified2?.url ?: url2, null, modified2?.headers ?: mapOf());
else {
val resp = client.get(modified2?.url ?: url, modified2?.headers?.toMutableMap() ?: mutableMapOf());
if(!resp.isOk)
throw IllegalStateException("Dash request2 failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
resp.body!!.bytes()
}
val data = executeOrGet(client, executor, modifier, url2)
fileStream2.write(data, 0, data.size);
speedTracker.addWork(data.size.toLong());
written2 += data.size;
@@ -1071,7 +1082,7 @@ class VideoDownload {
}
}
private fun downloadFileSource(name: String, client: ManagedHttpClient, source: JSSource?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
private fun downloadFileSource(name: String, client: ManagedHttpClient, modifier: IRequestModifier?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists())
targetFile.delete();
@@ -1080,13 +1091,8 @@ class VideoDownload {
val sourceLength: Long?;
val fileStream = FileOutputStream(targetFile);
val modifier = if (source is JSSource && source.hasRequestModifier)
source.getRequestModifier();
else
null;
try {
val head = client.tryHead(videoUrl);
val head = client.tryHead(videoUrl, modifier);
val relatedPlugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null };
if(Settings.instance.downloads.byteRangeDownload && head?.containsKey("accept-ranges") == true && head.containsKey("content-length"))
{
@@ -1161,12 +1167,7 @@ class VideoDownload {
var lastSpeed: Long = 0;
val result = if (modifier != null) {
val modified = modifier.modifyRequest(url, mapOf())
client.get(modified.url!!, modified.headers.toMutableMap())
} else {
client.get(url)
}
val result = client.get(url, HashMap(), modifier)
if (!result.isOk) {
result.body?.close()
throw IllegalStateException("Failed to download source. Web[${result.code}] Error");
@@ -1379,13 +1380,12 @@ class VideoDownload {
var lastException: Throwable? = null;
val headers = mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}"));
val modified = modifier?.modifyRequest(url, headers);
while (retryCount <= 3) {
try {
val toRead = rangeEnd - rangeStart;
val req = client.get(modified?.url ?: url, modified?.headers?.toMutableMap() ?: headers);
val req = client.get(url, headers.toMutableMap(), modifier);
if (!req.isOk) {
val bodyString = req.body?.string()
req.body?.close()
@@ -1519,6 +1519,18 @@ class VideoDownload {
}
}
private fun executeOrGet(client: ManagedHttpClient, executor: JSRequestExecutor?, modifier: IRequestModifier?, url: String, headers: Map<String, String> = mapOf()): ByteArray {
if (executor != null) {
val modified = modifier?.modifyRequest(url, headers)
return executor.executeRequest("GET", modified?.url ?: url, null, modified?.headers ?: headers)
} else {
val resp = client.get(url, headers.toMutableMap(), modifier)
if (!resp.isOk)
throw IllegalStateException("Request failed for ($url) with code: ${resp.code}")
return resp.body!!.bytes()
}
}
companion object {
const val TAG = "VideoDownload";
const val GROUP_PLAYLIST = "Playlist";
@@ -238,6 +238,16 @@ class DownloadService : Service() {
download.targetBitrate = download.audioSource!!.bitrate.toLong();
download.audioSource = null;
}
// Force re-prepare if auth modifiers are needed but lost (e.g. after deserialization,
// since IRequestModifier is transient and cannot survive serialization).
// Must also clear sources so prepare() enters the source selection branches where
// modifiers are recaptured from the live plugin JSSource.
if(download.needsReprepareForAuth) {
Logger.w(TAG, "Video Download [${download.name}] needs re-prepare for auth modifiers");
download.videoDetails = null;
download.videoSource = null;
download.audioSource = null;
}
if(download.videoDetails == null || (!download.isVideoDownloadReady || !download.isAudioDownloadReady))
download.changeState(VideoDownload.State.PREPARING);
notifyDownload(download);
@@ -668,6 +668,9 @@ class StateApp {
if (Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate Background]");
scopeOrNull?.launch(Dispatchers.IO) {
StateUpdate.instance.seedUiFromDisk(context)
}
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build();
@@ -10,6 +10,7 @@ import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
@@ -341,8 +342,8 @@ class StateDownloads {
fun download(video: IPlatformVideo, targetPixelcount: Long?, targetBitrate: Long?) {
download(VideoDownload(video, targetPixelcount, targetBitrate));
}
fun download(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?) {
download(VideoDownload(video, videoSource, audioSource, subtitleSource));
fun download(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?, videoModifier: IRequestModifier? = null, audioModifier: IRequestModifier? = null) {
download(VideoDownload(video, videoSource, audioSource, subtitleSource, videoModifier, audioModifier));
}
private fun download(videoState: VideoDownload, notify: Boolean = true) {
@@ -5,6 +5,7 @@ import android.os.Build
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.copyToOutputStream
import com.futo.platformplayer.logging.Logger
import kotlinx.coroutines.Dispatchers
@@ -14,7 +15,113 @@ import java.io.File
import java.io.InputStream
import java.io.OutputStream
enum class UpdateUiState { NONE, AVAILABLE, DOWNLOADING, READY, FAILED }
class StateUpdate {
@Volatile var uiState: UpdateUiState = UpdateUiState.NONE
private set
@Volatile var uiVersion: Int = 0
private set
@Volatile var uiProgress: Int = 0
private set
@Volatile var uiIndeterminate: Boolean = true
private set
@Volatile var uiApkFile: File? = null
private set
@Volatile var uiError: String? = null
private set
@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) {
try {
val client = ManagedHttpClient();
@@ -97,16 +204,16 @@ class StateUpdate {
throw Exception("App is not compatible. Supported ABIS: ${Build.SUPPORTED_ABIS.joinToString()}}.");
};
val VERSION_URL = if (BuildConfig.IS_UNSTABLE_BUILD) {
"https://releases.grayjay.app/version-unstable.txt"
"https://rel.grayjay.app/version-unstable.txt"
} else {
"https://releases.grayjay.app/version.txt"
"https://rel.grayjay.app/version.txt"
}
val APK_URL = if (BuildConfig.IS_UNSTABLE_BUILD) {
"https://releases.grayjay.app/app-$DESIRED_ABI-release-unstable.apk"
fun getApkUrl(version: Int): String = if (BuildConfig.IS_UNSTABLE_BUILD) {
"https://rel.grayjay.app/$version/app-$DESIRED_ABI-release-unstable.apk"
} else {
"https://releases.grayjay.app/app-$DESIRED_ABI-release.apk"
"https://rel.grayjay.app/$version/app-$DESIRED_ABI-release.apk"
}
val CHANGELOG_BASE_URL = "https://releases.grayjay.app/changelogs";
val CHANGELOG_BASE_URL = "https://rel.grayjay.app/changelogs";
fun getApkFile(context: Context, version: Int): File {
val dir = File(context.filesDir, "updates");
@@ -0,0 +1,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.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ImageButton
import android.widget.TextView
import androidx.annotation.OptIn
@@ -119,6 +120,15 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
private val _control_duration_fullscreen: TextView;
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 _author_fullscreen: TextView;
private var _shouldRestartHideJobOnPlaybackStateChange: Boolean = false;
@@ -189,6 +199,9 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
_buttonPrevious = videoControls.findViewById(R.id.button_previous);
_control_time = videoControls.findViewById(R.id.text_position);
_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);
_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_duration_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_duration);
_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.visibility = View.GONE
@@ -225,24 +241,26 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
_buttonNext.setOnClickListener { onNext.emit() };
_buttonPrevious_fullscreen.setOnClickListener { onPrevious.emit() };
_buttonNext_fullscreen.setOnClickListener { onNext.emit() };
_control_play.setOnClickListener {
exoPlayer?.player?.let {
if (it.contentPosition >= it.duration) {
it.seekTo(0)
val playClickHandler = View.OnClickListener {
// Order matters:
// 1. If the player is stuck (STATE_IDLE after error, STATE_ENDED on a slipped live
// 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();
};
_control_play_fullscreen.setOnClickListener {
exoPlayer?.player?.let {
if (it.contentPosition >= it.duration) {
it.seekTo(0)
}
exoPlayer?.player?.play();
}
updatePlayPause();
};
updatePlayPause()
}
_control_play.setOnClickListener(playClickHandler)
_control_play_fullscreen.setOnClickListener(playClickHandler)
_control_pause.setOnClickListener {
exoPlayer?.player?.pause();
updatePlayPause();
@@ -460,7 +478,17 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
updateAutoplayButton()
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()
_control_time.text = currentTime;
_control_time_fullscreen.text = currentTime;
@@ -473,6 +501,12 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
_time_bar_fullscreen.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);
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();
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) {
gestureControl.setSoundFactor(soundFactor);
}
@@ -17,6 +17,7 @@ import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.common.VideoSize
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.UnstableApi
@@ -129,6 +130,64 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
val position: Long get() = exoPlayer?.player?.currentPosition ?: 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
private set;
@@ -136,6 +195,8 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
val onStateChange = Event1<Int>();
val onPositionDiscontinuity = Event1<Long>();
val onDatasourceError = Event1<Throwable>();
/** Emits when live state (live vs not) of the current media item changes. */
val onLiveChanged = Event1<Boolean>();
val onReloadRequired = Event0();
@@ -150,6 +211,21 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
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 {
override fun onPlaybackSuppressionReasonChanged(playbackSuppressionReason: Int) {
super.onPlaybackSuppressionReasonChanged(playbackSuppressionReason)
@@ -217,6 +293,30 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
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) {
super.onPlayerError(error);
this@FutoVideoPlayerBase.onPlayerError(error);
@@ -315,6 +415,91 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
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?) {
exoPlayer?.modifyState(exoPlayerStateName, {state -> state.listener = null});
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 {
// 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)
val swapId = _swapIdVideo.incrementAndGet()
_lastGeneratedDash = null;
@@ -989,6 +1180,9 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
_lastAudioMediaSource = null;
_lastSubtitleMediaSource = null;
_mediaSource = null;
_isLiveSession = false
_liveReloadAttempts = 0
_lastLiveReloadAt_ms = 0
}
fun stop(){
@@ -1013,18 +1207,34 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
protected open fun onPlayerError(error: PlaybackException) {
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);
reloadMediaSource(true, true);
exoPlayer?.player?.seekToDefaultPosition();
return;
}
if(error != null && error.cause is HlsPlaylistTracker.PlaylistStuckException) {
Logger.e(TAG, "PlaylistStuckException");
reloadMediaSource(true, true);
exoPlayer?.player?.seekToDefaultPosition();
UIDialogs.toast("Live playback error, reloading..");
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) {
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}");
@@ -1043,7 +1253,6 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
//PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED,
//PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND,
//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_NO_PERMISSION,
//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) {
}
@@ -1074,6 +1300,17 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
Logger.i(TAG, "_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) { }
@@ -1092,6 +1329,21 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
val PREFERED_AUDIO_CONTAINERS: Array<String> get() { return if(Settings.instance.playback.preferWebmAudio)
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");
}
}
@@ -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>
+10 -1
View File
@@ -20,11 +20,20 @@
app:layout_constraintRight_toRightOf="parent"
tools:layout="@layout/fragment_overview_top_bar" />
<com.futo.platformplayer.views.announcements.UpdateBannerView
android:id="@+id/update_banner"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintTop_toBottomOf="@id/fragment_top_bar"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/fragment_main"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/fragment_top_bar"
app:layout_constraintTop_toBottomOf="@id/update_banner"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
+42 -1
View File
@@ -195,6 +195,47 @@
app:layout_constraintTop_toTopOf="@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
android:id="@+id/text_chapter_current"
android:layout_width="0dp"
@@ -204,7 +245,7 @@
android:paddingRight="10dp"
android:textSize="11sp"
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_constraintBottom_toBottomOf="@id/text_duration"
app:layout_constraintRight_toLeftOf="@id/button_fullscreen"
@@ -225,6 +225,45 @@
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
android:id="@+id/text_chapter_current"
android:layout_width="0dp"
@@ -234,7 +273,7 @@
android:layout_marginTop="-2dp"
android:textSize="11sp"
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_constraintBottom_toBottomOf="@id/text_duration"
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>
+1
View File
@@ -952,6 +952,7 @@
<string name="cd_button_stop">Stop</string>
<string name="cd_button_scan_qr">Scan QR code</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-array name="home_screen_array">
<item>Recommendations</item>