Show player loading status

This commit is contained in:
natan 2026-03-20 07:17:51 +02:00
parent 41ee49af72
commit c64abead0f
11 changed files with 81 additions and 15 deletions

View file

@ -170,6 +170,7 @@ data class PlayerSettings(
val preferredAudioLanguage: String = AudioLanguageOption.DEVICE,
val secondaryPreferredAudioLanguage: String? = null,
val loadingOverlayEnabled: Boolean = true,
val showPlayerLoadingStatus: Boolean = true,
val pauseOverlayEnabled: Boolean = true,
val osdClockEnabled: Boolean = true,
val skipIntroEnabled: Boolean = true,
@ -282,6 +283,7 @@ class PlayerSettingsDataStore @Inject constructor(
private val preferredAudioLanguageKey = stringPreferencesKey("preferred_audio_language")
private val secondaryPreferredAudioLanguageKey = stringPreferencesKey("secondary_preferred_audio_language")
private val loadingOverlayEnabledKey = booleanPreferencesKey("loading_overlay_enabled")
private val showPlayerLoadingStatusKey = booleanPreferencesKey("show_player_loading_status")
private val pauseOverlayEnabledKey = booleanPreferencesKey("pause_overlay_enabled")
private val osdClockEnabledKey = booleanPreferencesKey("osd_clock_enabled")
private val skipIntroEnabledKey = booleanPreferencesKey("skip_intro_enabled")
@ -426,6 +428,7 @@ class PlayerSettingsDataStore @Inject constructor(
secondaryPreferredAudioLanguage = prefs[secondaryPreferredAudioLanguageKey]
?.let(::normalizeSecondaryAudioLanguageCode),
loadingOverlayEnabled = prefs[loadingOverlayEnabledKey] ?: true,
showPlayerLoadingStatus = prefs[showPlayerLoadingStatusKey] ?: true,
pauseOverlayEnabled = prefs[pauseOverlayEnabledKey] ?: true,
osdClockEnabled = prefs[osdClockEnabledKey] ?: true,
skipIntroEnabled = prefs[skipIntroEnabledKey] ?: true,
@ -615,6 +618,12 @@ class PlayerSettingsDataStore @Inject constructor(
}
}
suspend fun setShowPlayerLoadingStatus(enabled: Boolean) {
store().edit { prefs ->
prefs[showPlayerLoadingStatusKey] = enabled
}
}
suspend fun setFrameRateMatchingMode(mode: FrameRateMatchingMode) {
store().edit { prefs ->
prefs[frameRateMatchingModeKey] = mode.name

View file

@ -15,6 +15,7 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject
class SubtitleRepositoryImpl @Inject constructor(
@ -33,7 +34,8 @@ class SubtitleRepositoryImpl @Inject constructor(
videoId: String?,
videoHash: String?,
videoSize: Long?,
filename: String?
filename: String?,
onProgress: ((completed: Int, total: Int) -> Unit)?
): List<Subtitle> = withContext(Dispatchers.IO) {
val requestType = canonicalSubtitleType(type)
val startedAtMs = System.currentTimeMillis()
@ -57,11 +59,15 @@ class SubtitleRepositoryImpl @Inject constructor(
}
Log.d(TAG, "Found ${subtitleAddons.size} subtitle addons: ${subtitleAddons.map { it.name }}")
if (subtitleAddons.isEmpty()) {
return@withContext emptyList()
}
val total = subtitleAddons.size
val completedCount = AtomicInteger(0)
onProgress?.invoke(0, total)
// Fetch subtitles from all addons in parallel
val result = coroutineScope {
subtitleAddons.map { addon ->
@ -70,6 +76,7 @@ class SubtitleRepositoryImpl @Inject constructor(
val subtitles = withTimeoutOrNull(PER_ADDON_TIMEOUT_MS) {
fetchSubtitlesFromAddon(addon, type, id, videoId, videoHash, videoSize, filename)
}
onProgress?.invoke(completedCount.incrementAndGet(), total)
if (subtitles == null) {
Log.w(
TAG,

View file

@ -19,6 +19,7 @@ interface SubtitleRepository {
videoId: String? = null,
videoHash: String? = null,
videoSize: Long? = null,
filename: String? = null
filename: String? = null,
onProgress: ((completed: Int, total: Int) -> Unit)? = null
): List<Subtitle>
}

View file

@ -1,6 +1,7 @@
package com.nuvio.tv.ui.screens.player
import android.content.Context
import com.nuvio.tv.R
import android.content.res.Resources
import android.os.Build
import android.util.Log
@ -71,10 +72,12 @@ internal fun PlayerRuntimeController.initializePlayer(url: String, headers: Map<
try {
resetLoadingOverlayForNewStream()
val playerSettings = playerSettingsDataStore.playerSettings.first()
val showLoadingStatus = playerSettings.showPlayerLoadingStatus
_uiState.update {
it.copy(
frameRateMatchingMode = playerSettings.frameRateMatchingMode,
resizeMode = playerSettings.resizeMode
resizeMode = playerSettings.resizeMode,
loadingMessage = if (showLoadingStatus) context.getString(R.string.player_loading_preparing) else null
)
}
runAfrPreflightIfEnabled(
@ -87,7 +90,7 @@ internal fun PlayerRuntimeController.initializePlayer(url: String, headers: Map<
url = url,
headers = headers
)
val startupSubtitlePreparation = prepareStreamStartSubtitles(playerSettings)
val startupSubtitlePreparation = prepareStreamStartSubtitles(playerSettings, showLoadingStatus)
requestedUseLibassByUser = playerSettings.useLibass
val useLibass = when {
!requestedUseLibassByUser -> false
@ -172,6 +175,7 @@ internal fun PlayerRuntimeController.initializePlayer(url: String, headers: Map<
).setExtensionRendererMode(playerSettings.decoderPriority)
.setMapDV7ToHevc(playerSettings.mapDV7ToHevc)
if (showLoadingStatus) _uiState.update { it.copy(loadingMessage = context.getString(R.string.player_loading_building)) }
val buildDefaultPlayer = {
mediaSourceFactory.configureSubtitleParsing(
extractorsFactory = null,
@ -247,6 +251,7 @@ internal fun PlayerRuntimeController.initializePlayer(url: String, headers: Map<
mimeTypeOverride = currentStreamMimeType
)
)
if (showLoadingStatus) _uiState.update { it.copy(loadingMessage = context.getString(R.string.player_loading_starting)) }
playWhenReady = true
prepare()
@ -268,9 +273,9 @@ internal fun PlayerRuntimeController.initializePlayer(url: String, headers: Map<
if (playbackState == Player.STATE_BUFFERING && !hasRenderedFirstFrame) {
_uiState.update { state ->
if (state.loadingOverlayEnabled && !state.showLoadingOverlay) {
state.copy(showLoadingOverlay = true, showControls = false)
state.copy(showLoadingOverlay = true, showControls = false, loadingMessage = if (showLoadingStatus) context.getString(R.string.player_loading_buffering) else null)
} else {
state
state.copy(loadingMessage = if (showLoadingStatus) context.getString(R.string.player_loading_buffering) else null)
}
}
}
@ -337,7 +342,7 @@ internal fun PlayerRuntimeController.initializePlayer(url: String, headers: Map<
override fun onRenderedFirstFrame() {
hasRenderedFirstFrame = true
_uiState.update { it.copy(showLoadingOverlay = false) }
_uiState.update { it.copy(showLoadingOverlay = false, loadingMessage = null) }
}
override fun onPlayerError(error: PlaybackException) {
@ -419,7 +424,8 @@ internal fun resolvePreferredAudioLanguages(
internal suspend fun PlayerRuntimeController.prepareStartupSubtitles(
mode: AddonSubtitleStartupMode,
preferredLanguage: String,
secondaryLanguage: String?
secondaryLanguage: String?,
showLoadingStatus: Boolean = true
): StartupSubtitlePreparation {
if (mode == AddonSubtitleStartupMode.FAST_STARTUP) {
return StartupSubtitlePreparation(
@ -460,7 +466,16 @@ internal suspend fun PlayerRuntimeController.prepareStartupSubtitles(
_uiState.update { it.copy(isLoadingAddonSubtitles = true, addonSubtitlesError = null) }
val fetchedSubtitles = withTimeoutOrNull(STARTUP_SUBTITLE_PREFETCH_TIMEOUT_MS) {
fetchAddonSubtitlesNow()
fetchAddonSubtitlesNow(
onProgress = if (showLoadingStatus) { completed, total ->
val msg = if (completed == 0) {
context.getString(R.string.player_loading_subtitles_from, total)
} else {
context.getString(R.string.player_loading_subtitles_progress, completed, total)
}
_uiState.update { it.copy(loadingMessage = msg) }
} else null
)
} ?: return StartupSubtitlePreparation(
fetchedSubtitles = emptyList(),
attachedSubtitles = emptyList(),
@ -503,7 +518,8 @@ internal fun PlayerRuntimeController.resetAddonSubtitleStateForNewStream() {
}
internal suspend fun PlayerRuntimeController.prepareStreamStartSubtitles(
playerSettings: PlayerSettings
playerSettings: PlayerSettings,
showLoadingStatus: Boolean = true
): StartupSubtitlePreparation {
requestedUseLibassByUser = playerSettings.useLibass
if (libassPipelineDecisionStreamUrl != currentStreamUrl) {
@ -516,7 +532,8 @@ internal suspend fun PlayerRuntimeController.prepareStreamStartSubtitles(
return prepareStartupSubtitles(
mode = playerSettings.addonSubtitleStartupMode,
preferredLanguage = playerSettings.subtitleStyle.preferredLanguage,
secondaryLanguage = playerSettings.subtitleStyle.secondaryPreferredLanguage
secondaryLanguage = playerSettings.subtitleStyle.secondaryPreferredLanguage,
showLoadingStatus = showLoadingStatus
)
}

View file

@ -27,7 +27,9 @@ internal fun PlayerRuntimeController.buildSubtitleFetchRequest(): SubtitleFetchR
)
}
internal suspend fun PlayerRuntimeController.fetchAddonSubtitlesNow(): List<Subtitle> {
internal suspend fun PlayerRuntimeController.fetchAddonSubtitlesNow(
onProgress: ((completed: Int, total: Int) -> Unit)? = null
): List<Subtitle> {
val request = buildSubtitleFetchRequest() ?: return emptyList()
val installedAddonOrder = addonRepository.getInstalledAddons().firstOrNull()
?.map { it.displayName }
@ -64,7 +66,8 @@ internal suspend fun PlayerRuntimeController.fetchAddonSubtitlesNow(): List<Subt
videoId = request.videoId,
videoHash = currentVideoHash,
videoSize = currentVideoSize,
filename = currentFilename
filename = currentFilename,
onProgress = onProgress
)
}

View file

@ -560,6 +560,7 @@ fun PlayerScreen(
backdropUrl = uiState.backdrop,
logoUrl = uiState.logo,
title = uiState.title,
message = uiState.loadingMessage,
modifier = Modifier
.fillMaxSize()
.zIndex(2f)

View file

@ -36,6 +36,7 @@ data class PlayerUiState(
val playbackSpeed: Float = 1f,
val loadingOverlayEnabled: Boolean = true,
val showLoadingOverlay: Boolean = true,
val loadingMessage: String? = null,
val pauseOverlayEnabled: Boolean = true,
val osdClockEnabled: Boolean = true,
val showPauseOverlay: Boolean = false,

View file

@ -232,6 +232,7 @@ fun PlaybackSettingsContent(
coroutineScope.launch { viewModel.setStreamAutoPlayTimeoutSeconds(seconds) }
},
onSetReuseLastLinkEnabled = { enabled -> coroutineScope.launch { viewModel.setStreamReuseLastLinkEnabled(enabled) } },
onSetShowPlayerLoadingStatus = { enabled -> coroutineScope.launch { viewModel.setShowPlayerLoadingStatus(enabled) } },
onSetLoadingOverlayEnabled = { enabled -> coroutineScope.launch { viewModel.setLoadingOverlayEnabled(enabled) } },
onSetPauseOverlayEnabled = { enabled -> coroutineScope.launch { viewModel.setPauseOverlayEnabled(enabled) } },
onSetOsdClockEnabled = { enabled -> coroutineScope.launch { viewModel.setOsdClockEnabled(enabled) } },

View file

@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PauseCircle
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Timer
@ -113,6 +114,7 @@ internal fun PlaybackSettingsSections(
onSetNextEpisodeThresholdMinutesBeforeEnd: (Float) -> Unit,
onSetStreamAutoPlayTimeoutSeconds: (Int) -> Unit,
onSetReuseLastLinkEnabled: (Boolean) -> Unit,
onSetShowPlayerLoadingStatus: (Boolean) -> Unit,
onSetLoadingOverlayEnabled: (Boolean) -> Unit,
onSetPauseOverlayEnabled: (Boolean) -> Unit,
onSetOsdClockEnabled: (Boolean) -> Unit,
@ -319,6 +321,17 @@ internal fun PlaybackSettingsSections(
onSetReuseLastLinkEnabled = onSetReuseLastLinkEnabled,
onItemFocused = { focusedSection = PlaybackSection.STREAM_SELECTION }
)
item(key = "stream_show_loading_status") {
ToggleSettingsItem(
icon = Icons.Default.Info,
title = stringResource(R.string.playback_show_loading_status),
subtitle = stringResource(R.string.playback_show_loading_status_sub),
isChecked = playerSettings.showPlayerLoadingStatus,
onCheckedChange = onSetShowPlayerLoadingStatus,
onFocused = { focusedSection = PlaybackSection.STREAM_SELECTION }
)
}
}
playbackCollapsibleSection(

View file

@ -91,6 +91,10 @@ class PlaybackSettingsViewModel @Inject constructor(
playerSettingsDataStore.setLoadingOverlayEnabled(enabled)
}
suspend fun setShowPlayerLoadingStatus(enabled: Boolean) {
playerSettingsDataStore.setShowPlayerLoadingStatus(enabled)
}
suspend fun setPauseOverlayEnabled(enabled: Boolean) {
playerSettingsDataStore.setPauseOverlayEnabled(enabled)
}

View file

@ -716,6 +716,15 @@
<!-- PlayerScreen -->
<string name="player_no_external_player">No external player found</string>
<string name="player_loading_preparing">Preparing stream…</string>
<string name="player_loading_subtitles">Fetching subtitles…</string>
<string name="player_loading_subtitles_from">Fetching subtitles from %1$d addons…</string>
<string name="player_loading_subtitles_progress">Fetching subtitles… (%1$d / %2$d)</string>
<string name="player_loading_building">Building player…</string>
<string name="player_loading_starting">Starting stream…</string>
<string name="player_loading_buffering">Buffering…</string>
<string name="playback_show_loading_status">Show loading status</string>
<string name="playback_show_loading_status_sub">Display step-by-step progress while the player is initializing.</string>
<!-- ParentalGuideOverlay -->
<string name="parental_nudity">Nudity</string>