mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-19 13:55:45 +00:00
feat: Implement stream link caching and reuse settings for improved playback continuity
This commit is contained in:
parent
ec2980d70e
commit
6ddae9a236
15 changed files with 447 additions and 7 deletions
|
|
@ -13,6 +13,7 @@ import com.nuvio.app.features.player.PlayerSettingsStorage
|
|||
import com.nuvio.app.features.profiles.ProfileStorage
|
||||
import com.nuvio.app.features.settings.ThemeSettingsStorage
|
||||
import com.nuvio.app.features.watched.WatchedStorage
|
||||
import com.nuvio.app.features.streams.StreamLinkCacheStorage
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressStorage
|
||||
|
||||
|
|
@ -37,6 +38,7 @@ class MainActivity : ComponentActivity() {
|
|||
ThemeSettingsStorage.initialize(applicationContext)
|
||||
ContinueWatchingPreferencesStorage.initialize(applicationContext)
|
||||
WatchProgressStorage.initialize(applicationContext)
|
||||
StreamLinkCacheStorage.initialize(applicationContext)
|
||||
|
||||
setContent {
|
||||
App()
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ actual object PlayerSettingsStorage {
|
|||
private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language"
|
||||
private const val preferredSubtitleLanguageKey = "preferred_subtitle_language"
|
||||
private const val secondaryPreferredSubtitleLanguageKey = "secondary_preferred_subtitle_language"
|
||||
private const val streamReuseLastLinkEnabledKey = "stream_reuse_last_link_enabled"
|
||||
private const val streamReuseLastLinkCacheHoursKey = "stream_reuse_last_link_cache_hours"
|
||||
|
||||
private var preferences: SharedPreferences? = null
|
||||
|
||||
|
|
@ -88,4 +90,38 @@ actual object PlayerSettingsStorage {
|
|||
}
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadStreamReuseLastLinkEnabled(): Boolean? =
|
||||
preferences?.let { sharedPreferences ->
|
||||
val key = ProfileScopedKey.of(streamReuseLastLinkEnabledKey)
|
||||
if (sharedPreferences.contains(key)) {
|
||||
sharedPreferences.getBoolean(key, false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual fun saveStreamReuseLastLinkEnabled(enabled: Boolean) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putBoolean(ProfileScopedKey.of(streamReuseLastLinkEnabledKey), enabled)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadStreamReuseLastLinkCacheHours(): Int? =
|
||||
preferences?.let { sharedPreferences ->
|
||||
val key = ProfileScopedKey.of(streamReuseLastLinkCacheHoursKey)
|
||||
if (sharedPreferences.contains(key)) {
|
||||
sharedPreferences.getInt(key, 24)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual fun saveStreamReuseLastLinkCacheHours(hours: Int) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putInt(ProfileScopedKey.of(streamReuseLastLinkCacheHoursKey), hours)
|
||||
?.apply()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
package com.nuvio.app.features.streams
|
||||
|
||||
internal actual fun epochMs(): Long = System.currentTimeMillis()
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package com.nuvio.app.features.streams
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||
|
||||
actual object StreamLinkCacheStorage {
|
||||
private const val preferencesName = "nuvio_stream_link_cache"
|
||||
|
||||
private var preferences: SharedPreferences? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
actual fun loadEntry(hashedKey: String): String? =
|
||||
preferences?.getString(ProfileScopedKey.of(hashedKey), null)
|
||||
|
||||
actual fun saveEntry(hashedKey: String, payload: String) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putString(ProfileScopedKey.of(hashedKey), payload)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun removeEntry(hashedKey: String) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.remove(ProfileScopedKey.of(hashedKey))
|
||||
?.apply()
|
||||
}
|
||||
}
|
||||
|
|
@ -85,8 +85,10 @@ import com.nuvio.app.features.settings.AccountSettingsScreen
|
|||
import com.nuvio.app.features.settings.ThemeSettingsRepository
|
||||
import com.nuvio.app.features.streams.StreamContext
|
||||
import com.nuvio.app.features.streams.StreamContextStore
|
||||
import com.nuvio.app.features.streams.StreamLinkCacheRepository
|
||||
import com.nuvio.app.features.streams.StreamsRepository
|
||||
import com.nuvio.app.features.streams.StreamsScreen
|
||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||
import com.nuvio.app.features.watched.WatchedRepository
|
||||
import com.nuvio.app.features.watched.toWatchedItem
|
||||
import com.nuvio.app.features.watched.watchedItemKey
|
||||
|
|
@ -454,6 +456,52 @@ private fun MainAppContent(
|
|||
StreamContextStore.get(contextId)?.pauseDescription
|
||||
}
|
||||
}
|
||||
|
||||
val playerSettings by remember {
|
||||
PlayerSettingsRepository.ensureLoaded()
|
||||
PlayerSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
|
||||
// Reuse Last Link: auto-play from cache if enabled (only on first entry)
|
||||
var reuseHandled by remember(route.videoId) { mutableStateOf(false) }
|
||||
LaunchedEffect(route.videoId, playerSettings.streamReuseLastLinkEnabled) {
|
||||
if (reuseHandled) return@LaunchedEffect
|
||||
reuseHandled = true
|
||||
if (!playerSettings.streamReuseLastLinkEnabled) return@LaunchedEffect
|
||||
val cacheKey = StreamLinkCacheRepository.contentKey(route.type, route.videoId)
|
||||
val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L
|
||||
val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs)
|
||||
if (cached != null) {
|
||||
val launchId = PlayerLaunchStore.put(
|
||||
PlayerLaunch(
|
||||
title = route.title,
|
||||
sourceUrl = cached.url,
|
||||
logo = route.logo,
|
||||
poster = route.poster,
|
||||
background = route.background,
|
||||
seasonNumber = route.seasonNumber,
|
||||
episodeNumber = route.episodeNumber,
|
||||
episodeTitle = route.episodeTitle,
|
||||
episodeThumbnail = route.episodeThumbnail,
|
||||
streamTitle = cached.streamName,
|
||||
streamSubtitle = null,
|
||||
pauseDescription = pauseDescription,
|
||||
providerName = cached.addonName,
|
||||
providerAddonId = cached.addonId,
|
||||
contentType = route.type,
|
||||
videoId = route.videoId,
|
||||
parentMetaId = route.parentMetaId ?: route.videoId,
|
||||
parentMetaType = route.parentMetaType ?: route.type,
|
||||
initialPositionMs = route.resumePositionMs ?: 0L,
|
||||
)
|
||||
)
|
||||
route.streamContextId?.let(StreamContextStore::remove)
|
||||
navController.navigate(PlayerRoute(launchId = launchId)) {
|
||||
popUpTo<StreamRoute> { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StreamsScreen(
|
||||
type = route.type,
|
||||
videoId = route.videoId,
|
||||
|
|
@ -471,6 +519,19 @@ private fun MainAppContent(
|
|||
onStreamSelected = { stream ->
|
||||
val sourceUrl = stream.directPlaybackUrl
|
||||
if (sourceUrl != null) {
|
||||
// Persist for Reuse Last Link
|
||||
if (playerSettings.streamReuseLastLinkEnabled) {
|
||||
val cacheKey = StreamLinkCacheRepository.contentKey(route.type, route.videoId)
|
||||
StreamLinkCacheRepository.save(
|
||||
contentKey = cacheKey,
|
||||
url = sourceUrl,
|
||||
streamName = stream.streamLabel,
|
||||
addonName = stream.addonName,
|
||||
addonId = stream.addonId,
|
||||
filename = stream.behaviorHints.filename,
|
||||
videoSize = stream.behaviorHints.videoSize,
|
||||
)
|
||||
}
|
||||
val launchId = PlayerLaunchStore.put(
|
||||
PlayerLaunch(
|
||||
title = route.title,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ data class PlayerSettingsUiState(
|
|||
val secondaryPreferredAudioLanguage: String? = null,
|
||||
val preferredSubtitleLanguage: String = SubtitleLanguageOption.NONE,
|
||||
val secondaryPreferredSubtitleLanguage: String? = null,
|
||||
val streamReuseLastLinkEnabled: Boolean = false,
|
||||
val streamReuseLastLinkCacheHours: Int = 24,
|
||||
)
|
||||
|
||||
object PlayerSettingsRepository {
|
||||
|
|
@ -22,6 +24,8 @@ object PlayerSettingsRepository {
|
|||
private var secondaryPreferredAudioLanguage: String? = null
|
||||
private var preferredSubtitleLanguage = SubtitleLanguageOption.NONE
|
||||
private var secondaryPreferredSubtitleLanguage: String? = null
|
||||
private var streamReuseLastLinkEnabled = false
|
||||
private var streamReuseLastLinkCacheHours = 24
|
||||
|
||||
fun ensureLoaded() {
|
||||
if (hasLoaded) return
|
||||
|
|
@ -45,6 +49,8 @@ object PlayerSettingsRepository {
|
|||
?: SubtitleLanguageOption.NONE
|
||||
secondaryPreferredSubtitleLanguage =
|
||||
normalizeLanguageCode(PlayerSettingsStorage.loadSecondaryPreferredSubtitleLanguage())
|
||||
streamReuseLastLinkEnabled = PlayerSettingsStorage.loadStreamReuseLastLinkEnabled() ?: false
|
||||
streamReuseLastLinkCacheHours = PlayerSettingsStorage.loadStreamReuseLastLinkCacheHours() ?: 24
|
||||
publish()
|
||||
}
|
||||
|
||||
|
|
@ -92,6 +98,22 @@ object PlayerSettingsRepository {
|
|||
PlayerSettingsStorage.saveSecondaryPreferredSubtitleLanguage(normalized)
|
||||
}
|
||||
|
||||
fun setStreamReuseLastLinkEnabled(enabled: Boolean) {
|
||||
ensureLoaded()
|
||||
if (streamReuseLastLinkEnabled == enabled) return
|
||||
streamReuseLastLinkEnabled = enabled
|
||||
publish()
|
||||
PlayerSettingsStorage.saveStreamReuseLastLinkEnabled(enabled)
|
||||
}
|
||||
|
||||
fun setStreamReuseLastLinkCacheHours(hours: Int) {
|
||||
ensureLoaded()
|
||||
if (streamReuseLastLinkCacheHours == hours) return
|
||||
streamReuseLastLinkCacheHours = hours
|
||||
publish()
|
||||
PlayerSettingsStorage.saveStreamReuseLastLinkCacheHours(hours)
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
_uiState.value = PlayerSettingsUiState(
|
||||
showLoadingOverlay = showLoadingOverlay,
|
||||
|
|
@ -99,6 +121,8 @@ object PlayerSettingsRepository {
|
|||
secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage,
|
||||
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
||||
secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage,
|
||||
streamReuseLastLinkEnabled = streamReuseLastLinkEnabled,
|
||||
streamReuseLastLinkCacheHours = streamReuseLastLinkCacheHours,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,4 +11,8 @@ internal expect object PlayerSettingsStorage {
|
|||
fun savePreferredSubtitleLanguage(language: String)
|
||||
fun loadSecondaryPreferredSubtitleLanguage(): String?
|
||||
fun saveSecondaryPreferredSubtitleLanguage(language: String?)
|
||||
fun loadStreamReuseLastLinkEnabled(): Boolean?
|
||||
fun saveStreamReuseLastLinkEnabled(enabled: Boolean)
|
||||
fun loadStreamReuseLastLinkCacheHours(): Int?
|
||||
fun saveStreamReuseLastLinkCacheHours(hours: Int)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ internal fun LazyListScope.playbackSettingsContent(
|
|||
secondaryPreferredAudioLanguage: String?,
|
||||
preferredSubtitleLanguage: String,
|
||||
secondaryPreferredSubtitleLanguage: String?,
|
||||
streamReuseLastLinkEnabled: Boolean,
|
||||
streamReuseLastLinkCacheHours: Int,
|
||||
) {
|
||||
item {
|
||||
PlaybackSettingsSection(
|
||||
|
|
@ -56,6 +58,8 @@ internal fun LazyListScope.playbackSettingsContent(
|
|||
secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage,
|
||||
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
||||
secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage,
|
||||
streamReuseLastLinkEnabled = streamReuseLastLinkEnabled,
|
||||
streamReuseLastLinkCacheHours = streamReuseLastLinkCacheHours,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -68,11 +72,14 @@ private fun PlaybackSettingsSection(
|
|||
secondaryPreferredAudioLanguage: String?,
|
||||
preferredSubtitleLanguage: String,
|
||||
secondaryPreferredSubtitleLanguage: String?,
|
||||
streamReuseLastLinkEnabled: Boolean,
|
||||
streamReuseLastLinkCacheHours: Int,
|
||||
) {
|
||||
var showPreferredAudioDialog by remember { mutableStateOf(false) }
|
||||
var showSecondaryAudioDialog by remember { mutableStateOf(false) }
|
||||
var showPreferredSubtitleDialog by remember { mutableStateOf(false) }
|
||||
var showSecondarySubtitleDialog by remember { mutableStateOf(false) }
|
||||
var showReuseCacheDurationDialog by remember { mutableStateOf(false) }
|
||||
|
||||
SettingsSection(
|
||||
title = "PLAYER",
|
||||
|
|
@ -130,6 +137,31 @@ private fun PlaybackSettingsSection(
|
|||
}
|
||||
}
|
||||
|
||||
SettingsSection(
|
||||
title = "STREAM SELECTION",
|
||||
isTablet = isTablet,
|
||||
) {
|
||||
SettingsGroup(isTablet = isTablet) {
|
||||
SettingsSwitchRow(
|
||||
title = "Reuse Last Link",
|
||||
description = "Auto-play your last working stream for this same movie/episode when cache is still valid.",
|
||||
checked = streamReuseLastLinkEnabled,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = PlayerSettingsRepository::setStreamReuseLastLinkEnabled,
|
||||
)
|
||||
if (streamReuseLastLinkEnabled) {
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = "Last Link Cache Duration",
|
||||
description = formatReuseCacheDuration(streamReuseLastLinkCacheHours),
|
||||
icon = Icons.Rounded.Language,
|
||||
isTablet = isTablet,
|
||||
onClick = { showReuseCacheDurationDialog = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showPreferredAudioDialog) {
|
||||
LanguageSelectionDialog(
|
||||
title = "Preferred Audio Language",
|
||||
|
|
@ -201,6 +233,26 @@ private fun PlaybackSettingsSection(
|
|||
onDismiss = { showSecondarySubtitleDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showReuseCacheDurationDialog) {
|
||||
ReuseCacheDurationDialog(
|
||||
selectedHours = streamReuseLastLinkCacheHours,
|
||||
onDurationSelected = { hours ->
|
||||
PlayerSettingsRepository.setStreamReuseLastLinkCacheHours(hours)
|
||||
showReuseCacheDurationDialog = false
|
||||
},
|
||||
onDismiss = { showReuseCacheDurationDialog = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatReuseCacheDuration(hours: Int): String = when {
|
||||
hours < 24 -> "$hours hour${if (hours != 1) "s" else ""}"
|
||||
hours % 24 == 0 -> {
|
||||
val days = hours / 24
|
||||
"$days day${if (days != 1) "s" else ""}"
|
||||
}
|
||||
else -> "$hours hours"
|
||||
}
|
||||
|
||||
private data class LanguageSelectionOption(
|
||||
|
|
@ -297,3 +349,90 @@ private fun LanguageSelectionDialog(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun ReuseCacheDurationDialog(
|
||||
selectedHours: Int,
|
||||
onDurationSelected: (Int) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val options = listOf(1, 6, 12, 24, 48, 72, 168)
|
||||
|
||||
BasicAlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Last Link Cache Duration",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
options.forEach { hours ->
|
||||
val isSelected = hours == selectedHours
|
||||
val containerColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onDurationSelected(hours) },
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = containerColor,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = formatReuseCacheDuration(hours),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.size(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "Tap outside to close",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,8 @@ fun SettingsScreen(
|
|||
secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage,
|
||||
preferredSubtitleLanguage = playerSettingsUiState.preferredSubtitleLanguage,
|
||||
secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage,
|
||||
streamReuseLastLinkEnabled = playerSettingsUiState.streamReuseLastLinkEnabled,
|
||||
streamReuseLastLinkCacheHours = playerSettingsUiState.streamReuseLastLinkCacheHours,
|
||||
selectedTheme = selectedTheme,
|
||||
onThemeSelected = ThemeSettingsRepository::setTheme,
|
||||
amoledEnabled = amoledEnabled,
|
||||
|
|
@ -100,6 +102,8 @@ fun SettingsScreen(
|
|||
secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage,
|
||||
preferredSubtitleLanguage = playerSettingsUiState.preferredSubtitleLanguage,
|
||||
secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage,
|
||||
streamReuseLastLinkEnabled = playerSettingsUiState.streamReuseLastLinkEnabled,
|
||||
streamReuseLastLinkCacheHours = playerSettingsUiState.streamReuseLastLinkCacheHours,
|
||||
selectedTheme = selectedTheme,
|
||||
onThemeSelected = ThemeSettingsRepository::setTheme,
|
||||
amoledEnabled = amoledEnabled,
|
||||
|
|
@ -123,6 +127,8 @@ private fun MobileSettingsScreen(
|
|||
secondaryPreferredAudioLanguage: String?,
|
||||
preferredSubtitleLanguage: String,
|
||||
secondaryPreferredSubtitleLanguage: String?,
|
||||
streamReuseLastLinkEnabled: Boolean,
|
||||
streamReuseLastLinkCacheHours: Int,
|
||||
selectedTheme: AppTheme,
|
||||
onThemeSelected: (AppTheme) -> Unit,
|
||||
amoledEnabled: Boolean,
|
||||
|
|
@ -158,6 +164,8 @@ private fun MobileSettingsScreen(
|
|||
secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage,
|
||||
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
||||
secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage,
|
||||
streamReuseLastLinkEnabled = streamReuseLastLinkEnabled,
|
||||
streamReuseLastLinkCacheHours = streamReuseLastLinkCacheHours,
|
||||
)
|
||||
SettingsPage.Appearance -> appearanceSettingsContent(
|
||||
isTablet = false,
|
||||
|
|
@ -185,6 +193,8 @@ private fun TabletSettingsScreen(
|
|||
secondaryPreferredAudioLanguage: String?,
|
||||
preferredSubtitleLanguage: String,
|
||||
secondaryPreferredSubtitleLanguage: String?,
|
||||
streamReuseLastLinkEnabled: Boolean,
|
||||
streamReuseLastLinkCacheHours: Int,
|
||||
selectedTheme: AppTheme,
|
||||
onThemeSelected: (AppTheme) -> Unit,
|
||||
amoledEnabled: Boolean,
|
||||
|
|
@ -269,6 +279,8 @@ private fun TabletSettingsScreen(
|
|||
secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage,
|
||||
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
||||
secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage,
|
||||
streamReuseLastLinkEnabled = streamReuseLastLinkEnabled,
|
||||
streamReuseLastLinkCacheHours = streamReuseLastLinkCacheHours,
|
||||
)
|
||||
SettingsPage.Appearance -> appearanceSettingsContent(
|
||||
isTablet = true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
package com.nuvio.app.features.streams
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class CachedStreamLink(
|
||||
val url: String,
|
||||
val streamName: String,
|
||||
val addonName: String,
|
||||
val addonId: String,
|
||||
val cachedAtMs: Long,
|
||||
val filename: String? = null,
|
||||
val videoSize: Long? = null,
|
||||
)
|
||||
|
||||
internal expect fun epochMs(): Long
|
||||
|
||||
object StreamLinkCacheRepository {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun contentKey(type: String, videoId: String): String =
|
||||
"${type.lowercase()}|$videoId"
|
||||
|
||||
fun save(
|
||||
contentKey: String,
|
||||
url: String,
|
||||
streamName: String,
|
||||
addonName: String,
|
||||
addonId: String,
|
||||
filename: String? = null,
|
||||
videoSize: Long? = null,
|
||||
) {
|
||||
val entry = CachedStreamLink(
|
||||
url = url,
|
||||
streamName = streamName,
|
||||
addonName = addonName,
|
||||
addonId = addonId,
|
||||
cachedAtMs = epochMs(),
|
||||
filename = filename,
|
||||
videoSize = videoSize,
|
||||
)
|
||||
val payload = json.encodeToString(CachedStreamLink.serializer(), entry)
|
||||
StreamLinkCacheStorage.saveEntry(hashedKey(contentKey), payload)
|
||||
}
|
||||
|
||||
fun getValid(contentKey: String, maxAgeMs: Long): CachedStreamLink? {
|
||||
if (maxAgeMs <= 0L) return null
|
||||
val raw = StreamLinkCacheStorage.loadEntry(hashedKey(contentKey)) ?: return null
|
||||
val entry = runCatching {
|
||||
json.decodeFromString(CachedStreamLink.serializer(), raw)
|
||||
}.getOrNull() ?: run {
|
||||
StreamLinkCacheStorage.removeEntry(hashedKey(contentKey))
|
||||
return null
|
||||
}
|
||||
val age = epochMs() - entry.cachedAtMs
|
||||
if (entry.cachedAtMs <= 0L || age > maxAgeMs) {
|
||||
StreamLinkCacheStorage.removeEntry(hashedKey(contentKey))
|
||||
return null
|
||||
}
|
||||
if (entry.url.isBlank()) {
|
||||
StreamLinkCacheStorage.removeEntry(hashedKey(contentKey))
|
||||
return null
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
private fun hashedKey(contentKey: String): String {
|
||||
val hash = contentKey.fold(0L) { acc, c -> acc * 31 + c.code }.toULong()
|
||||
return "stream_link_$hash"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.nuvio.app.features.streams
|
||||
|
||||
internal expect object StreamLinkCacheStorage {
|
||||
fun loadEntry(hashedKey: String): String?
|
||||
fun saveEntry(hashedKey: String, payload: String)
|
||||
fun removeEntry(hashedKey: String)
|
||||
}
|
||||
|
|
@ -23,13 +23,7 @@ object StreamsRepository {
|
|||
private var activeJob: Job? = null
|
||||
private var activeRequestKey: String? = null
|
||||
|
||||
/**
|
||||
* Loads streams for a given type + videoId from all installed addons that declare
|
||||
* the "stream" resource for the given type (and matching idPrefixes if set).
|
||||
*
|
||||
* For movies: videoId == meta id (e.g. "tt1234567")
|
||||
* For series: videoId == "{metaId}:{season}:{episode}" (e.g. "tt0898266:9:17")
|
||||
*/
|
||||
|
||||
fun load(type: String, videoId: String) {
|
||||
load(type = type, videoId = videoId, forceRefresh = false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ actual object PlayerSettingsStorage {
|
|||
private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language"
|
||||
private const val preferredSubtitleLanguageKey = "preferred_subtitle_language"
|
||||
private const val secondaryPreferredSubtitleLanguageKey = "secondary_preferred_subtitle_language"
|
||||
private const val streamReuseLastLinkEnabledKey = "stream_reuse_last_link_enabled"
|
||||
private const val streamReuseLastLinkCacheHoursKey = "stream_reuse_last_link_cache_hours"
|
||||
|
||||
actual fun loadShowLoadingOverlay(): Boolean? {
|
||||
val defaults = NSUserDefaults.standardUserDefaults
|
||||
|
|
@ -75,4 +77,32 @@ actual object PlayerSettingsStorage {
|
|||
defaults.setObject(language, forKey = key)
|
||||
}
|
||||
}
|
||||
|
||||
actual fun loadStreamReuseLastLinkEnabled(): Boolean? {
|
||||
val defaults = NSUserDefaults.standardUserDefaults
|
||||
val key = ProfileScopedKey.of(streamReuseLastLinkEnabledKey)
|
||||
return if (defaults.objectForKey(key) != null) {
|
||||
defaults.boolForKey(key)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual fun saveStreamReuseLastLinkEnabled(enabled: Boolean) {
|
||||
NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(streamReuseLastLinkEnabledKey))
|
||||
}
|
||||
|
||||
actual fun loadStreamReuseLastLinkCacheHours(): Int? {
|
||||
val defaults = NSUserDefaults.standardUserDefaults
|
||||
val key = ProfileScopedKey.of(streamReuseLastLinkCacheHoursKey)
|
||||
return if (defaults.objectForKey(key) != null) {
|
||||
defaults.integerForKey(key).toInt()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual fun saveStreamReuseLastLinkCacheHours(hours: Int) {
|
||||
NSUserDefaults.standardUserDefaults.setInteger(hours.toLong(), forKey = ProfileScopedKey.of(streamReuseLastLinkCacheHoursKey))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package com.nuvio.app.features.streams
|
||||
|
||||
import platform.Foundation.NSDate
|
||||
import platform.Foundation.timeIntervalSince1970
|
||||
|
||||
internal actual fun epochMs(): Long =
|
||||
(NSDate().timeIntervalSince1970 * 1000.0).toLong()
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.nuvio.app.features.streams
|
||||
|
||||
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||
import platform.Foundation.NSUserDefaults
|
||||
|
||||
actual object StreamLinkCacheStorage {
|
||||
actual fun loadEntry(hashedKey: String): String? =
|
||||
NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(hashedKey))
|
||||
|
||||
actual fun saveEntry(hashedKey: String, payload: String) {
|
||||
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(hashedKey))
|
||||
}
|
||||
|
||||
actual fun removeEntry(hashedKey: String) {
|
||||
NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(hashedKey))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue