From 41b0bd95ad85b53eb6bca850f43999a0940e004e Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 20 May 2026 16:15:02 +0530 Subject: [PATCH] Updated DebridStreamPresentation to filter out uncached streams and apply custom formatting based on settings. --- .../app/features/debrid/DebridSettings.kt | 3 + .../debrid/DebridSettingsRepository.kt | 37 ++- .../debrid/DebridStreamFormatterDefaults.kt | 8 +- .../debrid/DebridStreamPresentation.kt | 24 +- .../features/debrid/DirectDebridResolver.kt | 4 +- .../debrid/DirectDebridStreamPreparer.kt | 5 +- .../debrid/TorboxAvailabilityService.kt | 34 +- .../app/features/details/MetaDetailsScreen.kt | 35 ++ .../player/PlayerStreamsRepository.kt | 30 +- .../features/settings/DebridSettingsPage.kt | 2 +- .../streams/AddonStreamWarmupRepository.kt | 304 ++++++++++++++++++ .../streams/StreamAutoPlaySelector.kt | 5 +- .../app/features/streams/StreamModels.kt | 8 +- .../app/features/streams/StreamsRepository.kt | 31 +- .../app/features/streams/StreamsScreen.kt | 132 +++++--- .../features/streams/StreamsTabletLayout.kt | 2 + .../debrid/DebridStreamPresentationTest.kt | 36 ++- 17 files changed, 600 insertions(+), 100 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt index 6e48cc07..6fb882f8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt @@ -19,6 +19,9 @@ data class DebridSettings( ) { val hasAnyApiKey: Boolean get() = DebridProviders.configuredServices(this).isNotEmpty() + + val hasCustomStreamFormatting: Boolean + get() = streamNameTemplate.isNotBlank() || streamDescriptionTemplate.isNotBlank() } const val DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT = 2 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt index d8c7625b..475597fd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt @@ -160,7 +160,7 @@ object DebridSettingsRepository { fun setStreamNameTemplate(value: String) { ensureLoaded() - val normalized = value.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE } + val normalized = normalizeStreamTemplate(value, DebridTemplateKind.NAME) if (streamNameTemplate == normalized) return streamNameTemplate = normalized publish() @@ -169,7 +169,7 @@ object DebridSettingsRepository { fun setStreamDescriptionTemplate(value: String) { ensureLoaded() - val normalized = value.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE } + val normalized = normalizeStreamTemplate(value, DebridTemplateKind.DESCRIPTION) if (streamDescriptionTemplate == normalized) return streamDescriptionTemplate = normalized publish() @@ -178,8 +178,8 @@ object DebridSettingsRepository { fun setStreamTemplates(nameTemplate: String, descriptionTemplate: String) { ensureLoaded() - streamNameTemplate = nameTemplate.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE } - streamDescriptionTemplate = descriptionTemplate.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE } + streamNameTemplate = normalizeStreamTemplate(nameTemplate, DebridTemplateKind.NAME) + streamDescriptionTemplate = normalizeStreamTemplate(descriptionTemplate, DebridTemplateKind.DESCRIPTION) publish() DebridSettingsStorage.saveStreamNameTemplate(streamNameTemplate) DebridSettingsStorage.saveStreamDescriptionTemplate(streamDescriptionTemplate) @@ -241,12 +241,14 @@ object DebridSettingsRepository { hdrFilter = streamHdrFilter, codecFilter = streamCodecFilter, ) - streamNameTemplate = DebridSettingsStorage.loadStreamNameTemplate() - ?.takeIf { it.isNotBlank() } - ?: DebridStreamFormatterDefaults.NAME_TEMPLATE - streamDescriptionTemplate = DebridSettingsStorage.loadStreamDescriptionTemplate() - ?.takeIf { it.isNotBlank() } - ?: DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE + streamNameTemplate = normalizeStreamTemplate( + DebridSettingsStorage.loadStreamNameTemplate().orEmpty(), + DebridTemplateKind.NAME, + ) + streamDescriptionTemplate = normalizeStreamTemplate( + DebridSettingsStorage.loadStreamDescriptionTemplate().orEmpty(), + DebridTemplateKind.DESCRIPTION, + ) publish() } @@ -285,6 +287,21 @@ object DebridSettingsRepository { null } } + + private enum class DebridTemplateKind { + NAME, + DESCRIPTION, + } + + private fun normalizeStreamTemplate(value: String, kind: DebridTemplateKind): String { + val trimmed = value.trim() + return when { + trimmed.isBlank() -> "" + kind == DebridTemplateKind.NAME && trimmed == DebridStreamFormatterDefaults.LEGACY_NAME_TEMPLATE -> "" + kind == DebridTemplateKind.DESCRIPTION && trimmed == DebridStreamFormatterDefaults.LEGACY_DESCRIPTION_TEMPLATE -> "" + else -> value + } + } } internal fun DebridStreamPreferences.normalized(): DebridStreamPreferences = diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt index 585b020c..d6637fd4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt @@ -1,7 +1,11 @@ package com.nuvio.app.features.debrid object DebridStreamFormatterDefaults { - const val NAME_TEMPLATE = "{stream.resolution::=2160p[\"4K \"||\"\"]}{stream.resolution::=1440p[\"QHD \"||\"\"]}{stream.resolution::=1080p[\"FHD \"||\"\"]}{stream.resolution::=720p[\"HD \"||\"\"]}{service.shortName::exists[\"{service.shortName} \"||\"Debrid \"]}{service.cached::istrue[\"Ready\"||\"Not Ready\"]}" + const val NAME_TEMPLATE = "" - const val DESCRIPTION_TEMPLATE = "{stream.title::exists[\"{stream.title::title} \"||\"\"]}{stream.year::exists[\"({stream.year})\"||\"\"]}\n{stream.quality::exists[\"{stream.quality} \"||\"\"]}{stream.visualTags::exists[\"{stream.visualTags::join(' | ')} \"||\"\"]}{stream.encode::exists[\"{stream.encode} \"||\"\"]}\n{stream.audioTags::exists[\"{stream.audioTags::join(' | ')}\"||\"\"]}{stream.audioTags::exists::and::stream.audioChannels::exists[\" | \"||\"\"]}{stream.audioChannels::exists[\"{stream.audioChannels::join(' | ')}\"||\"\"]}\n{stream.size::>0[\"{stream.size::bytes} \"||\"\"]}{stream.releaseGroup::exists[\"{stream.releaseGroup} \"||\"\"]}{stream.indexer::exists[\"{stream.indexer}\"||\"\"]}\n{service.cached::istrue[\"Ready\"||\"Not Ready\"]}{service.shortName::exists[\" ({service.shortName})\"||\"\"]}{stream.filename::exists[\"\n{stream.filename}\"||\"\"]}" + const val DESCRIPTION_TEMPLATE = "" + + const val LEGACY_NAME_TEMPLATE = "{stream.resolution::=2160p[\"4K \"||\"\"]}{stream.resolution::=1440p[\"QHD \"||\"\"]}{stream.resolution::=1080p[\"FHD \"||\"\"]}{stream.resolution::=720p[\"HD \"||\"\"]}{service.shortName::exists[\"{service.shortName} \"||\"Debrid \"]}{service.cached::istrue[\"Ready\"||\"Not Ready\"]}" + + const val LEGACY_DESCRIPTION_TEMPLATE = "{stream.title::exists[\"{stream.title::title} \"||\"\"]}{stream.year::exists[\"({stream.year})\"||\"\"]}\n{stream.quality::exists[\"{stream.quality} \"||\"\"]}{stream.visualTags::exists[\"{stream.visualTags::join(' | ')} \"||\"\"]}{stream.encode::exists[\"{stream.encode} \"||\"\"]}\n{stream.audioTags::exists[\"{stream.audioTags::join(' | ')}\"||\"\"]}{stream.audioTags::exists::and::stream.audioChannels::exists[\" | \"||\"\"]}{stream.audioChannels::exists[\"{stream.audioChannels::join(' | ')}\"||\"\"]}\n{stream.size::>0[\"{stream.size::bytes} \"||\"\"]}{stream.releaseGroup::exists[\"{stream.releaseGroup} \"||\"\"]}{stream.indexer::exists[\"{stream.indexer}\"||\"\"]}\n{service.cached::istrue[\"Ready\"||\"Not Ready\"]}{service.shortName::exists[\" ({service.shortName})\"||\"\"]}{stream.filename::exists[\"\n{stream.filename}\"||\"\"]}" } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt index 68505de3..1f9d6d2d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt @@ -10,12 +10,19 @@ object DebridStreamPresentation { fun apply(groups: List, settings: DebridSettings): List { if (!settings.enabled) return groups return groups.map { group -> - val debridStreams = group.streams.filter { stream -> stream.isManagedDebridStream } - if (debridStreams.isEmpty()) return@map group + val visibleStreams = group.streams.filterNot { stream -> stream.isUncachedDebridStream } + val debridStreams = visibleStreams.filter { stream -> stream.isManagedDebridStream } + if (debridStreams.isEmpty()) return@map group.copy(streams = visibleStreams) val presentedDebridStreams = applyPreferences(debridStreams, settings) - .map { stream -> formatter.format(stream, settings) } - val passthroughStreams = group.streams.filterNot { stream -> stream.isManagedDebridStream } + .map { stream -> + if (settings.hasCustomStreamFormatting) { + formatter.format(stream, settings) + } else { + stream + } + } + val passthroughStreams = visibleStreams.filterNot { stream -> stream.isManagedDebridStream } group.copy(streams = presentedDebridStreams + passthroughStreams) } @@ -33,14 +40,19 @@ object DebridStreamPresentation { internal val StreamItem.isManagedDebridStream: Boolean get() { val status = debridCacheStatus - return isDirectDebridStream || ( + return isAddonDebridCandidate && (isDirectDebridStream || ( isTorrentStream && status != null && status.providerId == DebridProviders.TORBOX_ID && status.state != StreamDebridCacheState.CHECKING - ) + )) } + private val StreamItem.isUncachedDebridStream: Boolean + get() = isInstalledAddonStream && + debridCacheStatus?.providerId == DebridProviders.TORBOX_ID && + debridCacheStatus.state == StreamDebridCacheState.NOT_CACHED + private fun applyLimits( streams: List>, preferences: DebridStreamPreferences, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt index 7aff970b..68968c35 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt @@ -115,9 +115,9 @@ object DirectDebridPlaybackResolver { val settings = DebridSettingsRepository.snapshot() if (!settings.enabled) return false if (stream.needsLocalDebridResolve) { - return settings.torboxApiKey.isNotBlank() + return stream.isInstalledAddonStream && settings.torboxApiKey.isNotBlank() } - if (!stream.isDirectDebridStream || stream.playableDirectUrl != null) { + if (!stream.isInstalledAddonStream || !stream.isDirectDebridStream || stream.playableDirectUrl != null) { return false } return when (DebridProviders.byId(stream.clientResolve?.service)?.id) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt index 7cd37661..f9e99075 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt @@ -73,6 +73,7 @@ object DirectDebridStreamPreparer { val candidates = streams .filter { stream -> stream.playableDirectUrl == null && + stream.isAddonDebridCandidate && (stream.isDirectDebridStream || stream.isCachedDebridTorrentStream) } .distinctBy { it.preparationKey() } @@ -88,7 +89,7 @@ object DirectDebridStreamPreparer { selectedAddons = playerSettings.streamAutoPlaySelectedAddons, selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, ) - if (autoPlaySelection?.let { it.isDirectDebridStream || it.isCachedDebridTorrentStream } == true) { + if (autoPlaySelection?.let { it.isAddonDebridCandidate && (it.isDirectDebridStream || it.isCachedDebridTorrentStream) } == true) { candidates.firstOrNull { it.preparationKey() == autoPlaySelection.preparationKey() } ?.let(prioritized::add) } @@ -118,9 +119,11 @@ object DirectDebridStreamPreparer { groups: List, original: StreamItem, prepared: StreamItem, + eligibleGroupIds: Set? = null, ): List { val key = original.preparationKey() return groups.map { group -> + if (eligibleGroupIds != null && group.addonId !in eligibleGroupIds) return@map group var changed = false val updatedStreams = group.streams.map { stream -> if (stream.preparationKey() == key) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/TorboxAvailabilityService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/TorboxAvailabilityService.kt index 9dbd3876..39cb0e07 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/TorboxAvailabilityService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/TorboxAvailabilityService.kt @@ -7,10 +7,13 @@ import com.nuvio.app.features.streams.StreamItem import kotlinx.coroutines.CancellationException object TorboxAvailabilityService { - fun markChecking(groups: List): List { + fun markChecking( + groups: List, + eligibleGroupIds: Set? = null, + ): List { val settings = DebridSettingsRepository.snapshot() if (!settings.enabled || settings.torboxApiKey.isBlank()) return groups - return groups.updateAvailabilityStatus { stream -> + return groups.updateAvailabilityStatus(eligibleGroupIds) { stream -> if (stream.torboxAvailabilityHash() == null || stream.debridCacheStatus?.state == StreamDebridCacheState.CACHED) { stream } else { @@ -25,18 +28,27 @@ object TorboxAvailabilityService { } } - suspend fun annotateCachedAvailability(groups: List): List { + suspend fun annotateCachedAvailability( + groups: List, + eligibleGroupIds: Set? = null, + ): List { val settings = DebridSettingsRepository.snapshot() val apiKey = settings.torboxApiKey.trim() if (!settings.enabled || apiKey.isBlank()) return groups val hashes = groups - .flatMap { group -> group.streams.mapNotNull { stream -> stream.torboxAvailabilityHash() } } + .filter { group -> eligibleGroupIds == null || group.addonId in eligibleGroupIds } + .flatMap { group -> + group.streams.mapNotNull { stream -> + stream.torboxAvailabilityHash() + ?.takeUnless { stream.debridCacheStatus?.state in FINAL_CACHE_STATES } + } + } .distinct() if (hashes.isEmpty()) return groups val cached = checkCached(apiKey = apiKey, hashes = hashes) - ?: return groups.updateAvailabilityStatus { stream -> + ?: return groups.updateAvailabilityStatus(eligibleGroupIds) { stream -> val hash = stream.torboxAvailabilityHash() if (hash == null) { stream @@ -51,8 +63,9 @@ object TorboxAvailabilityService { } } - return groups.updateAvailabilityStatus { stream -> + return groups.updateAvailabilityStatus(eligibleGroupIds) { stream -> val hash = stream.torboxAvailabilityHash() ?: return@updateAvailabilityStatus stream + if (stream.debridCacheStatus?.state in FINAL_CACHE_STATES) return@updateAvailabilityStatus stream val cachedItem = cached[hash] stream.copy( debridCacheStatus = StreamDebridCacheStatus( @@ -91,16 +104,23 @@ object TorboxAvailabilityService { } } +private val FINAL_CACHE_STATES = setOf( + StreamDebridCacheState.CACHED, + StreamDebridCacheState.NOT_CACHED, +) + internal fun StreamItem.torboxAvailabilityHash(): String? = infoHash ?.trim() ?.lowercase() - ?.takeIf { needsLocalDebridResolve && it.isNotBlank() } + ?.takeIf { isInstalledAddonStream && needsLocalDebridResolve && it.isNotBlank() } private fun List.updateAvailabilityStatus( + eligibleGroupIds: Set?, transform: (StreamItem) -> StreamItem, ): List = map { group -> + if (eligibleGroupIds != null && group.addonId !in eligibleGroupIds) return@map group var changed = false val updatedStreams = group.streams.map { stream -> val updated = transform(stream) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index d8bfbf27..bad8527f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -80,6 +80,7 @@ import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.library.toLibraryItem import com.nuvio.app.features.player.PlayerSettingsRepository +import com.nuvio.app.features.streams.AddonStreamWarmupRepository import com.nuvio.app.features.streams.StreamAutoPlayPolicy import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.trakt.TraktAuthRepository @@ -372,6 +373,29 @@ fun MetaDetailsScreen( seriesActionVideo?.id?.takeIf { it.isNotBlank() } ?: action.videoId } val hasEpisodes = meta.videos.any { it.season != null || it.episode != null } + val debridWarmupTarget = remember(meta.id, meta.type, hasEpisodes, seriesStreamVideoId, seriesAction) { + if (meta.isSeriesLikeForDebridWarmup(hasEpisodes)) { + DetailDebridWarmupTarget( + videoId = seriesStreamVideoId ?: seriesAction?.videoId ?: meta.id, + season = seriesAction?.seasonNumber, + episode = seriesAction?.episodeNumber, + ) + } else { + DetailDebridWarmupTarget( + videoId = meta.id, + season = null, + episode = null, + ) + } + } + LaunchedEffect(meta.type, debridWarmupTarget) { + AddonStreamWarmupRepository.preload( + type = meta.type, + videoId = debridWarmupTarget.videoId, + season = debridWarmupTarget.season, + episode = debridWarmupTarget.episode, + ) + } val hasProductionSection = remember(meta) { meta.productionCompanies.isNotEmpty() || meta.networks.isNotEmpty() } @@ -1259,3 +1283,14 @@ private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp = } else { (maxWidth * 0.6f).coerceIn(520.dp, 680.dp) } + +private data class DetailDebridWarmupTarget( + val videoId: String, + val season: Int?, + val episode: Int?, +) + +private fun MetaDetails.isSeriesLikeForDebridWarmup(hasEpisodes: Boolean): Boolean = + hasEpisodes || type.equals("series", ignoreCase = true) || + type.equals("show", ignoreCase = true) || + type.equals("tv", ignoreCase = true) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index dffdff27..4a17a108 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt @@ -14,6 +14,7 @@ import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.plugins.pluginContentId import com.nuvio.app.features.plugins.PluginRuntimeResult import com.nuvio.app.features.plugins.PluginScraper +import com.nuvio.app.features.streams.AddonStreamWarmupRepository import com.nuvio.app.features.streams.AddonStreamGroup import com.nuvio.app.features.streams.StreamAutoPlaySelector import com.nuvio.app.features.streams.StreamItem @@ -203,8 +204,13 @@ object PlayerStreamsRepository { } val installedAddonOrder = streamAddons.map { it.addonName } + val warmedAddonGroups = AddonStreamWarmupRepository + .cachedGroups(type = type, videoId = videoId, season = season, episode = episode) + .orEmpty() + .associateBy { it.addonId } + val warmedAddonIds = warmedAddonGroups.keys val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon -> - AddonStreamGroup( + warmedAddonGroups[addon.addonId] ?: AddonStreamGroup( addonName = addon.addonName, addonId = addon.addonId, streams = emptyList(), @@ -218,14 +224,17 @@ object PlayerStreamsRepository { isLoading = true, ) }, installedAddonOrder) + val isInitiallyLoading = initialGroups.any { it.isLoading } stateFlow.value = StreamsUiState( groups = initialGroups, activeAddonIds = initialGroups.map { it.addonId }.toSet(), - isAnyLoading = true, + isAnyLoading = isInitiallyLoading, ) val job = scope.launch { - val addonJobs = streamAddons.map { addon -> + val pendingStreamAddons = streamAddons.filterNot { it.addonId in warmedAddonIds } + val installedAddonIds = streamAddons.map { it.addonId }.toSet() + val addonJobs = pendingStreamAddons.map { addon -> async { val url = buildAddonResourceUrl( manifestUrl = addon.manifest.transportUrl, @@ -316,9 +325,15 @@ object PlayerStreamsRepository { } if (!debridPreparationLaunched) { debridPreparationLaunched = true - val checkingGroups = TorboxAvailabilityService.markChecking(stateFlow.value.groups) + val checkingGroups = TorboxAvailabilityService.markChecking( + groups = stateFlow.value.groups, + eligibleGroupIds = installedAddonIds, + ) stateFlow.update { current -> current.copy(groups = checkingGroups) } - val availabilityGroups = TorboxAvailabilityService.annotateCachedAvailability(stateFlow.value.groups) + val availabilityGroups = TorboxAvailabilityService.annotateCachedAvailability( + groups = stateFlow.value.groups, + eligibleGroupIds = installedAddonIds, + ) val presentedGroups = DebridStreamPresentation.apply( groups = availabilityGroups, settings = DebridSettingsRepository.snapshot(), @@ -326,7 +341,9 @@ object PlayerStreamsRepository { stateFlow.update { current -> current.copy(groups = presentedGroups) } launch { DirectDebridStreamPreparer.prepare( - streams = stateFlow.value.groups.flatMap { it.streams }, + streams = stateFlow.value.groups + .filter { it.addonId in installedAddonIds } + .flatMap { it.streams }, season = season, episode = episode, playerSettings = playerSettings, @@ -338,6 +355,7 @@ object PlayerStreamsRepository { groups = current.groups, original = original, prepared = prepared, + eligibleGroupIds = installedAddonIds, ), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt index cbc9eece..86c8a4b7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt @@ -351,7 +351,7 @@ private fun templatePreview(value: String): String { .lineSequence() .map { it.trim() } .firstOrNull { it.isNotBlank() } - ?: return "" + ?: return "Addon default" return if (firstLine.length <= 28) firstLine else "${firstLine.take(28)}..." } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt new file mode 100644 index 00000000..d5ab7be1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt @@ -0,0 +1,304 @@ +package com.nuvio.app.features.streams + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.addons.AddonManifest +import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.addons.ManagedAddon +import com.nuvio.app.features.addons.buildAddonResourceUrl +import com.nuvio.app.features.addons.httpGetText +import com.nuvio.app.features.debrid.DebridSettings +import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.debrid.DebridStreamPresentation +import com.nuvio.app.features.debrid.DirectDebridStreamPreparer +import com.nuvio.app.features.debrid.TorboxAvailabilityService +import com.nuvio.app.features.player.PlayerSettingsRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +private const val ADDON_STREAM_WARMUP_CACHE_TTL_MS = 5L * 60L * 1000L + +object AddonStreamWarmupRepository { + private val log = Logger.withTag("AddonStreamWarmup") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val mutex = Mutex() + private val cache = mutableMapOf() + private val inFlight = mutableMapOf>>() + + fun preload(type: String, videoId: String, season: Int? = null, episode: Int? = null) { + val key = currentKey(type = type, videoId = videoId, season = season, episode = episode) ?: return + scope.launch { + runCatching { fetchWarmup(key) } + .onFailure { error -> + if (error is CancellationException) throw error + log.d(error) { "Addon stream warmup failed" } + } + } + } + + fun cachedGroups(type: String, videoId: String, season: Int? = null, episode: Int? = null): List? { + val key = currentKey(type = type, videoId = videoId, season = season, episode = episode) ?: return null + if (!mutex.tryLock()) return null + return try { + cachedGroupsLocked(key) + } finally { + mutex.unlock() + } + } + + private suspend fun fetchWarmup(key: AddonStreamWarmupKey): List { + cachedGroups(key.type, key.videoId, key.season, key.episode)?.let { return it } + + var ownsFetch = false + val newFetch = scope.async(start = CoroutineStart.LAZY) { + fetchWarmupUncached(key) + } + val activeFetch = mutex.withLock { + cachedGroupsLocked(key)?.let { cached -> + return@withLock null to cached + } + val existing = inFlight[key] + if (existing != null) { + existing to null + } else { + inFlight[key] = newFetch + ownsFetch = true + newFetch to null + } + } + activeFetch.second?.let { + newFetch.cancel() + return it + } + val deferred = activeFetch.first ?: return emptyList() + if (!ownsFetch) newFetch.cancel() + if (ownsFetch) deferred.start() + + return try { + val result = deferred.await() + val cacheableGroups = result.filter { it.streams.isNotEmpty() } + if (ownsFetch && cacheableGroups.isNotEmpty()) { + mutex.withLock { + cache[key] = CachedAddonStreamWarmup( + groups = cacheableGroups, + createdAtMs = epochMs(), + ) + } + } + result + } finally { + if (ownsFetch) { + mutex.withLock { + if (inFlight[key] === deferred) { + inFlight.remove(key) + } + } + } + } + } + + private suspend fun fetchWarmupUncached(key: AddonStreamWarmupKey): List { + val targets = key.addonTargets + if (targets.isEmpty()) return emptyList() + + val orderedGroups = coroutineScope { + targets.map { target -> + async { + fetchAddonStreams( + target = target, + type = key.type, + videoId = key.videoId, + ) + } + }.awaitAll() + }.let { groups -> + StreamAutoPlaySelector.orderAddonStreams( + groups = groups, + installedOrder = targets.map { it.addonName }, + ) + } + + val addonIds = targets.map { it.addonId }.toSet() + val availabilityGroups = TorboxAvailabilityService.annotateCachedAvailability( + groups = TorboxAvailabilityService.markChecking( + groups = orderedGroups, + eligibleGroupIds = addonIds, + ), + eligibleGroupIds = addonIds, + ) + var preparedGroups = DebridStreamPresentation.apply( + groups = availabilityGroups, + settings = key.settings, + ) + + PlayerSettingsRepository.ensureLoaded() + DirectDebridStreamPreparer.prepare( + streams = preparedGroups.flatMap { it.streams }, + season = key.season, + episode = key.episode, + playerSettings = PlayerSettingsRepository.uiState.value, + installedAddonNames = targets.map { it.addonName }.toSet(), + ) { original, prepared -> + preparedGroups = DirectDebridStreamPreparer.replacePreparedStream( + groups = preparedGroups, + original = original, + prepared = prepared, + eligibleGroupIds = addonIds, + ) + } + + return preparedGroups + } + + private suspend fun fetchAddonStreams( + target: AddonStreamWarmupTarget, + type: String, + videoId: String, + ): AddonStreamGroup { + val url = buildAddonResourceUrl( + manifestUrl = target.manifest.transportUrl, + resource = "stream", + type = type, + id = videoId, + ) + return runCatchingUnlessCancelled { + val payload = httpGetText(url) + StreamParser.parse( + payload = payload, + addonName = target.addonName, + addonId = target.addonId, + ) + }.fold( + onSuccess = { streams -> + AddonStreamGroup( + addonName = target.addonName, + addonId = target.addonId, + streams = streams, + isLoading = false, + ) + }, + onFailure = { error -> + log.d(error) { "Failed to warm addon stream target ${target.addonName}" } + AddonStreamGroup( + addonName = target.addonName, + addonId = target.addonId, + streams = emptyList(), + isLoading = false, + error = error.message, + ) + }, + ) + } + + private fun currentKey(type: String, videoId: String, season: Int?, episode: Int?): AddonStreamWarmupKey? { + val normalizedType = type.trim().lowercase() + val normalizedVideoId = videoId.trim() + if (normalizedType.isBlank() || normalizedVideoId.isBlank()) return null + + DebridSettingsRepository.ensureLoaded() + val settings = DebridSettingsRepository.snapshot() + if (!settings.enabled || settings.torboxApiKey.isBlank()) return null + + AddonRepository.initialize() + val addonTargets = AddonRepository.uiState.value.addons + .mapNotNull { addon -> addon.toWarmupTarget(normalizedType, normalizedVideoId) } + if (addonTargets.isEmpty()) return null + + return AddonStreamWarmupKey( + type = normalizedType, + videoId = normalizedVideoId, + season = season, + episode = episode, + addonFingerprint = addonTargets.joinToString("|") { it.fingerprint }, + settingsFingerprint = settings.warmupFingerprint(), + settings = settings, + addonTargets = addonTargets, + ) + } + + private fun cachedGroupsLocked(key: AddonStreamWarmupKey): List? { + val cached = cache[key] ?: return null + val age = epochMs() - cached.createdAtMs + return if (age in 0..ADDON_STREAM_WARMUP_CACHE_TTL_MS) { + cached.groups + } else { + cache.remove(key) + null + } + } +} + +private data class AddonStreamWarmupKey( + val type: String, + val videoId: String, + val season: Int?, + val episode: Int?, + val addonFingerprint: String, + val settingsFingerprint: String, + val settings: DebridSettings, + val addonTargets: List, +) + +private data class AddonStreamWarmupTarget( + val addonName: String, + val addonId: String, + val manifest: AddonManifest, + val fingerprint: String, +) + +private data class CachedAddonStreamWarmup( + val groups: List, + val createdAtMs: Long, +) + +private fun ManagedAddon.toWarmupTarget(type: String, videoId: String): AddonStreamWarmupTarget? { + val manifest = manifest ?: return null + val supportsRequestedStream = manifest.resources.any { resource -> + resource.name == "stream" && + resource.types.contains(type) && + (resource.idPrefixes.isEmpty() || resource.idPrefixes.any { videoId.startsWith(it) }) + } + if (!supportsRequestedStream) return null + + val addonName = displayTitle.ifBlank { manifest.name } + return AddonStreamWarmupTarget( + addonName = addonName, + addonId = "addon:${manifest.id}:$manifestUrl", + manifest = manifest, + fingerprint = "$manifestUrl:${manifest.id}:${manifest.version}:$addonName", + ) +} + +private fun DebridSettings.warmupFingerprint(): String = + listOf( + enabled, + torboxApiKey, + instantPlaybackPreparationLimit, + streamMaxResults, + streamSortMode, + streamMinimumQuality, + streamDolbyVisionFilter, + streamHdrFilter, + streamCodecFilter, + streamPreferences, + streamNameTemplate, + streamDescriptionTemplate, + ).joinToString("|") + +private suspend fun runCatchingUnlessCancelled(block: suspend () -> T): Result = + try { + Result.success(block()) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + Result.failure(error) + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt index f01c2392..a88faa7d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt @@ -17,7 +17,7 @@ object StreamAutoPlaySelector { val (directDebridEntries, remainingEntries) = groups.partition { group -> group.addonId.startsWith("debrid:") || - group.streams.any { stream -> stream.isDirectDebridStream } + group.streams.any { stream -> stream.isAddonDebridCandidate && stream.isDirectDebridStream } } if (installedOrder.isEmpty()) return directDebridEntries + remainingEntries @@ -117,5 +117,6 @@ object StreamAutoPlaySelector { } private fun StreamItem.isAutoPlayable(debridEnabled: Boolean): Boolean = - playableDirectUrl != null || (debridEnabled && (isDirectDebridStream || isCachedDebridTorrentStream)) + playableDirectUrl != null || + (debridEnabled && isAddonDebridCandidate && (isDirectDebridStream || isCachedDebridTorrentStream)) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index dd87cc15..55deebce 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -40,6 +40,9 @@ data class StreamItem( val isDirectDebridStream: Boolean get() = clientResolve?.isDirectDebridCandidate == true + val isInstalledAddonStream: Boolean + get() = addonId.startsWith("addon:") + val isTorrentStream: Boolean get() = !isDirectDebridStream && ( !infoHash.isNullOrBlank() || @@ -53,6 +56,9 @@ data class StreamItem( val needsLocalDebridResolve: Boolean get() = isTorrentStream && playableDirectUrl == null + val isAddonDebridCandidate: Boolean + get() = isInstalledAddonStream && (needsLocalDebridResolve || isDirectDebridStream) + val hasPlayableSource: Boolean get() = url != null || infoHash != null || externalUrl != null || clientResolve != null } @@ -61,7 +67,7 @@ private fun String?.isMagnetLink(): Boolean = this?.trimStart()?.startsWith("magnet:", ignoreCase = true) == true fun StreamItem.isSelectableForPlayback(debridEnabled: Boolean): Boolean = - playableDirectUrl != null || (debridEnabled && (needsLocalDebridResolve || isDirectDebridStream)) + playableDirectUrl != null || (debridEnabled && isAddonDebridCandidate) data class StreamBehaviorHints( val bingeGroup: String? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index 52f8965b..d86c8220 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -187,8 +187,13 @@ object StreamsRepository { // Initialise loading placeholders val installedAddonOrder = streamAddons.map { it.addonName } + val warmedAddonGroups = AddonStreamWarmupRepository + .cachedGroups(type = type, videoId = videoId, season = season, episode = episode) + .orEmpty() + .associateBy { it.addonId } + val warmedAddonIds = warmedAddonGroups.keys val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon -> - AddonStreamGroup( + warmedAddonGroups[addon.addonId] ?: AddonStreamGroup( addonName = addon.addonName, addonId = addon.addonId, streams = emptyList(), @@ -202,26 +207,29 @@ object StreamsRepository { isLoading = true, ) }, installedAddonOrder) + val isInitiallyLoading = initialGroups.any { it.isLoading } _uiState.value = StreamsUiState( requestToken = requestToken, groups = initialGroups, activeAddonIds = initialGroups.map { it.addonId }.toSet(), - isAnyLoading = true, + isAnyLoading = isInitiallyLoading, emptyStateReason = null, isDirectAutoPlayFlow = isDirectAutoPlayFlow, showDirectAutoPlayOverlay = isDirectAutoPlayFlow, ) activeJob = scope.launch { + val pendingStreamAddons = streamAddons.filterNot { it.addonId in warmedAddonIds } val completions = Channel(capacity = Channel.BUFFERED) val pluginRemainingByAddonId = pluginProviderGroups .associate { it.addonId to it.scrapers.size } .toMutableMap() val pluginFirstErrorByAddonId = mutableMapOf() - val totalTasks = streamAddons.size + + val totalTasks = pendingStreamAddons.size + pluginProviderGroups.sumOf { it.scrapers.size } val installedAddonNames = installedAddonOrder.toSet() + val installedAddonIds = streamAddons.map { it.addonId }.toSet() var autoSelectTriggered = false var timeoutElapsed = false var debridPreparationLaunched = false @@ -273,7 +281,7 @@ object StreamsRepository { null } - streamAddons.forEach { addon -> + pendingStreamAddons.forEach { addon -> launch { val url = buildAddonResourceUrl( manifestUrl = addon.manifest.transportUrl, @@ -425,9 +433,15 @@ object StreamsRepository { if (!debridPreparationLaunched) { debridPreparationLaunched = true - val checkingGroups = TorboxAvailabilityService.markChecking(_uiState.value.groups) + val checkingGroups = TorboxAvailabilityService.markChecking( + groups = _uiState.value.groups, + eligibleGroupIds = installedAddonIds, + ) _uiState.update { current -> current.copy(groups = checkingGroups) } - val availabilityGroups = TorboxAvailabilityService.annotateCachedAvailability(_uiState.value.groups) + val availabilityGroups = TorboxAvailabilityService.annotateCachedAvailability( + groups = _uiState.value.groups, + eligibleGroupIds = installedAddonIds, + ) val presentedGroups = DebridStreamPresentation.apply( groups = availabilityGroups, settings = debridSettings, @@ -435,7 +449,9 @@ object StreamsRepository { _uiState.update { current -> current.copy(groups = presentedGroups) } launch { DirectDebridStreamPreparer.prepare( - streams = _uiState.value.groups.flatMap { it.streams }, + streams = _uiState.value.groups + .filter { it.addonId in installedAddonIds } + .flatMap { it.streams }, season = season, episode = episode, playerSettings = playerSettings, @@ -447,6 +463,7 @@ object StreamsRepository { groups = current.groups, original = original, prepared = prepared, + eligibleGroupIds = installedAddonIds, ), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index 8b0cf9d3..c843a164 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -3,9 +3,12 @@ package com.nuvio.app.features.streams import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable @@ -85,6 +88,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.rememberModalBottomSheetState import coil3.compose.AsyncImage import com.nuvio.app.core.ui.nuvioSafeBottomPadding +import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository @@ -221,6 +225,7 @@ fun StreamsScreen( episodeTitle = episodeTitle, uiState = uiState, debridEnabled = debridSettings.enabled, + appendInstantServiceToDefaultName = !debridSettings.hasCustomStreamFormatting, resumePositionMs = effectiveResumePositionMs, resumeProgressFraction = effectiveResumeProgressFraction, onStreamSelected = { stream, positionMs, progressFraction -> @@ -239,6 +244,7 @@ fun StreamsScreen( episodeTitle = episodeTitle, uiState = uiState, debridEnabled = debridSettings.enabled, + appendInstantServiceToDefaultName = !debridSettings.hasCustomStreamFormatting, resumePositionMs = effectiveResumePositionMs, resumeProgressFraction = effectiveResumeProgressFraction, onStreamSelected = { stream, positionMs, progressFraction -> @@ -385,6 +391,7 @@ private fun MobileStreamsLayout( episodeTitle: String?, uiState: StreamsUiState, debridEnabled: Boolean, + appendInstantServiceToDefaultName: Boolean, resumePositionMs: Long?, resumeProgressFraction: Float?, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, @@ -466,6 +473,7 @@ private fun MobileStreamsLayout( StreamList( uiState = uiState, debridEnabled = debridEnabled, + appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, onStreamSelected = onStreamSelected, onStreamLongPress = onStreamLongPress, resumePositionMs = resumePositionMs, @@ -760,6 +768,7 @@ private fun FilterChip( internal fun StreamList( uiState: StreamsUiState, debridEnabled: Boolean, + appendInstantServiceToDefaultName: Boolean, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, onStreamLongPress: (StreamItem) -> Unit, resumePositionMs: Long?, @@ -799,6 +808,7 @@ internal fun StreamList( group = group, showHeader = uiState.selectedFilter == null, debridEnabled = debridEnabled, + appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, onStreamSelected = onStreamSelected, onStreamLongPress = onStreamLongPress, resumePositionMs = resumePositionMs, @@ -823,6 +833,7 @@ private fun LazyListScope.streamSection( group: AddonStreamGroup, showHeader: Boolean, debridEnabled: Boolean, + appendInstantServiceToDefaultName: Boolean, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, onStreamLongPress: (StreamItem) -> Unit, resumePositionMs: Long?, @@ -867,6 +878,7 @@ private fun LazyListScope.streamSection( StreamCard( stream = stream, enabled = stream.isSelectableForPlayback(debridEnabled), + appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, onClick = { if (stream.isSelectableForPlayback(debridEnabled)) { onStreamSelected(stream, resumePositionMs, resumeProgressFraction) @@ -971,6 +983,7 @@ private fun StreamSourceHeader( private fun StreamCard( stream: StreamItem, enabled: Boolean, + appendInstantServiceToDefaultName: Boolean, onClick: () -> Unit, onLongClick: (() -> Unit)? = null, modifier: Modifier = Modifier, @@ -997,15 +1010,9 @@ private fun StreamCard( verticalAlignment = Alignment.Top, ) { Column(modifier = Modifier.weight(1f)) { - Text( - text = stream.streamLabel, - style = MaterialTheme.typography.bodyMedium.copy( - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - lineHeight = 20.sp, - letterSpacing = 0.1.sp, - ), - color = MaterialTheme.colorScheme.onSurface, + StreamNameWithInstantService( + stream = stream, + appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, ) val subtitle = stream.streamSubtitle @@ -1022,14 +1029,68 @@ private fun StreamCard( } Spacer(modifier = Modifier.height(6.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - StreamAvailabilityBadge(stream = stream) + Row(verticalAlignment = Alignment.CenterVertically) { StreamFileSizeBadge(stream = stream) } } } } +@Composable +private fun StreamNameWithInstantService( + stream: StreamItem, + appendInstantServiceToDefaultName: Boolean, +) { + val nameStyle = MaterialTheme.typography.bodyMedium.copy( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + lineHeight = 20.sp, + letterSpacing = 0.sp, + ) + val instantLabel = if (appendInstantServiceToDefaultName) { + stream.instantServiceLabel() + } else { + null + } + val showInstantLabel = instantLabel != null + val visibleState = remember(stream.streamLabel) { + MutableTransitionState(showInstantLabel) + } + visibleState.targetState = showInstantLabel + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stream.streamLabel, + modifier = Modifier.weight(1f, fill = false), + style = nameStyle, + color = MaterialTheme.colorScheme.onSurface, + ) + AnimatedVisibility( + visibleState = visibleState, + enter = fadeIn(animationSpec = tween(durationMillis = 260)) + + expandHorizontally( + animationSpec = tween(durationMillis = 260), + expandFrom = Alignment.Start, + ), + exit = fadeOut(animationSpec = tween(durationMillis = 120)) + + shrinkHorizontally( + animationSpec = tween(durationMillis = 120), + shrinkTowards = Alignment.Start, + ), + label = "streamNameInstantService", + ) { + Text( + text = " ${instantLabel.orEmpty()}", + style = nameStyle, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun StreamActionsSheet( @@ -1128,48 +1189,13 @@ private fun StreamActionsSheet( } } -@Composable -private fun StreamAvailabilityBadge(stream: StreamItem) { - val status = stream.debridCacheStatus ?: return - val (label, background, foreground) = when (status.state) { - StreamDebridCacheState.CHECKING -> Triple( - "${status.providerName} checking", - MaterialTheme.colorScheme.primary.copy(alpha = 0.18f), - MaterialTheme.colorScheme.primary, - ) - StreamDebridCacheState.CACHED -> Triple( - "${status.providerName} ready", - Color(0xFF123D2B), - Color(0xFFB8F6D3), - ) - StreamDebridCacheState.NOT_CACHED -> Triple( - "${status.providerName} not cached", - Color(0xFF3D2424), - Color(0xFFFFC9C9), - ) - StreamDebridCacheState.UNKNOWN -> Triple( - "${status.providerName} unknown", - Color(0xFF2C3033), - MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Box( - modifier = Modifier - .clip(RoundedCornerShape(12.dp)) - .background(background) - .padding(horizontal = 8.dp, vertical = 3.dp), - ) { - Text( - text = label, - style = MaterialTheme.typography.labelSmall.copy( - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = 0.2.sp, - ), - color = foreground, - ) - } +private fun StreamItem.instantServiceLabel(): String? { + val status = debridCacheStatus ?: return null + if (status.state != StreamDebridCacheState.CACHED) return null + val providerLabel = DebridProviders.shortName(status.providerId) + .ifBlank { status.providerName.trim() } + .ifBlank { DebridProviders.displayName(status.providerId) } + return "- $providerLabel Instant" } @Composable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt index 9b7ffbda..5ed13ae7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt @@ -61,6 +61,7 @@ internal fun TabletStreamsLayout( episodeTitle: String?, uiState: StreamsUiState, debridEnabled: Boolean, + appendInstantServiceToDefaultName: Boolean, resumePositionMs: Long?, resumeProgressFraction: Float?, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, @@ -201,6 +202,7 @@ internal fun TabletStreamsLayout( StreamList( uiState = uiState, debridEnabled = debridEnabled, + appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, onStreamSelected = onStreamSelected, onStreamLongPress = onStreamLongPress, resumePositionMs = resumePositionMs, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt index 07877814..6abf6975 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt @@ -75,13 +75,45 @@ class DebridStreamPresentationTest { ), ).single().streams - assertEquals(listOf("4K TB Ready", "FHD TB Ready", "Resolved addon URL"), presented.map { it.name }) + assertEquals(listOf("Large", "Mid", "Resolved addon URL"), presented.map { it.name }) + } + + @Test + fun `hides addon torrent streams that are not cached`() { + val cached = localTorboxStream( + name = "Cached", + filename = "Movie.1080p.WEB-DL.HEVC-GRP.mkv", + size = 10_000_000_000, + ) + val uncached = localTorboxStream( + name = "Uncached", + filename = "Movie.2160p.WEB-DL.HEVC-GRP.mkv", + size = 20_000_000_000, + cacheState = StreamDebridCacheState.NOT_CACHED, + ) + + val presented = DebridStreamPresentation.apply( + groups = listOf( + AddonStreamGroup( + addonName = "Addon", + addonId = "addon:test", + streams = listOf(cached, uncached), + ), + ), + settings = DebridSettings( + enabled = true, + torboxApiKey = "key", + ), + ).single().streams + + assertEquals(listOf("Cached"), presented.map { it.name }) } private fun localTorboxStream( name: String = "Torrent", filename: String, size: Long, + cacheState: StreamDebridCacheState = StreamDebridCacheState.CACHED, ): StreamItem = StreamItem( name = name, @@ -95,7 +127,7 @@ class DebridStreamPresentationTest { debridCacheStatus = StreamDebridCacheStatus( providerId = DebridProviders.TORBOX_ID, providerName = DebridProviders.Torbox.displayName, - state = StreamDebridCacheState.CACHED, + state = cacheState, cachedName = filename, cachedSize = size, ),