mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-27 18:25:21 +02:00
Compare commits
12 Commits
livestream
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 620d4f8fb7 | |||
| 9c0c8fe927 | |||
| 1263070fc9 | |||
| 336f30a631 | |||
| 9ec4b76d5e | |||
| 91bea8faf2 | |||
| 1d6f2d2ff7 | |||
| 32686215c4 | |||
| f226669b77 | |||
| a792dea4c5 | |||
| a7fc549afb | |||
| b345ba5ca3 |
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -100,7 +100,7 @@ class SourcePluginDescriptor {
|
||||
@FormField(R.string.check_for_updates_setting, FieldForm.TOGGLE, R.string.check_for_updates_setting_description, -1)
|
||||
var checkForUpdates: Boolean = true;
|
||||
@FormField(R.string.automatic_update_setting, FieldForm.TOGGLE, R.string.automatic_update_setting_description, 0)
|
||||
var automaticUpdate: Boolean = false;
|
||||
var automaticUpdate: Boolean = true;
|
||||
|
||||
@FormField(R.string.visibility, "group", R.string.enable_where_this_plugins_content_are_visible, 2)
|
||||
var tabEnabled = TabEnabled();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+5
-1
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Submodule app/src/stable/assets/sources/apple-podcasts updated: 9c65475be1...8d9dee8a49
Submodule app/src/stable/assets/sources/bilibili updated: 9186672f0f...c63c69beec
Submodule app/src/stable/assets/sources/bitchute updated: b213f91c0b...deed10c077
Submodule app/src/stable/assets/sources/crunchyroll updated: a1714790c5...499ab8b438
Submodule app/src/stable/assets/sources/curiositystream updated: 1ebf5da236...68f85a0d62
Submodule app/src/stable/assets/sources/dailymotion updated: 70f625a3bd...256b8433e0
Submodule app/src/stable/assets/sources/fosdem updated: 2231fbec11...e8fe3b4bb5
Submodule app/src/stable/assets/sources/mixcloud updated: 1b801553b3...c107d15296
Submodule
+1
Submodule app/src/stable/assets/sources/nasa-plus added at 56068c37dd
Submodule app/src/stable/assets/sources/nebula updated: 090cd76dfa...84e920f378
Submodule app/src/stable/assets/sources/odysee updated: 1c7a8a4974...c6e462db9b
Submodule app/src/stable/assets/sources/patreon updated: 52154f36c2...87b168a7cb
Submodule app/src/stable/assets/sources/peertube updated: 7b52405ad0...c955d8ed56
Submodule app/src/stable/assets/sources/redbull-tv updated: 179b7a6e22...7f4317f5c7
Submodule app/src/stable/assets/sources/soundcloud updated: e785c5d8c9...8ed7c19c45
Submodule app/src/stable/assets/sources/tedtalks updated: 292e459eef...f7f31a4f9a
Submodule app/src/stable/assets/sources/twitch updated: cebdad37a3...3a46d407de
Submodule app/src/stable/assets/sources/youtube updated: fb90a44f83...de50576849
@@ -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"
|
||||
|
||||
Submodule app/src/unstable/assets/sources/apple-podcasts updated: 9c65475be1...8d9dee8a49
Submodule app/src/unstable/assets/sources/bilibili updated: 9186672f0f...c63c69beec
Submodule app/src/unstable/assets/sources/bitchute updated: b213f91c0b...deed10c077
Submodule app/src/unstable/assets/sources/crunchyroll updated: a1714790c5...499ab8b438
Submodule app/src/unstable/assets/sources/curiositystream updated: 1ebf5da236...68f85a0d62
Submodule app/src/unstable/assets/sources/dailymotion updated: 70f625a3bd...256b8433e0
Submodule app/src/unstable/assets/sources/fosdem updated: 2231fbec11...e8fe3b4bb5
Submodule app/src/unstable/assets/sources/mixcloud updated: 1b801553b3...c107d15296
+1
Submodule app/src/unstable/assets/sources/nasa-plus added at 56068c37dd
Submodule app/src/unstable/assets/sources/nebula updated: 090cd76dfa...84e920f378
Submodule app/src/unstable/assets/sources/odysee updated: 1c7a8a4974...c6e462db9b
Submodule app/src/unstable/assets/sources/patreon updated: 52154f36c2...87b168a7cb
Submodule app/src/unstable/assets/sources/peertube updated: 7b52405ad0...c955d8ed56
Submodule app/src/unstable/assets/sources/redbull-tv updated: 179b7a6e22...7f4317f5c7
Submodule app/src/unstable/assets/sources/soundcloud updated: e785c5d8c9...8ed7c19c45
Submodule app/src/unstable/assets/sources/tedtalks updated: 292e459eef...f7f31a4f9a
Submodule app/src/unstable/assets/sources/twitch updated: cebdad37a3...3a46d407de
Submodule app/src/unstable/assets/sources/youtube updated: fb90a44f83...de50576849
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user