mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-19 16:31:44 +00:00
Merge branch 'NuvioMedia:cmp-rewrite' into cmp-rewrite
This commit is contained in:
commit
e71e3af46e
24 changed files with 327 additions and 37 deletions
|
|
@ -506,6 +506,8 @@
|
||||||
<string name="settings_homescreen_show_hero_description">Display hero carousel at top of home.</string>
|
<string name="settings_homescreen_show_hero_description">Display hero carousel at top of home.</string>
|
||||||
<string name="layout_hide_unreleased">Hide Unreleased Content</string>
|
<string name="layout_hide_unreleased">Hide Unreleased Content</string>
|
||||||
<string name="layout_hide_unreleased_sub">Hide movies and shows that haven't been released yet.</string>
|
<string name="layout_hide_unreleased_sub">Hide movies and shows that haven't been released yet.</string>
|
||||||
|
<string name="settings_homescreen_hide_catalog_underline">Hide Catalog Underline</string>
|
||||||
|
<string name="settings_homescreen_hide_catalog_underline_description">Remove the accent line under catalog and collection titles throughout the app.</string>
|
||||||
<string name="settings_homescreen_summary">%1$d of %2$d catalogs visible • %3$d hero sources selected</string>
|
<string name="settings_homescreen_summary">%1$d of %2$d catalogs visible • %3$d hero sources selected</string>
|
||||||
<string name="settings_homescreen_summary_hint">Open a catalog only when you need to rename or reorder it.</string>
|
<string name="settings_homescreen_summary_hint">Open a catalog only when you need to rename or reorder it.</string>
|
||||||
<string name="settings_homescreen_visible">Visible</string>
|
<string name="settings_homescreen_visible">Visible</string>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -98,11 +98,14 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
|
||||||
// Fallback: if the seed wasn't found by season+episode (anime with absolute
|
// Fallback: if the seed wasn't found by season+episode (anime with absolute
|
||||||
// numbering on Trakt vs multi-season on addon), try global index matching.
|
// numbering on Trakt vs multi-season on addon), try global index matching.
|
||||||
if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) {
|
if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) {
|
||||||
val addonSeasons = sortedEpisodes.mapTo(mutableSetOf()) { it.season }
|
val mainEpisodes = sortedEpisodes.filter { episode -> normalizeSeasonNumber(episode.season) > 0 }
|
||||||
|
val addonSeasons = mainEpisodes.mapTo(mutableSetOf()) { episode ->
|
||||||
|
normalizeSeasonNumber(episode.season)
|
||||||
|
}
|
||||||
if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) {
|
if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) {
|
||||||
val globalIndex = episodeNumber - 1
|
val globalIndex = episodeNumber - 1
|
||||||
if (globalIndex in sortedEpisodes.indices) {
|
if (globalIndex in mainEpisodes.indices) {
|
||||||
watchedIndex = globalIndex
|
watchedIndex = sortedEpisodes.indexOf(mainEpisodes[globalIndex])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ data class HomeCatalogSettingsItem(
|
||||||
data class HomeCatalogSettingsUiState(
|
data class HomeCatalogSettingsUiState(
|
||||||
val heroEnabled: Boolean = true,
|
val heroEnabled: Boolean = true,
|
||||||
val hideUnreleasedContent: Boolean = false,
|
val hideUnreleasedContent: Boolean = false,
|
||||||
|
val hideCatalogUnderline: Boolean = false,
|
||||||
val items: List<HomeCatalogSettingsItem> = emptyList(),
|
val items: List<HomeCatalogSettingsItem> = emptyList(),
|
||||||
) {
|
) {
|
||||||
val signature: String
|
val signature: String
|
||||||
|
|
@ -41,6 +42,8 @@ data class HomeCatalogSettingsUiState(
|
||||||
append('|')
|
append('|')
|
||||||
append(hideUnreleasedContent)
|
append(hideUnreleasedContent)
|
||||||
append('|')
|
append('|')
|
||||||
|
append(hideCatalogUnderline)
|
||||||
|
append('|')
|
||||||
append(
|
append(
|
||||||
items.joinToString(separator = "|") { item ->
|
items.joinToString(separator = "|") { item ->
|
||||||
"${item.key}:${item.order}:${item.enabled}:${item.heroSourceEnabled}:${item.customTitle}"
|
"${item.key}:${item.order}:${item.enabled}:${item.heroSourceEnabled}:${item.customTitle}"
|
||||||
|
|
@ -59,6 +62,7 @@ internal data class HomeCatalogPreference(
|
||||||
internal data class HomeCatalogSettingsSnapshot(
|
internal data class HomeCatalogSettingsSnapshot(
|
||||||
val heroEnabled: Boolean,
|
val heroEnabled: Boolean,
|
||||||
val hideUnreleasedContent: Boolean,
|
val hideUnreleasedContent: Boolean,
|
||||||
|
val hideCatalogUnderline: Boolean,
|
||||||
val preferences: Map<String, HomeCatalogPreference>,
|
val preferences: Map<String, HomeCatalogPreference>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -75,6 +79,7 @@ private data class StoredHomeCatalogPreference(
|
||||||
private data class StoredHomeCatalogSettingsPayload(
|
private data class StoredHomeCatalogSettingsPayload(
|
||||||
val heroEnabled: Boolean = true,
|
val heroEnabled: Boolean = true,
|
||||||
val hideUnreleasedContent: Boolean = false,
|
val hideUnreleasedContent: Boolean = false,
|
||||||
|
val hideCatalogUnderline: Boolean = false,
|
||||||
val items: List<StoredHomeCatalogPreference> = emptyList(),
|
val items: List<StoredHomeCatalogPreference> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -95,12 +100,14 @@ object HomeCatalogSettingsRepository {
|
||||||
private var preferences: MutableMap<String, StoredHomeCatalogPreference> = mutableMapOf()
|
private var preferences: MutableMap<String, StoredHomeCatalogPreference> = mutableMapOf()
|
||||||
private var heroEnabled = true
|
private var heroEnabled = true
|
||||||
private var hideUnreleasedContent = false
|
private var hideUnreleasedContent = false
|
||||||
|
private var hideCatalogUnderline = false
|
||||||
|
|
||||||
fun onProfileChanged() {
|
fun onProfileChanged() {
|
||||||
hasLoaded = false
|
hasLoaded = false
|
||||||
preferences.clear()
|
preferences.clear()
|
||||||
heroEnabled = true
|
heroEnabled = true
|
||||||
hideUnreleasedContent = false
|
hideUnreleasedContent = false
|
||||||
|
hideCatalogUnderline = false
|
||||||
definitions = emptyList()
|
definitions = emptyList()
|
||||||
collectionDefinitions = emptyList()
|
collectionDefinitions = emptyList()
|
||||||
_uiState.value = HomeCatalogSettingsUiState()
|
_uiState.value = HomeCatalogSettingsUiState()
|
||||||
|
|
@ -113,6 +120,7 @@ object HomeCatalogSettingsRepository {
|
||||||
preferences.clear()
|
preferences.clear()
|
||||||
heroEnabled = true
|
heroEnabled = true
|
||||||
hideUnreleasedContent = false
|
hideUnreleasedContent = false
|
||||||
|
hideCatalogUnderline = false
|
||||||
_uiState.value = HomeCatalogSettingsUiState()
|
_uiState.value = HomeCatalogSettingsUiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,6 +152,7 @@ object HomeCatalogSettingsRepository {
|
||||||
return HomeCatalogSettingsSnapshot(
|
return HomeCatalogSettingsSnapshot(
|
||||||
heroEnabled = heroEnabled,
|
heroEnabled = heroEnabled,
|
||||||
hideUnreleasedContent = hideUnreleasedContent,
|
hideUnreleasedContent = hideUnreleasedContent,
|
||||||
|
hideCatalogUnderline = hideCatalogUnderline,
|
||||||
preferences = preferences.mapValues { (_, value) ->
|
preferences = preferences.mapValues { (_, value) ->
|
||||||
HomeCatalogPreference(
|
HomeCatalogPreference(
|
||||||
customTitle = value.customTitle,
|
customTitle = value.customTitle,
|
||||||
|
|
@ -172,6 +181,14 @@ object HomeCatalogSettingsRepository {
|
||||||
HomeRepository.applyCurrentSettings()
|
HomeRepository.applyCurrentSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setHideCatalogUnderline(enabled: Boolean) {
|
||||||
|
ensureLoaded()
|
||||||
|
if (hideCatalogUnderline == enabled) return
|
||||||
|
hideCatalogUnderline = enabled
|
||||||
|
publish()
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
fun setHeroSourceEnabled(key: String, enabled: Boolean) {
|
fun setHeroSourceEnabled(key: String, enabled: Boolean) {
|
||||||
updatePreference(key) { preference ->
|
updatePreference(key) { preference ->
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
|
|
@ -200,6 +217,7 @@ object HomeCatalogSettingsRepository {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
heroEnabled = true
|
heroEnabled = true
|
||||||
hideUnreleasedContent = false
|
hideUnreleasedContent = false
|
||||||
|
hideCatalogUnderline = false
|
||||||
preferences.clear()
|
preferences.clear()
|
||||||
normalizePreferences()
|
normalizePreferences()
|
||||||
publish()
|
publish()
|
||||||
|
|
@ -246,6 +264,7 @@ object HomeCatalogSettingsRepository {
|
||||||
if (parsedPayload != null) {
|
if (parsedPayload != null) {
|
||||||
heroEnabled = parsedPayload.heroEnabled
|
heroEnabled = parsedPayload.heroEnabled
|
||||||
hideUnreleasedContent = parsedPayload.hideUnreleasedContent
|
hideUnreleasedContent = parsedPayload.hideUnreleasedContent
|
||||||
|
hideCatalogUnderline = parsedPayload.hideCatalogUnderline
|
||||||
preferences = parsedPayload.items.associateBy { it.key }.toMutableMap()
|
preferences = parsedPayload.items.associateBy { it.key }.toMutableMap()
|
||||||
publish()
|
publish()
|
||||||
return
|
return
|
||||||
|
|
@ -345,6 +364,7 @@ object HomeCatalogSettingsRepository {
|
||||||
_uiState.value = HomeCatalogSettingsUiState(
|
_uiState.value = HomeCatalogSettingsUiState(
|
||||||
heroEnabled = heroEnabled,
|
heroEnabled = heroEnabled,
|
||||||
hideUnreleasedContent = hideUnreleasedContent,
|
hideUnreleasedContent = hideUnreleasedContent,
|
||||||
|
hideCatalogUnderline = hideCatalogUnderline,
|
||||||
items = items,
|
items = items,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -355,6 +375,7 @@ object HomeCatalogSettingsRepository {
|
||||||
StoredHomeCatalogSettingsPayload(
|
StoredHomeCatalogSettingsPayload(
|
||||||
heroEnabled = heroEnabled,
|
heroEnabled = heroEnabled,
|
||||||
hideUnreleasedContent = hideUnreleasedContent,
|
hideUnreleasedContent = hideUnreleasedContent,
|
||||||
|
hideCatalogUnderline = hideCatalogUnderline,
|
||||||
items = preferences.values.sortedBy { it.order },
|
items = preferences.values.sortedBy { it.order },
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -437,6 +458,7 @@ object HomeCatalogSettingsRepository {
|
||||||
}
|
}
|
||||||
return SyncHomeCatalogPayload(
|
return SyncHomeCatalogPayload(
|
||||||
hideUnreleasedContent = hideUnreleasedContent,
|
hideUnreleasedContent = hideUnreleasedContent,
|
||||||
|
hideCatalogUnderline = hideCatalogUnderline,
|
||||||
items = items,
|
items = items,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -444,6 +466,7 @@ object HomeCatalogSettingsRepository {
|
||||||
fun applyFromRemote(payload: SyncHomeCatalogPayload) {
|
fun applyFromRemote(payload: SyncHomeCatalogPayload) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
hideUnreleasedContent = payload.hideUnreleasedContent
|
hideUnreleasedContent = payload.hideUnreleasedContent
|
||||||
|
hideCatalogUnderline = payload.hideCatalogUnderline
|
||||||
if (payload.items.isNotEmpty()) {
|
if (payload.items.isNotEmpty()) {
|
||||||
val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled }
|
val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled }
|
||||||
preferences = payload.items.associate { item ->
|
preferences = payload.items.associate { item ->
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ data class SyncCatalogItem(
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SyncHomeCatalogPayload(
|
data class SyncHomeCatalogPayload(
|
||||||
@SerialName("hide_unreleased_content") val hideUnreleasedContent: Boolean = false,
|
@SerialName("hide_unreleased_content") val hideUnreleasedContent: Boolean = false,
|
||||||
|
@SerialName("hide_catalog_underline") val hideCatalogUnderline: Boolean = false,
|
||||||
val items: List<SyncCatalogItem> = emptyList(),
|
val items: List<SyncCatalogItem> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,15 @@ import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.nuvio.app.core.ui.NuvioShelfSection
|
import com.nuvio.app.core.ui.NuvioShelfSection
|
||||||
import com.nuvio.app.core.ui.NuvioViewAllPillSize
|
import com.nuvio.app.core.ui.NuvioViewAllPillSize
|
||||||
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||||
|
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||||
import com.nuvio.app.features.home.HomeCatalogSection
|
import com.nuvio.app.features.home.HomeCatalogSection
|
||||||
import com.nuvio.app.features.home.MetaPreview
|
import com.nuvio.app.features.home.MetaPreview
|
||||||
import com.nuvio.app.features.home.stableKey
|
import com.nuvio.app.features.home.stableKey
|
||||||
|
|
@ -64,6 +68,10 @@ private fun HomeCatalogRowSectionContent(
|
||||||
onPosterLongClick: ((MetaPreview) -> Unit)?,
|
onPosterLongClick: ((MetaPreview) -> Unit)?,
|
||||||
) {
|
) {
|
||||||
val posterCardStyle = rememberPosterCardStyleUiState()
|
val posterCardStyle = rememberPosterCardStyleUiState()
|
||||||
|
val homeCatalogSettings by remember {
|
||||||
|
HomeCatalogSettingsRepository.snapshot()
|
||||||
|
HomeCatalogSettingsRepository.uiState
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
NuvioShelfSection(
|
NuvioShelfSection(
|
||||||
title = section.title,
|
title = section.title,
|
||||||
|
|
@ -71,6 +79,7 @@ private fun HomeCatalogRowSectionContent(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
headerHorizontalPadding = sectionPadding,
|
headerHorizontalPadding = sectionPadding,
|
||||||
rowContentPadding = PaddingValues(horizontal = sectionPadding),
|
rowContentPadding = PaddingValues(horizontal = sectionPadding),
|
||||||
|
showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline,
|
||||||
onViewAllClick = onViewAllClick,
|
onViewAllClick = onViewAllClick,
|
||||||
viewAllPillSize = NuvioViewAllPillSize.Compact,
|
viewAllPillSize = NuvioViewAllPillSize.Compact,
|
||||||
key = { item -> item.stableKey() },
|
key = { item -> item.stableKey() },
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
|
@ -23,6 +25,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.nuvio.app.core.ui.NuvioShelfSection
|
import com.nuvio.app.core.ui.NuvioShelfSection
|
||||||
import com.nuvio.app.core.ui.PosterLandscapeAspectRatio
|
import com.nuvio.app.core.ui.PosterLandscapeAspectRatio
|
||||||
import com.nuvio.app.core.ui.landscapePosterWidth
|
import com.nuvio.app.core.ui.landscapePosterWidth
|
||||||
|
|
@ -30,6 +33,7 @@ import com.nuvio.app.core.ui.posterCardClickable
|
||||||
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||||
import com.nuvio.app.features.collection.Collection
|
import com.nuvio.app.features.collection.Collection
|
||||||
import com.nuvio.app.features.collection.CollectionFolder
|
import com.nuvio.app.features.collection.CollectionFolder
|
||||||
|
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||||
import com.nuvio.app.features.home.PosterShape
|
import com.nuvio.app.features.home.PosterShape
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -71,12 +75,18 @@ private fun HomeCollectionRowSectionContent(
|
||||||
animateGifs: Boolean,
|
animateGifs: Boolean,
|
||||||
onFolderClick: ((collectionId: String, folderId: String) -> Unit)?,
|
onFolderClick: ((collectionId: String, folderId: String) -> Unit)?,
|
||||||
) {
|
) {
|
||||||
|
val homeCatalogSettings by remember {
|
||||||
|
HomeCatalogSettingsRepository.snapshot()
|
||||||
|
HomeCatalogSettingsRepository.uiState
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
NuvioShelfSection(
|
NuvioShelfSection(
|
||||||
title = collection.title,
|
title = collection.title,
|
||||||
entries = collection.folders,
|
entries = collection.folders,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
headerHorizontalPadding = sectionPadding,
|
headerHorizontalPadding = sectionPadding,
|
||||||
rowContentPadding = PaddingValues(horizontal = sectionPadding),
|
rowContentPadding = PaddingValues(horizontal = sectionPadding),
|
||||||
|
showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline,
|
||||||
key = { folder -> "collection_${collection.id}_folder_${folder.id}" },
|
key = { folder -> "collection_${collection.id}_folder_${folder.id}" },
|
||||||
) { folder ->
|
) { folder ->
|
||||||
CollectionFolderCard(
|
CollectionFolderCard(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,11 @@ import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.awaitAll
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.joinAll
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import nuvio.composeapp.generated.resources.*
|
import nuvio.composeapp.generated.resources.*
|
||||||
import org.jetbrains.compose.resources.getString
|
import org.jetbrains.compose.resources.getString
|
||||||
|
|
@ -91,16 +91,57 @@ object SearchRepository {
|
||||||
_uiState.value = SearchUiState(isLoading = true)
|
_uiState.value = SearchUiState(isLoading = true)
|
||||||
|
|
||||||
activeJob = scope.launch {
|
activeJob = scope.launch {
|
||||||
val results = requests.map { request ->
|
val resultChannel = Channel<IndexedSearchResult>(Channel.UNLIMITED)
|
||||||
async {
|
val jobs = requests.mapIndexed { index, request ->
|
||||||
|
launch {
|
||||||
runCatching { request.toSection() }
|
runCatching { request.toSection() }
|
||||||
|
.fold(
|
||||||
|
onSuccess = { section ->
|
||||||
|
resultChannel.send(
|
||||||
|
IndexedSearchResult(
|
||||||
|
index = index,
|
||||||
|
section = section,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onFailure = { error ->
|
||||||
|
if (error is CancellationException) throw error
|
||||||
|
resultChannel.send(
|
||||||
|
IndexedSearchResult(
|
||||||
|
index = index,
|
||||||
|
error = error,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}.awaitAll()
|
}
|
||||||
|
val closeChannelJob = launch {
|
||||||
|
jobs.joinAll()
|
||||||
|
resultChannel.close()
|
||||||
|
}
|
||||||
|
val results = arrayOfNulls<IndexedSearchResult>(requests.size)
|
||||||
|
|
||||||
val sections = results
|
try {
|
||||||
.mapNotNull { it.getOrNull() }
|
for (result in resultChannel) {
|
||||||
val firstFailure = results.firstNotNullOfOrNull { it.exceptionOrNull()?.message }
|
results[result.index] = result
|
||||||
val allFailed = results.isNotEmpty() && results.all { it.isFailure }
|
val sections = results.orderedSections()
|
||||||
|
if (sections.isNotEmpty()) {
|
||||||
|
_uiState.value = SearchUiState(
|
||||||
|
isLoading = true,
|
||||||
|
sections = sections,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
closeChannelJob.cancel()
|
||||||
|
resultChannel.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
val completedResults = results.filterNotNull()
|
||||||
|
val sections = results.orderedSections()
|
||||||
|
val firstFailure = completedResults.firstNotNullOfOrNull { it.error?.message }
|
||||||
|
val allFailed = completedResults.isNotEmpty() && completedResults.all { it.error != null }
|
||||||
|
|
||||||
_uiState.value = SearchUiState(
|
_uiState.value = SearchUiState(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
|
@ -436,6 +477,15 @@ object SearchRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class IndexedSearchResult(
|
||||||
|
val index: Int,
|
||||||
|
val section: HomeCatalogSection? = null,
|
||||||
|
val error: Throwable? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun Array<IndexedSearchResult?>.orderedSections(): List<HomeCatalogSection> =
|
||||||
|
mapNotNull { result -> result?.section }
|
||||||
|
|
||||||
private fun CatalogPage.withUnreleasedFilter(): CatalogPage {
|
private fun CatalogPage.withUnreleasedFilter(): CatalogPage {
|
||||||
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
|
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
|
||||||
val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate())
|
val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate())
|
||||||
|
|
|
||||||
|
|
@ -334,6 +334,11 @@ fun SearchScreen(
|
||||||
onPosterLongClick = onPosterLongClick,
|
onPosterLongClick = onPosterLongClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
item(key = "search_loading_more") {
|
||||||
|
HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ import nuvio.composeapp.generated.resources.layout_hide_unreleased
|
||||||
import nuvio.composeapp.generated.resources.layout_hide_unreleased_sub
|
import nuvio.composeapp.generated.resources.layout_hide_unreleased_sub
|
||||||
import nuvio.composeapp.generated.resources.settings_homescreen_empty_message
|
import nuvio.composeapp.generated.resources.settings_homescreen_empty_message
|
||||||
import nuvio.composeapp.generated.resources.settings_homescreen_empty_title
|
import nuvio.composeapp.generated.resources.settings_homescreen_empty_title
|
||||||
|
import nuvio.composeapp.generated.resources.settings_homescreen_hide_catalog_underline
|
||||||
|
import nuvio.composeapp.generated.resources.settings_homescreen_hide_catalog_underline_description
|
||||||
import nuvio.composeapp.generated.resources.settings_homescreen_keep_home_focused
|
import nuvio.composeapp.generated.resources.settings_homescreen_keep_home_focused
|
||||||
import nuvio.composeapp.generated.resources.settings_homescreen_limit_reached
|
import nuvio.composeapp.generated.resources.settings_homescreen_limit_reached
|
||||||
import nuvio.composeapp.generated.resources.settings_homescreen_no_sources_selected
|
import nuvio.composeapp.generated.resources.settings_homescreen_no_sources_selected
|
||||||
|
|
@ -65,6 +67,7 @@ internal fun LazyListScope.homescreenSettingsContent(
|
||||||
isTablet: Boolean,
|
isTablet: Boolean,
|
||||||
heroEnabled: Boolean,
|
heroEnabled: Boolean,
|
||||||
hideUnreleasedContent: Boolean,
|
hideUnreleasedContent: Boolean,
|
||||||
|
hideCatalogUnderline: Boolean,
|
||||||
items: List<HomeCatalogSettingsItem>,
|
items: List<HomeCatalogSettingsItem>,
|
||||||
) {
|
) {
|
||||||
val selectedHeroSourceCount = items.count { it.heroSourceEnabled }
|
val selectedHeroSourceCount = items.count { it.heroSourceEnabled }
|
||||||
|
|
@ -98,6 +101,14 @@ internal fun LazyListScope.homescreenSettingsContent(
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
onCheckedChange = HomeCatalogSettingsRepository::setHideUnreleasedContent,
|
onCheckedChange = HomeCatalogSettingsRepository::setHideUnreleasedContent,
|
||||||
)
|
)
|
||||||
|
SettingsGroupDivider(isTablet = isTablet)
|
||||||
|
SettingsSwitchRow(
|
||||||
|
title = stringResource(Res.string.settings_homescreen_hide_catalog_underline),
|
||||||
|
description = stringResource(Res.string.settings_homescreen_hide_catalog_underline_description),
|
||||||
|
checked = hideCatalogUnderline,
|
||||||
|
isTablet = isTablet,
|
||||||
|
onCheckedChange = HomeCatalogSettingsRepository::setHideCatalogUnderline,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ fun HomescreenSettingsScreen(
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
heroEnabled = homescreenSettingsUiState.heroEnabled,
|
heroEnabled = homescreenSettingsUiState.heroEnabled,
|
||||||
hideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
|
hideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
|
||||||
|
hideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline,
|
||||||
items = homescreenSettingsUiState.items,
|
items = homescreenSettingsUiState.items,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,7 @@ fun SettingsScreen(
|
||||||
traktSettingsUiState = traktSettingsUiState,
|
traktSettingsUiState = traktSettingsUiState,
|
||||||
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
|
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
|
||||||
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
|
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
|
||||||
|
homescreenHideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline,
|
||||||
homescreenItems = homescreenSettingsUiState.items,
|
homescreenItems = homescreenSettingsUiState.items,
|
||||||
metaScreenSettingsUiState = metaScreenSettingsUiState,
|
metaScreenSettingsUiState = metaScreenSettingsUiState,
|
||||||
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
|
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
|
||||||
|
|
@ -283,6 +284,7 @@ fun SettingsScreen(
|
||||||
traktSettingsUiState = traktSettingsUiState,
|
traktSettingsUiState = traktSettingsUiState,
|
||||||
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
|
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
|
||||||
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
|
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
|
||||||
|
homescreenHideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline,
|
||||||
homescreenItems = homescreenSettingsUiState.items,
|
homescreenItems = homescreenSettingsUiState.items,
|
||||||
metaScreenSettingsUiState = metaScreenSettingsUiState,
|
metaScreenSettingsUiState = metaScreenSettingsUiState,
|
||||||
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
|
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
|
||||||
|
|
@ -339,6 +341,7 @@ private fun MobileSettingsScreen(
|
||||||
traktSettingsUiState: TraktSettingsUiState,
|
traktSettingsUiState: TraktSettingsUiState,
|
||||||
homescreenHeroEnabled: Boolean,
|
homescreenHeroEnabled: Boolean,
|
||||||
homescreenHideUnreleasedContent: Boolean,
|
homescreenHideUnreleasedContent: Boolean,
|
||||||
|
homescreenHideCatalogUnderline: Boolean,
|
||||||
homescreenItems: List<HomeCatalogSettingsItem>,
|
homescreenItems: List<HomeCatalogSettingsItem>,
|
||||||
metaScreenSettingsUiState: MetaScreenSettingsUiState,
|
metaScreenSettingsUiState: MetaScreenSettingsUiState,
|
||||||
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
|
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
|
||||||
|
|
@ -530,6 +533,7 @@ private fun MobileSettingsScreen(
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
heroEnabled = homescreenHeroEnabled,
|
heroEnabled = homescreenHeroEnabled,
|
||||||
hideUnreleasedContent = homescreenHideUnreleasedContent,
|
hideUnreleasedContent = homescreenHideUnreleasedContent,
|
||||||
|
hideCatalogUnderline = homescreenHideCatalogUnderline,
|
||||||
items = homescreenItems,
|
items = homescreenItems,
|
||||||
)
|
)
|
||||||
SettingsPage.MetaScreen -> metaScreenSettingsContent(
|
SettingsPage.MetaScreen -> metaScreenSettingsContent(
|
||||||
|
|
@ -638,6 +642,7 @@ private fun TabletSettingsScreen(
|
||||||
traktSettingsUiState: TraktSettingsUiState,
|
traktSettingsUiState: TraktSettingsUiState,
|
||||||
homescreenHeroEnabled: Boolean,
|
homescreenHeroEnabled: Boolean,
|
||||||
homescreenHideUnreleasedContent: Boolean,
|
homescreenHideUnreleasedContent: Boolean,
|
||||||
|
homescreenHideCatalogUnderline: Boolean,
|
||||||
homescreenItems: List<HomeCatalogSettingsItem>,
|
homescreenItems: List<HomeCatalogSettingsItem>,
|
||||||
metaScreenSettingsUiState: MetaScreenSettingsUiState,
|
metaScreenSettingsUiState: MetaScreenSettingsUiState,
|
||||||
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
|
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
|
||||||
|
|
@ -888,6 +893,7 @@ private fun TabletSettingsScreen(
|
||||||
isTablet = true,
|
isTablet = true,
|
||||||
heroEnabled = homescreenHeroEnabled,
|
heroEnabled = homescreenHeroEnabled,
|
||||||
hideUnreleasedContent = homescreenHideUnreleasedContent,
|
hideUnreleasedContent = homescreenHideUnreleasedContent,
|
||||||
|
hideCatalogUnderline = homescreenHideCatalogUnderline,
|
||||||
items = homescreenItems,
|
items = homescreenItems,
|
||||||
)
|
)
|
||||||
SettingsPage.MetaScreen -> metaScreenSettingsContent(
|
SettingsPage.MetaScreen -> metaScreenSettingsContent(
|
||||||
|
|
|
||||||
|
|
@ -588,6 +588,7 @@ internal fun settingsSearchEntries(
|
||||||
listOf(
|
listOf(
|
||||||
PlaybackSearchRow("home-hero", stringResource(Res.string.settings_homescreen_show_hero), stringResource(Res.string.settings_homescreen_show_hero_description)),
|
PlaybackSearchRow("home-hero", stringResource(Res.string.settings_homescreen_show_hero), stringResource(Res.string.settings_homescreen_show_hero_description)),
|
||||||
PlaybackSearchRow("home-hide-unreleased", stringResource(Res.string.layout_hide_unreleased), stringResource(Res.string.layout_hide_unreleased_sub)),
|
PlaybackSearchRow("home-hide-unreleased", stringResource(Res.string.layout_hide_unreleased), stringResource(Res.string.layout_hide_unreleased_sub)),
|
||||||
|
PlaybackSearchRow("home-hide-catalog-underline", stringResource(Res.string.settings_homescreen_hide_catalog_underline), stringResource(Res.string.settings_homescreen_hide_catalog_underline_description)),
|
||||||
PlaybackSearchRow("home-hero-sources", stringResource(Res.string.settings_homescreen_section_hero_sources)),
|
PlaybackSearchRow("home-hero-sources", stringResource(Res.string.settings_homescreen_section_hero_sources)),
|
||||||
PlaybackSearchRow("home-catalogs", stringResource(Res.string.settings_homescreen_section_catalogs)),
|
PlaybackSearchRow("home-catalogs", stringResource(Res.string.settings_homescreen_section_catalogs)),
|
||||||
).forEach { row ->
|
).forEach { row ->
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
@ -153,6 +172,7 @@ object StreamsRepository {
|
||||||
|
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -53,11 +53,14 @@ fun nextReleasedEpisodeAfter(
|
||||||
// Fallback: if the seed wasn't found by season+episode (anime with absolute
|
// Fallback: if the seed wasn't found by season+episode (anime with absolute
|
||||||
// numbering on Trakt vs multi-season on addon), try global index matching.
|
// numbering on Trakt vs multi-season on addon), try global index matching.
|
||||||
if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) {
|
if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) {
|
||||||
val addonSeasons = sortedEpisodes.mapTo(mutableSetOf()) { it.seasonNumber }
|
val mainEpisodes = sortedEpisodes.filter { episode -> normalizeSeasonNumber(episode.seasonNumber) > 0 }
|
||||||
|
val addonSeasons = mainEpisodes.mapTo(mutableSetOf()) { episode ->
|
||||||
|
normalizeSeasonNumber(episode.seasonNumber)
|
||||||
|
}
|
||||||
if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) {
|
if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) {
|
||||||
val globalIndex = episodeNumber - 1
|
val globalIndex = episodeNumber - 1
|
||||||
if (globalIndex in sortedEpisodes.indices) {
|
if (globalIndex in mainEpisodes.indices) {
|
||||||
watchedIndex = globalIndex
|
watchedIndex = sortedEpisodes.indexOf(mainEpisodes[globalIndex])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,4 +88,31 @@ class SeriesPlaybackResolverTest {
|
||||||
assertEquals("Up Next • S1E3", action.label)
|
assertEquals("Up Next • S1E3", action.label)
|
||||||
assertEquals("show:1:3", action.videoId)
|
assertEquals("show:1:3", action.videoId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nextReleasedEpisodeAfter_global_index_fallback_ignores_specials() {
|
||||||
|
val meta = MetaDetails(
|
||||||
|
id = "show",
|
||||||
|
type = "series",
|
||||||
|
name = "Show",
|
||||||
|
videos = listOf(
|
||||||
|
MetaVideo(id = "sp1", title = "Special 1", season = 0, episode = 1, released = "2026-01-01"),
|
||||||
|
MetaVideo(id = "s1e1", title = "Episode 1", season = 1, episode = 1, released = "2026-01-08"),
|
||||||
|
MetaVideo(id = "s1e2", title = "Episode 2", season = 1, episode = 2, released = "2026-01-15"),
|
||||||
|
MetaVideo(id = "s2e1", title = "Episode 3", season = 2, episode = 1, released = "2026-01-22"),
|
||||||
|
MetaVideo(id = "s2e2", title = "Episode 4", season = 2, episode = 2, released = "2026-01-29"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val nextEpisode = meta.nextReleasedEpisodeAfter(
|
||||||
|
seasonNumber = 1,
|
||||||
|
episodeNumber = 3,
|
||||||
|
todayIsoDate = "2026-02-01",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNotNull(nextEpisode)
|
||||||
|
assertEquals(2, nextEpisode.season)
|
||||||
|
assertEquals(2, nextEpisode.episode)
|
||||||
|
assertEquals("s2e2", nextEpisode.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -97,6 +97,30 @@ class SeriesContinuityTest {
|
||||||
assertEquals("show:1:1", action.videoId)
|
assertEquals("show:1:1", action.videoId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nextReleasedEpisodeAfter_global_index_fallback_ignores_specials() {
|
||||||
|
val episodesWithSpecials = listOf(
|
||||||
|
WatchingReleasedEpisode(videoId = "sp1", seasonNumber = 0, episodeNumber = 1, title = "Special 1", releasedDate = "2026-01-01"),
|
||||||
|
WatchingReleasedEpisode(videoId = "s1e1", seasonNumber = 1, episodeNumber = 1, title = "Episode 1", releasedDate = "2026-01-08"),
|
||||||
|
WatchingReleasedEpisode(videoId = "s1e2", seasonNumber = 1, episodeNumber = 2, title = "Episode 2", releasedDate = "2026-01-15"),
|
||||||
|
WatchingReleasedEpisode(videoId = "s2e1", seasonNumber = 2, episodeNumber = 1, title = "Episode 3", releasedDate = "2026-01-22"),
|
||||||
|
WatchingReleasedEpisode(videoId = "s2e2", seasonNumber = 2, episodeNumber = 2, title = "Episode 4", releasedDate = "2026-01-29"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val nextEpisode = nextReleasedEpisodeAfter(
|
||||||
|
content = show,
|
||||||
|
episodes = episodesWithSpecials,
|
||||||
|
seasonNumber = 1,
|
||||||
|
episodeNumber = 3,
|
||||||
|
todayIsoDate = "2026-02-01",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNotNull(nextEpisode)
|
||||||
|
assertEquals(2, nextEpisode.seasonNumber)
|
||||||
|
assertEquals(2, nextEpisode.episodeNumber)
|
||||||
|
assertEquals("s2e2", nextEpisode.videoId)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun decideSeriesPrimaryAction_falls_back_to_specials_when_no_main_season() {
|
fun decideSeriesPrimaryAction_falls_back_to_specials_when_no_main_season() {
|
||||||
val specialsOnly = listOf(
|
val specialsOnly = listOf(
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
#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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
CURRENT_PROJECT_VERSION=56
|
CURRENT_PROJECT_VERSION=58
|
||||||
MARKETING_VERSION=0.1.17
|
MARKETING_VERSION=0.1.0
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue