Merge branch 'NuvioMedia:cmp-rewrite' into cmp-rewrite

This commit is contained in:
emgeje 2026-05-12 10:53:11 +02:00 committed by GitHub
commit e71e3af46e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 327 additions and 37 deletions

View file

@ -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>

View file

@ -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,

View file

@ -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])
} }
} }
} }

View file

@ -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 ->

View file

@ -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(),
) )

View file

@ -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() },

View file

@ -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(

View file

@ -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)
} }

View file

@ -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())

View file

@ -334,6 +334,11 @@ fun SearchScreen(
onPosterLongClick = onPosterLongClick, onPosterLongClick = onPosterLongClick,
) )
} }
if (uiState.isLoading) {
item(key = "search_loading_more") {
HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
}
}
} }
} }
} }

View file

@ -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,
)
} }
} }
} }

View file

@ -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,
) )
} }

View file

@ -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(

View file

@ -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 ->

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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])
} }
} }
} }

View file

@ -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)
}
} }

View file

@ -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)
}
}

View file

@ -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(

View file

@ -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

View file

@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=56 CURRENT_PROJECT_VERSION=58
MARKETING_VERSION=0.1.17 MARKETING_VERSION=0.1.0