Compare commits

..

12 Commits

Author SHA1 Message Date
Koen J 620d4f8fb7 Last 4 seconds cut off from UMP downloads fix #2243. 2026-05-27 16:08:06 +02:00
Koen J 9c0c8fe927 Implemented performance optimization for composite trust 2026-05-27 14:37:30 +02:00
Koen J 1263070fc9 Added setting to use downloaded CA bundle also for OkHttp 2026-05-27 14:13:37 +02:00
Koen J 336f30a631 Work for diagnosing #3279 2026-05-27 12:48:21 +02:00
Koen J 9ec4b76d5e Potentail fixes for #3129 2026-05-27 10:55:33 +02:00
koen-futo 91bea8faf2 Merge pull request #3301 from GRBaset/master
Persist subtitle setting across restarts with optional fallback to first found
2026-05-27 10:30:20 +02:00
GRBaset 1d6f2d2ff7 Persist subtitle language to storage 2026-05-23 18:32:35 +02:00
Koen 32686215c4 Merge branch 'new-plugin-nasa-plus' into 'master'
Add NASA+ plugin

See merge request videostreaming/grayjay!174
2026-05-20 10:31:51 +00:00
Stefan f226669b77 Add NASA+ plugin 2026-05-20 11:26:24 +01:00
Koen J a792dea4c5 Build fix. 2026-05-08 17:50:30 +02:00
Koen J a7fc549afb Automatic updates for plugins defaults to true and made the loading bar smaller. 2026-05-08 17:40:57 +02:00
Koen J b345ba5ca3 Updated submodules. 2026-05-08 16:12:50 +02:00
63 changed files with 281 additions and 650 deletions
+6
View File
@@ -124,3 +124,9 @@
[submodule "app/src/stable/assets/sources/fosdem"]
path = app/src/stable/assets/sources/fosdem
url = ../plugins/fosdem.git
[submodule "app/src/unstable/assets/sources/nasa-plus"]
path = app/src/unstable/assets/sources/nasa-plus
url = ../plugins/nasa-plus.git
[submodule "app/src/stable/assets/sources/nasa-plus"]
path = app/src/stable/assets/sources/nasa-plus
url = ../plugins/nasa-plus.git
+1 -1
View File
@@ -60,7 +60,7 @@
<activity
android:name=".activities.MainActivity"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|smallestScreenSize|screenLayout|uiMode|density|fontScale"
android:exported="true"
android:theme="@style/Theme.FutoVideo.NoActionBar"
android:windowSoftInputMode="adjustPan"
@@ -251,11 +251,6 @@ 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
@@ -698,6 +698,11 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.enable_video_cache, FieldForm.TOGGLE, R.string.cache_to_quickly_load_previously_fetched_videos, 0)
@Serializable(with = FlexibleBooleanSerializer::class)
var videoCache: Boolean = false; //Temporary default disabled to prevent ui freeze?
@AdvancedField
@FormField(R.string.use_downloaded_ca_bundle, FieldForm.TOGGLE, R.string.use_downloaded_ca_bundle_description, 1)
@Serializable(with = FlexibleBooleanSerializer::class)
var useDownloadedCABundle: Boolean = false;
}
@FormField(R.string.casting, "group", R.string.configure_casting, 9)
@@ -290,6 +290,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
exIntent.putExtra(ExceptionActivity.EXTRA_STACK, message);
startActivity(exIntent);
Logger.flushBlocking();
Runtime.getRuntime().exit(0);
}
}
@@ -1,6 +1,7 @@
package com.futo.platformplayer.api.http
import androidx.collection.arrayMapOf
import com.futo.platformplayer.Settings
import com.futo.platformplayer.SettingsDev
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.ensureNotMainThread
@@ -17,11 +18,17 @@ import okhttp3.Response
import okhttp3.ResponseBody
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.io.File
import java.security.KeyStore
import java.security.SecureRandom
import java.security.cert.CertificateException
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.time.Duration
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
import kotlin.system.measureTimeMillis
@@ -64,10 +71,17 @@ open class ManagedHttpClient {
Logger.w(TAG, "Creating INSECURE client (TrustAll)");
}
private fun applyMergedTrustStore(builder: OkHttpClient.Builder) {
val pair = getOrBuildCompositeTrustStore() ?: return;
builder.sslSocketFactory(pair.first, pair.second);
}
constructor(builder: OkHttpClient.Builder = OkHttpClient.Builder()) {
_builderTemplate = builder;
if(FragmentedStorage.isInitialized && StateApp.instance.isMainActive && SettingsDev.instance.developerMode && SettingsDev.instance.networking.allowAllCertificates)
trustAllCertificates(builder);
else if(FragmentedStorage.isInitialized && Settings.instance.browsing.useDownloadedCABundle)
applyMergedTrustStore(builder);
client = builder.addNetworkInterceptor { chain ->
val request = beforeRequest(chain.request());
val response = afterRequest(chain.proceed(request));
@@ -328,5 +342,76 @@ open class ManagedHttpClient {
companion object {
val TAG = "ManagedHttpClient";
@Volatile private var _cachedCompositePair: Pair<SSLSocketFactory, X509TrustManager>? = null;
@Volatile private var _cachedCompositeMtime: Long = -1L;
private val _compositeBuildLock = Any();
private fun getOrBuildCompositeTrustStore(): Pair<SSLSocketFactory, X509TrustManager>? {
val context = StateApp.instance.contextOrNull ?: return null;
val bundleFile = File(context.noBackupFilesDir, "curl-ca-bundle.pem");
if (!bundleFile.exists()) {
Logger.w(TAG, "useDownloadedCABundle requested but bundle file not present yet");
return null;
}
val modTime = bundleFile.lastModified();
val cached = _cachedCompositePair;
if (cached != null && _cachedCompositeMtime == modTime) {
return cached;
}
synchronized(_compositeBuildLock) {
val recheck = _cachedCompositePair;
if (recheck != null && _cachedCompositeMtime == modTime) {
return recheck;
}
try {
val defaultAlgo = TrustManagerFactory.getDefaultAlgorithm();
val platformTmf = TrustManagerFactory.getInstance(defaultAlgo);
platformTmf.init(null as KeyStore?);
val platformTm = platformTmf.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager
?: return null;
val bundleKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null, null) };
val cf = CertificateFactory.getInstance("X.509");
val certs = bundleFile.inputStream().use { cf.generateCertificates(it) };
certs.forEachIndexed { i, cert -> bundleKeyStore.setCertificateEntry("bundle-$i", cert) };
val bundleTmf = TrustManagerFactory.getInstance(defaultAlgo);
bundleTmf.init(bundleKeyStore);
val bundleTm = bundleTmf.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager
?: return null;
val composite = object : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
platformTm.checkClientTrusted(chain, authType);
}
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
try {
platformTm.checkServerTrusted(chain, authType);
} catch (primary: CertificateException) {
bundleTm.checkServerTrusted(chain, authType);
}
}
override fun getAcceptedIssuers(): Array<X509Certificate> {
return platformTm.acceptedIssuers + bundleTm.acceptedIssuers;
}
};
val sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, arrayOf<TrustManager>(composite), SecureRandom());
val pair = Pair(sslContext.socketFactory, composite as X509TrustManager);
_cachedCompositePair = pair;
_cachedCompositeMtime = modTime;
Logger.i(TAG, "Built platform+bundle composite trust manager (${certs.size} extra certs from cacert.pem); cached until bundle mtime changes");
return pair;
} catch (ex: Throwable) {
Logger.e(TAG, "Failed to build downloaded CA bundle; will use platform default", ex);
return null;
}
}
}
}
}
@@ -100,7 +100,7 @@ class SourcePluginDescriptor {
@FormField(R.string.check_for_updates_setting, FieldForm.TOGGLE, R.string.check_for_updates_setting_description, -1)
var checkForUpdates: Boolean = true;
@FormField(R.string.automatic_update_setting, FieldForm.TOGGLE, R.string.automatic_update_setting_description, 0)
var automaticUpdate: Boolean = false;
var automaticUpdate: Boolean = true;
@FormField(R.string.visibility, "group", R.string.enable_where_this_plugins_content_are_visible, 2)
var tabEnabled = TabEnabled();
@@ -1024,6 +1024,31 @@ class VideoDownload {
}
}
}
try {
val tailUrl = foundTemplateUrl.replace("\$Number\$", indexCounter.toString());
Logger.i(TAG, "Downloading tail segment (segIndex=$indexCounter)");
val tailData = executeOrGet(client, executor, modifier, tailUrl);
fileStream.write(tailData, 0, tailData.size);
speedTracker.addWork(tailData.size.toLong());
written += tailData.size;
} catch (ex: Throwable) {
Logger.w(TAG, "$name tail segment fetch (segIndex=$indexCounter) failed; continuing without it", ex);
}
if (foundCues2 != null && foundTemplateUrl2 != null && fileStream2 != null) {
try {
val tailUrl2 = foundTemplateUrl2!!.replace("\$Number\$", foundCues2.size.toString());
Logger.i(TAG, "Downloading audio tail segment (segIndex=${foundCues2.size})");
val tailData2 = executeOrGet(client, executor, modifier, tailUrl2);
fileStream2.write(tailData2, 0, tailData2.size);
speedTracker.addWork(tailData2.size.toLong());
written2 += tailData2.size;
} catch (ex: Throwable) {
Logger.w(TAG, "$name(audio) tail segment fetch (segIndex=${foundCues2.size}) failed; continuing without it", ex);
}
}
sourceLength = written;
sourceLengthAudio = written2;
@@ -126,6 +126,7 @@ import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.states.StateSync
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.stores.db.types.DBHistory
import com.futo.platformplayer.sync.internal.GJSyncOpcodes
import com.futo.platformplayer.sync.models.SendToDevicePackage
@@ -364,7 +365,7 @@ class VideoDetailView : ConstraintLayout {
Pair(-5 * 60, 30), //around 5 minutes, try every 30 seconds
Pair(0, 10) //around live, try every 10 seconds
);
private var _subtitleLanguage: String? = null
private var _subtitleLanguage: String? = _subtitleLanguageStore.value;
@androidx.annotation.OptIn(UnstableApi::class)
constructor(context: Context, attrs : AttributeSet? = null) : super(context, attrs) {
@@ -1289,6 +1290,7 @@ class VideoDetailView : ConstraintLayout {
_taskLoadVideo.cancel();
_commentsList.cancel();
_player.clear();
_player.changePlayer(null);
_cast.cleanup();
_container_content_replies.cleanup();
_container_content_queue.cleanup();
@@ -2807,6 +2809,7 @@ class VideoDetailView : ConstraintLayout {
}
_lastSubtitleSource = toSet;
_subtitleLanguage = toSet?.language
_subtitleLanguageStore.setAndSave(_subtitleLanguage ?: "")
}
private fun handleUnavailableVideo(msg: String? = null) {
@@ -3641,6 +3644,7 @@ class VideoDetailView : ConstraintLayout {
const val TAG_MORE = "MORE";
private val _buttonPinStore = FragmentedStorage.get<StringArrayStorage>("videoPinnedButtons");
private val _subtitleLanguageStore = FragmentedStorage.get<StringStorage>("subtitleLanguage");
private var _lastOfflinePlaybackToastTime: Long = 0
}
}
@@ -14,6 +14,7 @@ class FileLogConsumer : ILogConsumer, Closeable {
private var _shouldSubmitLogs = false;
private val _linesToWrite = ConcurrentLinkedQueue<String>();
private var _writer: BufferedWriter? = null;
private val _writerLock = Any();
private var _running: Boolean = false;
private var _file: File;
private val _level: LogLevel;
@@ -42,12 +43,7 @@ class FileLogConsumer : ILogConsumer, Closeable {
submitLogs();
}
while (_linesToWrite.isNotEmpty()) {
val todo = _linesToWrite.remove()
_writer?.appendLine(todo);
}
_writer?.flush();
drainAndFlush();
} catch (e: Throwable) {
Log.e(TAG, "Failed to process logs.", e);
}
@@ -68,6 +64,25 @@ class FileLogConsumer : ILogConsumer, Closeable {
override fun consume(level: LogLevel, tag: String, text: String?, e: Throwable?) {
_linesToWrite.add(Logging.buildLogString(level, tag, text, e));
if (level == LogLevel.ERROR) {
try { drainAndFlush() } catch (_: Throwable) { /* best-effort */ }
}
}
fun flushBlocking() {
try { drainAndFlush() } catch (e: Throwable) {
Log.e(TAG, "flushBlocking failed", e);
}
}
private fun drainAndFlush() {
synchronized(_writerLock) {
val w = _writer ?: return
while (_linesToWrite.isNotEmpty()) {
w.appendLine(_linesToWrite.remove());
}
w.flush();
}
}
fun submitLogs() {
@@ -84,8 +99,10 @@ class FileLogConsumer : ILogConsumer, Closeable {
Log.i(TAG, "Requesting log writer exit.");
_running = false;
_writer?.close();
_writer = null;
synchronized(_writerLock) {
_writer?.close();
_writer = null;
}
//_logThread?.join();
_logThread = null;
}
@@ -74,6 +74,14 @@ class Logger {
return loggingEnabled;
}
fun flushBlocking() {
for (logConsumer in _logConsumers) {
if (logConsumer is FileLogConsumer) {
logConsumer.flushBlocking();
}
}
}
private fun log(level: LogLevel, tag: String, e: Throwable? = null, textBuilder: () -> String?) {
if (!_logConsumers.any { c -> c.willConsume(level, tag) }) {
return;
@@ -31,47 +31,37 @@ class StateUpdate {
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()
}
@@ -82,12 +72,6 @@ class StateUpdate {
uiIndeterminate = true
uiApkFile = null
uiError = null
uiDismissed = false
onUiChanged.emit()
}
fun dismissUi() {
uiDismissed = true
onUiChanged.emit()
}
@@ -243,4 +227,4 @@ class StateUpdate {
}
}
}
}
}
@@ -55,9 +55,18 @@ class PlayerManager {
}
fun modifyState(name: String, cb: (PlayerState) -> Unit) {
val state = getState(name);
val previousListener = state.listener;
cb(state);
if(_currentState == state)
if(_currentState == state) {
applyState(state);
val newListener = state.listener;
if(previousListener !== newListener) {
if(previousListener != null)
player.removeListener(previousListener);
if(newListener != null)
player.addListener(newListener);
}
}
}
fun switchState(name: String) {
val newState = getState(name);
@@ -29,11 +29,9 @@ 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?
@@ -45,15 +43,9 @@ class UpdateBannerView : LinearLayout {
_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()
@@ -96,17 +88,6 @@ class UpdateBannerView : LinearLayout {
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 {
@@ -118,6 +99,7 @@ class UpdateBannerView : LinearLayout {
Logger.w(TAG, "Download start service failed", t)
}
}
UpdateUiState.DOWNLOADING -> {}
UpdateUiState.NONE -> {}
}
}
@@ -125,7 +107,7 @@ class UpdateBannerView : LinearLayout {
private fun refresh() {
val st = StateUpdate.instance
val gateOpen = Settings.instance.autoUpdate.shouldBackgroundDownload
val visible = gateOpen && !st.uiDismissed && st.uiState != UpdateUiState.NONE
val visible = gateOpen && st.uiState != UpdateUiState.NONE
if (!visible) {
_root.visibility = View.GONE
@@ -135,41 +117,31 @@ class UpdateBannerView : LinearLayout {
when (st.uiState) {
UpdateUiState.AVAILABLE -> {
_textTitle.text = "Update available (v${st.uiVersion})"
_textBody.text = "A new Grayjay version is available."
_textBody.visibility = View.VISIBLE
_textTitle.text = "Update v${st.uiVersion}"
_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…"
_textTitle.text = "Downloading v${st.uiVersion}"
_progressBar.isIndeterminate = true
} else {
_textBody.text = "${st.uiProgress}% downloaded"
_textTitle.text = "Downloading v${st.uiVersion} - ${st.uiProgress}%"
_progressBar.isIndeterminate = false
_progressBar.progress = st.uiProgress
}
_textBody.visibility = View.VISIBLE
_progressBar.visibility = View.VISIBLE
_textAction.text = "Cancel"
_buttonAction.visibility = View.VISIBLE
_buttonAction.visibility = View.GONE
}
UpdateUiState.READY -> {
_textTitle.text = "Update v${st.uiVersion} ready"
_textBody.text = "Tap install to apply the update."
_textBody.visibility = View.VISIBLE
_textTitle.text = "Ready v${st.uiVersion}"
_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
@@ -18,7 +18,6 @@ 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
@@ -120,15 +119,6 @@ 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;
@@ -199,9 +189,6 @@ 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);
@@ -219,9 +206,6 @@ 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
@@ -241,26 +225,24 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
_buttonNext.setOnClickListener { onNext.emit() };
_buttonPrevious_fullscreen.setOnClickListener { onPrevious.emit() };
_buttonNext_fullscreen.setOnClickListener { onNext.emit() };
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()
_control_play.setOnClickListener {
exoPlayer?.player?.let {
if (it.contentPosition >= it.duration) {
it.seekTo(0)
}
exoPlayer?.player?.play();
}
updatePlayPause()
}
_control_play.setOnClickListener(playClickHandler)
_control_play_fullscreen.setOnClickListener(playClickHandler)
updatePlayPause();
};
_control_play_fullscreen.setOnClickListener {
exoPlayer?.player?.let {
if (it.contentPosition >= it.duration) {
it.seekTo(0)
}
exoPlayer?.player?.play();
}
updatePlayPause();
};
_control_pause.setOnClickListener {
exoPlayer?.player?.pause();
updatePlayPause();
@@ -478,17 +460,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
updateAutoplayButton()
val progressUpdateListener = { position: Long, bufferedPosition: Long ->
// 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 currentTime = position.formatDuration()
val currentDuration = duration.formatDuration()
_control_time.text = currentTime;
_control_time_fullscreen.text = currentTime;
@@ -501,12 +473,6 @@ 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)
@@ -533,23 +499,6 @@ 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) {
@@ -946,58 +895,6 @@ 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,7 +17,6 @@ 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
@@ -130,64 +129,6 @@ 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;
@@ -195,8 +136,6 @@ 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();
@@ -211,21 +150,6 @@ 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)
@@ -293,30 +217,6 @@ 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);
@@ -415,91 +315,6 @@ 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});
@@ -661,12 +476,6 @@ 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;
@@ -1180,9 +989,6 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
_lastAudioMediaSource = null;
_lastSubtitleMediaSource = null;
_mediaSource = null;
_isLiveSession = false
_liveReloadAttempts = 0
_lastLiveReloadAt_ms = 0
}
fun stop(){
@@ -1207,34 +1013,18 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
protected open fun onPlayerError(error: PlaybackException) {
Logger.i(TAG, "onPlayerError error=$error error.errorCode=${error.errorCode} connectivityLoss, cause=${error.cause}");
// 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) {
if(error is BehindLiveWindowException) {
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}");
@@ -1253,6 +1043,7 @@ 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,
@@ -1267,23 +1058,6 @@ 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) {
}
@@ -1300,17 +1074,6 @@ 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) { }
@@ -1329,21 +1092,6 @@ 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");
}
}
@@ -1,6 +0,0 @@
<?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>
@@ -1,7 +0,0 @@
<?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>
@@ -1,7 +0,0 @@
<?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>
@@ -1,7 +0,0 @@
<?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>
+1 -42
View File
@@ -195,47 +195,6 @@
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"
@@ -245,7 +204,7 @@
android:paddingRight="10dp"
android:textSize="11sp"
android:gravity="left"
app:layout_constraintLeft_toRightOf="@id/live_pill_container"
app:layout_constraintLeft_toRightOf="@id/text_duration"
app:layout_constraintTop_toTopOf="@id/text_duration"
app:layout_constraintBottom_toBottomOf="@id/text_duration"
app:layout_constraintRight_toLeftOf="@id/button_fullscreen"
@@ -225,45 +225,6 @@
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"
@@ -273,7 +234,7 @@
android:layout_marginTop="-2dp"
android:textSize="11sp"
android:gravity="left"
app:layout_constraintLeft_toRightOf="@id/live_pill_container"
app:layout_constraintLeft_toRightOf="@id/text_duration"
app:layout_constraintTop_toTopOf="@id/text_duration"
app:layout_constraintBottom_toBottomOf="@id/text_duration"
app:layout_constraintRight_toLeftOf="@id/button_fullscreen"
+2 -2
View File
@@ -7,7 +7,7 @@
android:paddingEnd="12dp"
android:background="@drawable/background_pill"
android:layout_marginEnd="6dp"
android:layout_marginTop="17dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:id="@+id/root">
<LinearLayout
@@ -36,4 +36,4 @@
tools:text="Tag text" />
</LinearLayout>
</FrameLayout>
</FrameLayout>
+36 -59
View File
@@ -1,94 +1,71 @@
<?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
<LinearLayout
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">
android:layout_marginLeft="10dp"
android:layout_marginTop="6dp"
android:layout_marginRight="10dp"
android:layout_marginBottom="0dp"
android:minHeight="40dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingLeft="12dp"
android:paddingTop="6dp"
android:paddingRight="8dp"
android:paddingBottom="6dp">
<ImageView android:id="@+id/icon_update"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_width="18dp"
android:layout_height="18dp"
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" />
android:layout_marginRight="10dp"
android:alpha="0.9"
android:importantForAccessibility="no" />
<TextView android:id="@+id/text_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
tools:text="Downloading update v123"
android:layout_weight="1"
tools:text="Downloading v123 - 42%"
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" />
android:textColor="@color/white"
android:ellipsize="end"
android:maxLines="1" />
<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:layout_width="78dp"
android:layout_height="4dp"
android:layout_marginLeft="10dp"
android:layout_marginRight="4dp"
android:max="100"
android:visibility="gone"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_body" />
tools:visibility="visible" />
<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">
android:layout_height="28dp"
android:layout_marginLeft="10dp"
android:background="@drawable/background_button_primary_round_4dp">
<TextView android:id="@+id/text_action"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_height="match_parent"
android:gravity="center"
tools:text="Install"
android:fontFamily="@font/inter_regular"
android:textSize="14sp"
android:textSize="12sp"
android:textColor="@color/white"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:paddingLeft="20dp"
android:paddingRight="20dp" />
android:paddingLeft="13dp"
android:paddingRight="13dp" />
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
</FrameLayout>
+2 -1
View File
@@ -372,6 +372,8 @@
<string name="deletes_license_keys_from_app">Deletes license keys from app</string>
<string name="download_when">Download when</string>
<string name="enable_video_cache">Enable Video Cache</string>
<string name="use_downloaded_ca_bundle">Use downloaded CA bundle</string>
<string name="use_downloaded_ca_bundle_description">May help on devices with stale root certificates.</string>
<string name="enable_casting">Enable casting</string>
<string name="experimental_background_update_for_subscriptions_cache">Experimental background update for subscriptions cache</string>
<string name="export_data">Export Data</string>
@@ -952,7 +954,6 @@
<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>
+2 -1
View File
@@ -19,7 +19,8 @@
"84331338-b045-419c-88e4-c86036f4cbf5": "sources/mixcloud/MixcloudConfig.json",
"009775f8-9173-48a2-8df3-d730d08d198d": "sources/radiobrowser/RadioBrowserConfig.json",
"5f6658bb-96cc-4965-ba04-c81f8686ab67": "sources/redbull-tv/RedBullTvConfig.json",
"d890ff43-7d9f-4f0e-a52d-239014fd512d": "sources/fosdem/FOSDEMConfig.json"
"d890ff43-7d9f-4f0e-a52d-239014fd512d": "sources/fosdem/FOSDEMConfig.json",
"a1b2c3d4-5e6f-7890-abcd-ef1234567890": "sources/nasa-plus/NASA-PlusConfig.json"
},
"SOURCES_EMBEDDED_DEFAULT": [
"35ae969a-a7db-11ed-afa1-0242ac120002"
+2 -1
View File
@@ -19,7 +19,8 @@
"84331338-b045-419c-88e4-c86036f4cbf5": "sources/mixcloud/MixcloudConfig.json",
"009775f8-9173-48a2-8df3-d730d08d198d": "sources/radiobrowser/RadioBrowserConfig.json",
"5f6658bb-96cc-4965-ba04-c81f8686ab67": "sources/redbull-tv/RedBullTvConfig.json",
"d890ff43-7d9f-4f0e-a52d-239014fd512d": "sources/fosdem/FOSDEMConfig.json"
"d890ff43-7d9f-4f0e-a52d-239014fd512d": "sources/fosdem/FOSDEMConfig.json",
"a1b2c3d4-5e6f-7890-abcd-ef1234567890": "sources/nasa-plus/NASA-PlusConfig.json"
},
"SOURCES_EMBEDDED_DEFAULT": [
"35ae969a-a7db-11ed-afa1-0242ac120002"