mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-23 02:02:04 +00:00
Updated DebridStreamPresentation to filter out uncached streams and apply custom formatting based on settings.
This commit is contained in:
parent
2a550c4356
commit
41b0bd95ad
17 changed files with 600 additions and 100 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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}\"||\"\"]}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,12 +10,19 @@ object DebridStreamPresentation {
|
|||
fun apply(groups: List<AddonStreamGroup>, settings: DebridSettings): List<AddonStreamGroup> {
|
||||
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<Pair<StreamItem, DebridStreamFacts>>,
|
||||
preferences: DebridStreamPreferences,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<AddonStreamGroup>,
|
||||
original: StreamItem,
|
||||
prepared: StreamItem,
|
||||
eligibleGroupIds: Set<String>? = null,
|
||||
): List<AddonStreamGroup> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -7,10 +7,13 @@ import com.nuvio.app.features.streams.StreamItem
|
|||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
object TorboxAvailabilityService {
|
||||
fun markChecking(groups: List<AddonStreamGroup>): List<AddonStreamGroup> {
|
||||
fun markChecking(
|
||||
groups: List<AddonStreamGroup>,
|
||||
eligibleGroupIds: Set<String>? = null,
|
||||
): List<AddonStreamGroup> {
|
||||
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<AddonStreamGroup>): List<AddonStreamGroup> {
|
||||
suspend fun annotateCachedAvailability(
|
||||
groups: List<AddonStreamGroup>,
|
||||
eligibleGroupIds: Set<String>? = null,
|
||||
): List<AddonStreamGroup> {
|
||||
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<AddonStreamGroup>.updateAvailabilityStatus(
|
||||
eligibleGroupIds: Set<String>?,
|
||||
transform: (StreamItem) -> StreamItem,
|
||||
): List<AddonStreamGroup> =
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}..."
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AddonStreamWarmupKey, CachedAddonStreamWarmup>()
|
||||
private val inFlight = mutableMapOf<AddonStreamWarmupKey, Deferred<List<AddonStreamGroup>>>()
|
||||
|
||||
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<AddonStreamGroup>? {
|
||||
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<AddonStreamGroup> {
|
||||
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<AddonStreamGroup> {
|
||||
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<AddonStreamGroup>? {
|
||||
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<AddonStreamWarmupTarget>,
|
||||
)
|
||||
|
||||
private data class AddonStreamWarmupTarget(
|
||||
val addonName: String,
|
||||
val addonId: String,
|
||||
val manifest: AddonManifest,
|
||||
val fingerprint: String,
|
||||
)
|
||||
|
||||
private data class CachedAddonStreamWarmup(
|
||||
val groups: List<AddonStreamGroup>,
|
||||
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 <T> runCatchingUnlessCancelled(block: suspend () -> T): Result<T> =
|
||||
try {
|
||||
Result.success(block())
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Throwable) {
|
||||
Result.failure(error)
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<StreamLoadCompletion>(capacity = Channel.BUFFERED)
|
||||
val pluginRemainingByAddonId = pluginProviderGroups
|
||||
.associate { it.addonId to it.scrapers.size }
|
||||
.toMutableMap()
|
||||
val pluginFirstErrorByAddonId = mutableMapOf<String, String>()
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in a new issue