mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 07:21:58 +00:00
parent
17c747e5c1
commit
ee66440bf5
9 changed files with 135 additions and 21 deletions
|
|
@ -1338,7 +1338,13 @@ private fun MainAppContent(
|
||||||
reuseHandled = true
|
reuseHandled = true
|
||||||
if (launch.manualSelection) return@LaunchedEffect
|
if (launch.manualSelection) return@LaunchedEffect
|
||||||
if (!playerSettings.streamReuseLastLinkEnabled) return@LaunchedEffect
|
if (!playerSettings.streamReuseLastLinkEnabled) return@LaunchedEffect
|
||||||
val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId)
|
val cacheKey = StreamLinkCacheRepository.contentKey(
|
||||||
|
type = launch.type,
|
||||||
|
videoId = effectiveVideoId,
|
||||||
|
parentMetaId = launch.parentMetaId,
|
||||||
|
season = launch.seasonNumber,
|
||||||
|
episode = launch.episodeNumber,
|
||||||
|
)
|
||||||
val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L
|
val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L
|
||||||
val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs)
|
val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs)
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
|
|
@ -1378,17 +1384,37 @@ private fun MainAppContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
val streamsUiState by StreamsRepository.uiState.collectAsStateWithLifecycle()
|
val streamsUiState by StreamsRepository.uiState.collectAsStateWithLifecycle()
|
||||||
|
val expectedStreamsRequestToken = StreamsRepository.requestToken(
|
||||||
|
type = launch.type,
|
||||||
|
videoId = effectiveVideoId,
|
||||||
|
season = launch.seasonNumber,
|
||||||
|
episode = launch.episodeNumber,
|
||||||
|
manualSelection = launch.manualSelection,
|
||||||
|
)
|
||||||
var autoPlayHandled by rememberSaveable(launch.videoId, effectiveVideoId) { mutableStateOf(false) }
|
var autoPlayHandled by rememberSaveable(launch.videoId, effectiveVideoId) { mutableStateOf(false) }
|
||||||
LaunchedEffect(streamsUiState.autoPlayStream, reuseHandled, launch.manualSelection) {
|
LaunchedEffect(
|
||||||
|
streamsUiState.autoPlayStream,
|
||||||
|
streamsUiState.requestToken,
|
||||||
|
expectedStreamsRequestToken,
|
||||||
|
reuseHandled,
|
||||||
|
launch.manualSelection,
|
||||||
|
) {
|
||||||
if (!reuseHandled) return@LaunchedEffect
|
if (!reuseHandled) return@LaunchedEffect
|
||||||
if (launch.manualSelection) return@LaunchedEffect
|
if (launch.manualSelection) return@LaunchedEffect
|
||||||
if (reuseNavigated) return@LaunchedEffect
|
if (reuseNavigated) return@LaunchedEffect
|
||||||
if (autoPlayHandled) return@LaunchedEffect
|
if (autoPlayHandled) return@LaunchedEffect
|
||||||
|
if (streamsUiState.requestToken != expectedStreamsRequestToken) return@LaunchedEffect
|
||||||
val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect
|
val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect
|
||||||
val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect
|
val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect
|
||||||
autoPlayHandled = true
|
autoPlayHandled = true
|
||||||
if (playerSettings.streamReuseLastLinkEnabled) {
|
if (playerSettings.streamReuseLastLinkEnabled) {
|
||||||
val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId)
|
val cacheKey = StreamLinkCacheRepository.contentKey(
|
||||||
|
type = launch.type,
|
||||||
|
videoId = effectiveVideoId,
|
||||||
|
parentMetaId = launch.parentMetaId,
|
||||||
|
season = launch.seasonNumber,
|
||||||
|
episode = launch.episodeNumber,
|
||||||
|
)
|
||||||
StreamLinkCacheRepository.save(
|
StreamLinkCacheRepository.save(
|
||||||
contentKey = cacheKey,
|
contentKey = cacheKey,
|
||||||
url = sourceUrl,
|
url = sourceUrl,
|
||||||
|
|
@ -1468,7 +1494,13 @@ private fun MainAppContent(
|
||||||
if (sourceUrl != null) {
|
if (sourceUrl != null) {
|
||||||
// Persist for Reuse Last Link
|
// Persist for Reuse Last Link
|
||||||
if (playerSettings.streamReuseLastLinkEnabled) {
|
if (playerSettings.streamReuseLastLinkEnabled) {
|
||||||
val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId)
|
val cacheKey = StreamLinkCacheRepository.contentKey(
|
||||||
|
type = launch.type,
|
||||||
|
videoId = effectiveVideoId,
|
||||||
|
parentMetaId = launch.parentMetaId,
|
||||||
|
season = launch.seasonNumber,
|
||||||
|
episode = launch.episodeNumber,
|
||||||
|
)
|
||||||
StreamLinkCacheRepository.save(
|
StreamLinkCacheRepository.save(
|
||||||
contentKey = cacheKey,
|
contentKey = cacheKey,
|
||||||
url = sourceUrl,
|
url = sourceUrl,
|
||||||
|
|
|
||||||
|
|
@ -791,8 +791,11 @@ fun PlayerScreen(
|
||||||
flushWatchProgress()
|
flushWatchProgress()
|
||||||
if (playerSettingsUiState.streamReuseLastLinkEnabled && activeVideoId != null) {
|
if (playerSettingsUiState.streamReuseLastLinkEnabled && activeVideoId != null) {
|
||||||
val cacheKey = StreamLinkCacheRepository.contentKey(
|
val cacheKey = StreamLinkCacheRepository.contentKey(
|
||||||
contentType ?: parentMetaType,
|
type = contentType ?: parentMetaType,
|
||||||
activeVideoId!!,
|
videoId = activeVideoId!!,
|
||||||
|
parentMetaId = parentMetaId,
|
||||||
|
season = activeSeasonNumber,
|
||||||
|
episode = activeEpisodeNumber,
|
||||||
)
|
)
|
||||||
StreamLinkCacheRepository.save(
|
StreamLinkCacheRepository.save(
|
||||||
contentKey = cacheKey,
|
contentKey = cacheKey,
|
||||||
|
|
@ -851,8 +854,11 @@ fun PlayerScreen(
|
||||||
val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L
|
val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L
|
||||||
if (playerSettingsUiState.streamReuseLastLinkEnabled) {
|
if (playerSettingsUiState.streamReuseLastLinkEnabled) {
|
||||||
val cacheKey = StreamLinkCacheRepository.contentKey(
|
val cacheKey = StreamLinkCacheRepository.contentKey(
|
||||||
contentType ?: parentMetaType,
|
type = contentType ?: parentMetaType,
|
||||||
epVideoId,
|
videoId = epVideoId,
|
||||||
|
parentMetaId = parentMetaId,
|
||||||
|
season = episode.season,
|
||||||
|
episode = episode.episode,
|
||||||
)
|
)
|
||||||
StreamLinkCacheRepository.save(
|
StreamLinkCacheRepository.save(
|
||||||
contentKey = cacheKey,
|
contentKey = cacheKey,
|
||||||
|
|
@ -1563,8 +1569,11 @@ fun PlayerScreen(
|
||||||
val currentVideoId = activeVideoId
|
val currentVideoId = activeVideoId
|
||||||
if (currentVideoId != null) {
|
if (currentVideoId != null) {
|
||||||
val cacheKey = StreamLinkCacheRepository.contentKey(
|
val cacheKey = StreamLinkCacheRepository.contentKey(
|
||||||
contentType ?: parentMetaType,
|
type = contentType ?: parentMetaType,
|
||||||
currentVideoId,
|
videoId = currentVideoId,
|
||||||
|
parentMetaId = parentMetaId,
|
||||||
|
season = activeSeasonNumber,
|
||||||
|
episode = activeEpisodeNumber,
|
||||||
)
|
)
|
||||||
StreamLinkCacheRepository.remove(cacheKey)
|
StreamLinkCacheRepository.remove(cacheKey)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,20 @@ internal expect fun epochMs(): Long
|
||||||
object StreamLinkCacheRepository {
|
object StreamLinkCacheRepository {
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
fun contentKey(type: String, videoId: String): String =
|
fun contentKey(
|
||||||
"${type.lowercase()}|$videoId"
|
type: String,
|
||||||
|
videoId: String,
|
||||||
|
parentMetaId: String? = null,
|
||||||
|
season: Int? = null,
|
||||||
|
episode: Int? = null,
|
||||||
|
): String {
|
||||||
|
val normalizedType = type.lowercase()
|
||||||
|
return if (!parentMetaId.isNullOrBlank() && season != null && episode != null) {
|
||||||
|
"$normalizedType|${parentMetaId.trim()}|s$season|e$episode|$videoId"
|
||||||
|
} else {
|
||||||
|
"$normalizedType|$videoId"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun save(
|
fun save(
|
||||||
contentKey: String,
|
contentKey: String,
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ enum class StreamsEmptyStateReason {
|
||||||
}
|
}
|
||||||
|
|
||||||
data class StreamsUiState(
|
data class StreamsUiState(
|
||||||
|
val requestToken: String? = null,
|
||||||
val groups: List<AddonStreamGroup> = emptyList(),
|
val groups: List<AddonStreamGroup> = emptyList(),
|
||||||
val activeAddonIds: Set<String> = emptySet(),
|
val activeAddonIds: Set<String> = emptySet(),
|
||||||
val selectedFilter: String? = null,
|
val selectedFilter: String? = null,
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,15 @@ object StreamsRepository {
|
||||||
private var activeJob: Job? = null
|
private var activeJob: Job? = null
|
||||||
private var activeRequestKey: String? = null
|
private var activeRequestKey: String? = null
|
||||||
|
|
||||||
|
fun requestToken(
|
||||||
|
type: String,
|
||||||
|
videoId: String,
|
||||||
|
season: Int? = null,
|
||||||
|
episode: Int? = null,
|
||||||
|
manualSelection: Boolean = false,
|
||||||
|
): String =
|
||||||
|
"$type::$videoId::$season::$episode::$manualSelection"
|
||||||
|
|
||||||
fun load(type: String, videoId: String, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) {
|
fun load(type: String, videoId: String, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) {
|
||||||
load(
|
load(
|
||||||
type = type,
|
type = type,
|
||||||
|
|
@ -65,7 +74,14 @@ object StreamsRepository {
|
||||||
} else {
|
} else {
|
||||||
PluginsUiState(pluginsEnabled = false)
|
PluginsUiState(pluginsEnabled = false)
|
||||||
}
|
}
|
||||||
val requestKey = "$type::$videoId::$season::$episode::$manualSelection::pluginsGrouped=${pluginUiState.groupStreamsByRepository}"
|
val requestToken = requestToken(
|
||||||
|
type = type,
|
||||||
|
videoId = videoId,
|
||||||
|
season = season,
|
||||||
|
episode = episode,
|
||||||
|
manualSelection = manualSelection,
|
||||||
|
)
|
||||||
|
val requestKey = "$requestToken::pluginsGrouped=${pluginUiState.groupStreamsByRepository}"
|
||||||
val currentState = _uiState.value
|
val currentState = _uiState.value
|
||||||
if (
|
if (
|
||||||
!forceRefresh &&
|
!forceRefresh &&
|
||||||
|
|
@ -78,7 +94,7 @@ object StreamsRepository {
|
||||||
|
|
||||||
activeRequestKey = requestKey
|
activeRequestKey = requestKey
|
||||||
activeJob?.cancel()
|
activeJob?.cancel()
|
||||||
_uiState.value = StreamsUiState()
|
_uiState.value = StreamsUiState(requestToken = requestToken)
|
||||||
|
|
||||||
PlayerSettingsRepository.ensureLoaded()
|
PlayerSettingsRepository.ensureLoaded()
|
||||||
val playerSettings = PlayerSettingsRepository.uiState.value
|
val playerSettings = PlayerSettingsRepository.uiState.value
|
||||||
|
|
@ -90,6 +106,7 @@ object StreamsRepository {
|
||||||
|
|
||||||
if (isDirectAutoPlayFlow) {
|
if (isDirectAutoPlayFlow) {
|
||||||
_uiState.value = StreamsUiState(
|
_uiState.value = StreamsUiState(
|
||||||
|
requestToken = requestToken,
|
||||||
isDirectAutoPlayFlow = true,
|
isDirectAutoPlayFlow = true,
|
||||||
showDirectAutoPlayOverlay = true,
|
showDirectAutoPlayOverlay = true,
|
||||||
)
|
)
|
||||||
|
|
@ -105,6 +122,7 @@ object StreamsRepository {
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
)
|
)
|
||||||
_uiState.value = StreamsUiState(
|
_uiState.value = StreamsUiState(
|
||||||
|
requestToken = requestToken,
|
||||||
groups = listOf(group),
|
groups = listOf(group),
|
||||||
activeAddonIds = setOf("embedded"),
|
activeAddonIds = setOf("embedded"),
|
||||||
isAnyLoading = false,
|
isAnyLoading = false,
|
||||||
|
|
@ -125,6 +143,7 @@ object StreamsRepository {
|
||||||
|
|
||||||
if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
|
if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
|
||||||
_uiState.value = StreamsUiState(
|
_uiState.value = StreamsUiState(
|
||||||
|
requestToken = requestToken,
|
||||||
isAnyLoading = false,
|
isAnyLoading = false,
|
||||||
emptyStateReason = StreamsEmptyStateReason.NoAddonsInstalled,
|
emptyStateReason = StreamsEmptyStateReason.NoAddonsInstalled,
|
||||||
)
|
)
|
||||||
|
|
@ -151,8 +170,9 @@ object StreamsRepository {
|
||||||
|
|
||||||
log.d { "Found ${streamAddons.size} addons for stream type=$type id=$videoId" }
|
log.d { "Found ${streamAddons.size} addons for stream type=$type id=$videoId" }
|
||||||
|
|
||||||
if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
|
if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
|
||||||
_uiState.value = StreamsUiState(
|
_uiState.value = StreamsUiState(
|
||||||
|
requestToken = requestToken,
|
||||||
isAnyLoading = false,
|
isAnyLoading = false,
|
||||||
emptyStateReason = StreamsEmptyStateReason.NoCompatibleAddons,
|
emptyStateReason = StreamsEmptyStateReason.NoCompatibleAddons,
|
||||||
)
|
)
|
||||||
|
|
@ -176,6 +196,7 @@ object StreamsRepository {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_uiState.value = StreamsUiState(
|
_uiState.value = StreamsUiState(
|
||||||
|
requestToken = requestToken,
|
||||||
groups = initialGroups,
|
groups = initialGroups,
|
||||||
activeAddonIds = initialGroups.map { it.addonId }.toSet(),
|
activeAddonIds = initialGroups.map { it.addonId }.toSet(),
|
||||||
isAnyLoading = true,
|
isAnyLoading = true,
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ fun StreamsScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(type, videoId, manualSelection) {
|
LaunchedEffect(type, videoId, seasonNumber, episodeNumber, manualSelection) {
|
||||||
StreamsRepository.load(
|
StreamsRepository.load(
|
||||||
type = type,
|
type = type,
|
||||||
videoId = videoId,
|
videoId = videoId,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
package com.nuvio.app.features.streams
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotEquals
|
||||||
|
|
||||||
|
class StreamLinkCacheRepositoryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `movie cache key keeps legacy type and video id shape`() {
|
||||||
|
val key = StreamLinkCacheRepository.contentKey(
|
||||||
|
type = "movie",
|
||||||
|
videoId = "tt123",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("movie|tt123", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `episode cache key is scoped to parent show and episode`() {
|
||||||
|
val firstEpisode = StreamLinkCacheRepository.contentKey(
|
||||||
|
type = "series",
|
||||||
|
videoId = "video-id",
|
||||||
|
parentMetaId = "tt999",
|
||||||
|
season = 1,
|
||||||
|
episode = 1,
|
||||||
|
)
|
||||||
|
val secondEpisode = StreamLinkCacheRepository.contentKey(
|
||||||
|
type = "series",
|
||||||
|
videoId = "video-id",
|
||||||
|
parentMetaId = "tt999",
|
||||||
|
season = 1,
|
||||||
|
episode = 2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNotEquals(firstEpisode, secondEpisode)
|
||||||
|
assertEquals("series|tt999|s1|e1|video-id", firstEpisode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
#Kotlin
|
#Kotlin
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
kotlin.daemon.jvmargs=-Xmx4096M
|
kotlin.daemon.jvmargs=-Xmx6144M
|
||||||
kotlin.native.jvmArgs=-Xmx6144M
|
kotlin.native.jvmArgs=-Xmx12288M
|
||||||
kotlin.mpp.enableCInteropCommonization=true
|
kotlin.mpp.enableCInteropCommonization=true
|
||||||
|
|
||||||
#Gradle
|
#Gradle
|
||||||
org.gradle.jvmargs=-Xmx6144M -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=1024m
|
org.gradle.jvmargs=-Xmx8192M -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=1536m
|
||||||
org.gradle.configuration-cache=true
|
org.gradle.configuration-cache=true
|
||||||
org.gradle.caching=true
|
org.gradle.caching=true
|
||||||
|
|
||||||
#Android
|
#Android
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
CURRENT_PROJECT_VERSION=58
|
CURRENT_PROJECT_VERSION=58
|
||||||
MARKETING_VERSION=0.1.18
|
MARKETING_VERSION=0.1.0
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue