mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-18 16:01: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="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="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_hint">Open a catalog only when you need to rename or reorder it.</string>
|
||||
<string name="settings_homescreen_visible">Visible</string>
|
||||
|
|
|
|||
|
|
@ -1338,7 +1338,13 @@ private fun MainAppContent(
|
|||
reuseHandled = true
|
||||
if (launch.manualSelection) 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 cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs)
|
||||
if (cached != null) {
|
||||
|
|
@ -1378,17 +1384,37 @@ private fun MainAppContent(
|
|||
}
|
||||
|
||||
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) }
|
||||
LaunchedEffect(streamsUiState.autoPlayStream, reuseHandled, launch.manualSelection) {
|
||||
LaunchedEffect(
|
||||
streamsUiState.autoPlayStream,
|
||||
streamsUiState.requestToken,
|
||||
expectedStreamsRequestToken,
|
||||
reuseHandled,
|
||||
launch.manualSelection,
|
||||
) {
|
||||
if (!reuseHandled) return@LaunchedEffect
|
||||
if (launch.manualSelection) return@LaunchedEffect
|
||||
if (reuseNavigated) return@LaunchedEffect
|
||||
if (autoPlayHandled) return@LaunchedEffect
|
||||
if (streamsUiState.requestToken != expectedStreamsRequestToken) return@LaunchedEffect
|
||||
val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect
|
||||
val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect
|
||||
autoPlayHandled = true
|
||||
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(
|
||||
contentKey = cacheKey,
|
||||
url = sourceUrl,
|
||||
|
|
@ -1468,7 +1494,13 @@ private fun MainAppContent(
|
|||
if (sourceUrl != null) {
|
||||
// Persist for Reuse Last Link
|
||||
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(
|
||||
contentKey = cacheKey,
|
||||
url = sourceUrl,
|
||||
|
|
|
|||
|
|
@ -98,11 +98,14 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
|
|||
// 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.
|
||||
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) {
|
||||
val globalIndex = episodeNumber - 1
|
||||
if (globalIndex in sortedEpisodes.indices) {
|
||||
watchedIndex = globalIndex
|
||||
if (globalIndex in mainEpisodes.indices) {
|
||||
watchedIndex = sortedEpisodes.indexOf(mainEpisodes[globalIndex])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ data class HomeCatalogSettingsItem(
|
|||
data class HomeCatalogSettingsUiState(
|
||||
val heroEnabled: Boolean = true,
|
||||
val hideUnreleasedContent: Boolean = false,
|
||||
val hideCatalogUnderline: Boolean = false,
|
||||
val items: List<HomeCatalogSettingsItem> = emptyList(),
|
||||
) {
|
||||
val signature: String
|
||||
|
|
@ -41,6 +42,8 @@ data class HomeCatalogSettingsUiState(
|
|||
append('|')
|
||||
append(hideUnreleasedContent)
|
||||
append('|')
|
||||
append(hideCatalogUnderline)
|
||||
append('|')
|
||||
append(
|
||||
items.joinToString(separator = "|") { item ->
|
||||
"${item.key}:${item.order}:${item.enabled}:${item.heroSourceEnabled}:${item.customTitle}"
|
||||
|
|
@ -59,6 +62,7 @@ internal data class HomeCatalogPreference(
|
|||
internal data class HomeCatalogSettingsSnapshot(
|
||||
val heroEnabled: Boolean,
|
||||
val hideUnreleasedContent: Boolean,
|
||||
val hideCatalogUnderline: Boolean,
|
||||
val preferences: Map<String, HomeCatalogPreference>,
|
||||
)
|
||||
|
||||
|
|
@ -75,6 +79,7 @@ private data class StoredHomeCatalogPreference(
|
|||
private data class StoredHomeCatalogSettingsPayload(
|
||||
val heroEnabled: Boolean = true,
|
||||
val hideUnreleasedContent: Boolean = false,
|
||||
val hideCatalogUnderline: Boolean = false,
|
||||
val items: List<StoredHomeCatalogPreference> = emptyList(),
|
||||
)
|
||||
|
||||
|
|
@ -95,12 +100,14 @@ object HomeCatalogSettingsRepository {
|
|||
private var preferences: MutableMap<String, StoredHomeCatalogPreference> = mutableMapOf()
|
||||
private var heroEnabled = true
|
||||
private var hideUnreleasedContent = false
|
||||
private var hideCatalogUnderline = false
|
||||
|
||||
fun onProfileChanged() {
|
||||
hasLoaded = false
|
||||
preferences.clear()
|
||||
heroEnabled = true
|
||||
hideUnreleasedContent = false
|
||||
hideCatalogUnderline = false
|
||||
definitions = emptyList()
|
||||
collectionDefinitions = emptyList()
|
||||
_uiState.value = HomeCatalogSettingsUiState()
|
||||
|
|
@ -113,6 +120,7 @@ object HomeCatalogSettingsRepository {
|
|||
preferences.clear()
|
||||
heroEnabled = true
|
||||
hideUnreleasedContent = false
|
||||
hideCatalogUnderline = false
|
||||
_uiState.value = HomeCatalogSettingsUiState()
|
||||
}
|
||||
|
||||
|
|
@ -144,6 +152,7 @@ object HomeCatalogSettingsRepository {
|
|||
return HomeCatalogSettingsSnapshot(
|
||||
heroEnabled = heroEnabled,
|
||||
hideUnreleasedContent = hideUnreleasedContent,
|
||||
hideCatalogUnderline = hideCatalogUnderline,
|
||||
preferences = preferences.mapValues { (_, value) ->
|
||||
HomeCatalogPreference(
|
||||
customTitle = value.customTitle,
|
||||
|
|
@ -172,6 +181,14 @@ object HomeCatalogSettingsRepository {
|
|||
HomeRepository.applyCurrentSettings()
|
||||
}
|
||||
|
||||
fun setHideCatalogUnderline(enabled: Boolean) {
|
||||
ensureLoaded()
|
||||
if (hideCatalogUnderline == enabled) return
|
||||
hideCatalogUnderline = enabled
|
||||
publish()
|
||||
persist()
|
||||
}
|
||||
|
||||
fun setHeroSourceEnabled(key: String, enabled: Boolean) {
|
||||
updatePreference(key) { preference ->
|
||||
if (!enabled) {
|
||||
|
|
@ -200,6 +217,7 @@ object HomeCatalogSettingsRepository {
|
|||
ensureLoaded()
|
||||
heroEnabled = true
|
||||
hideUnreleasedContent = false
|
||||
hideCatalogUnderline = false
|
||||
preferences.clear()
|
||||
normalizePreferences()
|
||||
publish()
|
||||
|
|
@ -246,6 +264,7 @@ object HomeCatalogSettingsRepository {
|
|||
if (parsedPayload != null) {
|
||||
heroEnabled = parsedPayload.heroEnabled
|
||||
hideUnreleasedContent = parsedPayload.hideUnreleasedContent
|
||||
hideCatalogUnderline = parsedPayload.hideCatalogUnderline
|
||||
preferences = parsedPayload.items.associateBy { it.key }.toMutableMap()
|
||||
publish()
|
||||
return
|
||||
|
|
@ -345,6 +364,7 @@ object HomeCatalogSettingsRepository {
|
|||
_uiState.value = HomeCatalogSettingsUiState(
|
||||
heroEnabled = heroEnabled,
|
||||
hideUnreleasedContent = hideUnreleasedContent,
|
||||
hideCatalogUnderline = hideCatalogUnderline,
|
||||
items = items,
|
||||
)
|
||||
}
|
||||
|
|
@ -355,6 +375,7 @@ object HomeCatalogSettingsRepository {
|
|||
StoredHomeCatalogSettingsPayload(
|
||||
heroEnabled = heroEnabled,
|
||||
hideUnreleasedContent = hideUnreleasedContent,
|
||||
hideCatalogUnderline = hideCatalogUnderline,
|
||||
items = preferences.values.sortedBy { it.order },
|
||||
),
|
||||
),
|
||||
|
|
@ -437,6 +458,7 @@ object HomeCatalogSettingsRepository {
|
|||
}
|
||||
return SyncHomeCatalogPayload(
|
||||
hideUnreleasedContent = hideUnreleasedContent,
|
||||
hideCatalogUnderline = hideCatalogUnderline,
|
||||
items = items,
|
||||
)
|
||||
}
|
||||
|
|
@ -444,6 +466,7 @@ object HomeCatalogSettingsRepository {
|
|||
fun applyFromRemote(payload: SyncHomeCatalogPayload) {
|
||||
ensureLoaded()
|
||||
hideUnreleasedContent = payload.hideUnreleasedContent
|
||||
hideCatalogUnderline = payload.hideCatalogUnderline
|
||||
if (payload.items.isNotEmpty()) {
|
||||
val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled }
|
||||
preferences = payload.items.associate { item ->
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ data class SyncCatalogItem(
|
|||
@Serializable
|
||||
data class SyncHomeCatalogPayload(
|
||||
@SerialName("hide_unreleased_content") val hideUnreleasedContent: Boolean = false,
|
||||
@SerialName("hide_catalog_underline") val hideCatalogUnderline: Boolean = false,
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.core.ui.NuvioShelfSection
|
||||
import com.nuvio.app.core.ui.NuvioViewAllPillSize
|
||||
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.MetaPreview
|
||||
import com.nuvio.app.features.home.stableKey
|
||||
|
|
@ -64,6 +68,10 @@ private fun HomeCatalogRowSectionContent(
|
|||
onPosterLongClick: ((MetaPreview) -> Unit)?,
|
||||
) {
|
||||
val posterCardStyle = rememberPosterCardStyleUiState()
|
||||
val homeCatalogSettings by remember {
|
||||
HomeCatalogSettingsRepository.snapshot()
|
||||
HomeCatalogSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
|
||||
NuvioShelfSection(
|
||||
title = section.title,
|
||||
|
|
@ -71,6 +79,7 @@ private fun HomeCatalogRowSectionContent(
|
|||
modifier = modifier,
|
||||
headerHorizontalPadding = sectionPadding,
|
||||
rowContentPadding = PaddingValues(horizontal = sectionPadding),
|
||||
showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline,
|
||||
onViewAllClick = onViewAllClick,
|
||||
viewAllPillSize = NuvioViewAllPillSize.Compact,
|
||||
key = { item -> item.stableKey() },
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import androidx.compose.material3.CardDefaults
|
|||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.core.ui.NuvioShelfSection
|
||||
import com.nuvio.app.core.ui.PosterLandscapeAspectRatio
|
||||
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.features.collection.Collection
|
||||
import com.nuvio.app.features.collection.CollectionFolder
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
import com.nuvio.app.features.home.PosterShape
|
||||
|
||||
@Composable
|
||||
|
|
@ -71,12 +75,18 @@ private fun HomeCollectionRowSectionContent(
|
|||
animateGifs: Boolean,
|
||||
onFolderClick: ((collectionId: String, folderId: String) -> Unit)?,
|
||||
) {
|
||||
val homeCatalogSettings by remember {
|
||||
HomeCatalogSettingsRepository.snapshot()
|
||||
HomeCatalogSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
|
||||
NuvioShelfSection(
|
||||
title = collection.title,
|
||||
entries = collection.folders,
|
||||
modifier = modifier,
|
||||
headerHorizontalPadding = sectionPadding,
|
||||
rowContentPadding = PaddingValues(horizontal = sectionPadding),
|
||||
showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline,
|
||||
key = { folder -> "collection_${collection.id}_folder_${folder.id}" },
|
||||
) { folder ->
|
||||
CollectionFolderCard(
|
||||
|
|
|
|||
|
|
@ -791,8 +791,11 @@ fun PlayerScreen(
|
|||
flushWatchProgress()
|
||||
if (playerSettingsUiState.streamReuseLastLinkEnabled && activeVideoId != null) {
|
||||
val cacheKey = StreamLinkCacheRepository.contentKey(
|
||||
contentType ?: parentMetaType,
|
||||
activeVideoId!!,
|
||||
type = contentType ?: parentMetaType,
|
||||
videoId = activeVideoId!!,
|
||||
parentMetaId = parentMetaId,
|
||||
season = activeSeasonNumber,
|
||||
episode = activeEpisodeNumber,
|
||||
)
|
||||
StreamLinkCacheRepository.save(
|
||||
contentKey = cacheKey,
|
||||
|
|
@ -851,8 +854,11 @@ fun PlayerScreen(
|
|||
val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L
|
||||
if (playerSettingsUiState.streamReuseLastLinkEnabled) {
|
||||
val cacheKey = StreamLinkCacheRepository.contentKey(
|
||||
contentType ?: parentMetaType,
|
||||
epVideoId,
|
||||
type = contentType ?: parentMetaType,
|
||||
videoId = epVideoId,
|
||||
parentMetaId = parentMetaId,
|
||||
season = episode.season,
|
||||
episode = episode.episode,
|
||||
)
|
||||
StreamLinkCacheRepository.save(
|
||||
contentKey = cacheKey,
|
||||
|
|
@ -1563,8 +1569,11 @@ fun PlayerScreen(
|
|||
val currentVideoId = activeVideoId
|
||||
if (currentVideoId != null) {
|
||||
val cacheKey = StreamLinkCacheRepository.contentKey(
|
||||
contentType ?: parentMetaType,
|
||||
currentVideoId,
|
||||
type = contentType ?: parentMetaType,
|
||||
videoId = currentVideoId,
|
||||
parentMetaId = parentMetaId,
|
||||
season = activeSeasonNumber,
|
||||
episode = activeEpisodeNumber,
|
||||
)
|
||||
StreamLinkCacheRepository.remove(cacheKey)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,11 +20,11 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
|
@ -91,16 +91,57 @@ object SearchRepository {
|
|||
_uiState.value = SearchUiState(isLoading = true)
|
||||
|
||||
activeJob = scope.launch {
|
||||
val results = requests.map { request ->
|
||||
async {
|
||||
val resultChannel = Channel<IndexedSearchResult>(Channel.UNLIMITED)
|
||||
val jobs = requests.mapIndexed { index, request ->
|
||||
launch {
|
||||
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
|
||||
.mapNotNull { it.getOrNull() }
|
||||
val firstFailure = results.firstNotNullOfOrNull { it.exceptionOrNull()?.message }
|
||||
val allFailed = results.isNotEmpty() && results.all { it.isFailure }
|
||||
try {
|
||||
for (result in resultChannel) {
|
||||
results[result.index] = result
|
||||
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(
|
||||
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 {
|
||||
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
|
||||
val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate())
|
||||
|
|
|
|||
|
|
@ -334,6 +334,11 @@ fun SearchScreen(
|
|||
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.settings_homescreen_empty_message
|
||||
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_limit_reached
|
||||
import nuvio.composeapp.generated.resources.settings_homescreen_no_sources_selected
|
||||
|
|
@ -65,6 +67,7 @@ internal fun LazyListScope.homescreenSettingsContent(
|
|||
isTablet: Boolean,
|
||||
heroEnabled: Boolean,
|
||||
hideUnreleasedContent: Boolean,
|
||||
hideCatalogUnderline: Boolean,
|
||||
items: List<HomeCatalogSettingsItem>,
|
||||
) {
|
||||
val selectedHeroSourceCount = items.count { it.heroSourceEnabled }
|
||||
|
|
@ -98,6 +101,14 @@ internal fun LazyListScope.homescreenSettingsContent(
|
|||
isTablet = isTablet,
|
||||
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,
|
||||
heroEnabled = homescreenSettingsUiState.heroEnabled,
|
||||
hideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
|
||||
hideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline,
|
||||
items = homescreenSettingsUiState.items,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -237,6 +237,7 @@ fun SettingsScreen(
|
|||
traktSettingsUiState = traktSettingsUiState,
|
||||
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
|
||||
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
|
||||
homescreenHideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline,
|
||||
homescreenItems = homescreenSettingsUiState.items,
|
||||
metaScreenSettingsUiState = metaScreenSettingsUiState,
|
||||
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
|
||||
|
|
@ -283,6 +284,7 @@ fun SettingsScreen(
|
|||
traktSettingsUiState = traktSettingsUiState,
|
||||
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
|
||||
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
|
||||
homescreenHideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline,
|
||||
homescreenItems = homescreenSettingsUiState.items,
|
||||
metaScreenSettingsUiState = metaScreenSettingsUiState,
|
||||
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
|
||||
|
|
@ -339,6 +341,7 @@ private fun MobileSettingsScreen(
|
|||
traktSettingsUiState: TraktSettingsUiState,
|
||||
homescreenHeroEnabled: Boolean,
|
||||
homescreenHideUnreleasedContent: Boolean,
|
||||
homescreenHideCatalogUnderline: Boolean,
|
||||
homescreenItems: List<HomeCatalogSettingsItem>,
|
||||
metaScreenSettingsUiState: MetaScreenSettingsUiState,
|
||||
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
|
||||
|
|
@ -530,6 +533,7 @@ private fun MobileSettingsScreen(
|
|||
isTablet = false,
|
||||
heroEnabled = homescreenHeroEnabled,
|
||||
hideUnreleasedContent = homescreenHideUnreleasedContent,
|
||||
hideCatalogUnderline = homescreenHideCatalogUnderline,
|
||||
items = homescreenItems,
|
||||
)
|
||||
SettingsPage.MetaScreen -> metaScreenSettingsContent(
|
||||
|
|
@ -638,6 +642,7 @@ private fun TabletSettingsScreen(
|
|||
traktSettingsUiState: TraktSettingsUiState,
|
||||
homescreenHeroEnabled: Boolean,
|
||||
homescreenHideUnreleasedContent: Boolean,
|
||||
homescreenHideCatalogUnderline: Boolean,
|
||||
homescreenItems: List<HomeCatalogSettingsItem>,
|
||||
metaScreenSettingsUiState: MetaScreenSettingsUiState,
|
||||
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
|
||||
|
|
@ -888,6 +893,7 @@ private fun TabletSettingsScreen(
|
|||
isTablet = true,
|
||||
heroEnabled = homescreenHeroEnabled,
|
||||
hideUnreleasedContent = homescreenHideUnreleasedContent,
|
||||
hideCatalogUnderline = homescreenHideCatalogUnderline,
|
||||
items = homescreenItems,
|
||||
)
|
||||
SettingsPage.MetaScreen -> metaScreenSettingsContent(
|
||||
|
|
|
|||
|
|
@ -588,6 +588,7 @@ internal fun settingsSearchEntries(
|
|||
listOf(
|
||||
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-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-catalogs", stringResource(Res.string.settings_homescreen_section_catalogs)),
|
||||
).forEach { row ->
|
||||
|
|
|
|||
|
|
@ -22,8 +22,20 @@ internal expect fun epochMs(): Long
|
|||
object StreamLinkCacheRepository {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun contentKey(type: String, videoId: String): String =
|
||||
"${type.lowercase()}|$videoId"
|
||||
fun contentKey(
|
||||
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(
|
||||
contentKey: String,
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ enum class StreamsEmptyStateReason {
|
|||
}
|
||||
|
||||
data class StreamsUiState(
|
||||
val requestToken: String? = null,
|
||||
val groups: List<AddonStreamGroup> = emptyList(),
|
||||
val activeAddonIds: Set<String> = emptySet(),
|
||||
val selectedFilter: String? = null,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,15 @@ object StreamsRepository {
|
|||
private var activeJob: Job? = 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) {
|
||||
load(
|
||||
type = type,
|
||||
|
|
@ -65,7 +74,14 @@ object StreamsRepository {
|
|||
} else {
|
||||
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
|
||||
if (
|
||||
!forceRefresh &&
|
||||
|
|
@ -78,7 +94,7 @@ object StreamsRepository {
|
|||
|
||||
activeRequestKey = requestKey
|
||||
activeJob?.cancel()
|
||||
_uiState.value = StreamsUiState()
|
||||
_uiState.value = StreamsUiState(requestToken = requestToken)
|
||||
|
||||
PlayerSettingsRepository.ensureLoaded()
|
||||
val playerSettings = PlayerSettingsRepository.uiState.value
|
||||
|
|
@ -90,6 +106,7 @@ object StreamsRepository {
|
|||
|
||||
if (isDirectAutoPlayFlow) {
|
||||
_uiState.value = StreamsUiState(
|
||||
requestToken = requestToken,
|
||||
isDirectAutoPlayFlow = true,
|
||||
showDirectAutoPlayOverlay = true,
|
||||
)
|
||||
|
|
@ -105,6 +122,7 @@ object StreamsRepository {
|
|||
isLoading = false,
|
||||
)
|
||||
_uiState.value = StreamsUiState(
|
||||
requestToken = requestToken,
|
||||
groups = listOf(group),
|
||||
activeAddonIds = setOf("embedded"),
|
||||
isAnyLoading = false,
|
||||
|
|
@ -125,6 +143,7 @@ object StreamsRepository {
|
|||
|
||||
if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
|
||||
_uiState.value = StreamsUiState(
|
||||
requestToken = requestToken,
|
||||
isAnyLoading = false,
|
||||
emptyStateReason = StreamsEmptyStateReason.NoAddonsInstalled,
|
||||
)
|
||||
|
|
@ -151,8 +170,9 @@ object StreamsRepository {
|
|||
|
||||
log.d { "Found ${streamAddons.size} addons for stream type=$type id=$videoId" }
|
||||
|
||||
if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
|
||||
if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
|
||||
_uiState.value = StreamsUiState(
|
||||
requestToken = requestToken,
|
||||
isAnyLoading = false,
|
||||
emptyStateReason = StreamsEmptyStateReason.NoCompatibleAddons,
|
||||
)
|
||||
|
|
@ -176,6 +196,7 @@ object StreamsRepository {
|
|||
)
|
||||
}
|
||||
_uiState.value = StreamsUiState(
|
||||
requestToken = requestToken,
|
||||
groups = initialGroups,
|
||||
activeAddonIds = initialGroups.map { it.addonId }.toSet(),
|
||||
isAnyLoading = true,
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ fun StreamsScreen(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(type, videoId, manualSelection) {
|
||||
LaunchedEffect(type, videoId, seasonNumber, episodeNumber, manualSelection) {
|
||||
StreamsRepository.load(
|
||||
type = type,
|
||||
videoId = videoId,
|
||||
|
|
|
|||
|
|
@ -53,11 +53,14 @@ fun nextReleasedEpisodeAfter(
|
|||
// 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.
|
||||
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) {
|
||||
val globalIndex = episodeNumber - 1
|
||||
if (globalIndex in sortedEpisodes.indices) {
|
||||
watchedIndex = globalIndex
|
||||
if (globalIndex in mainEpisodes.indices) {
|
||||
watchedIndex = sortedEpisodes.indexOf(mainEpisodes[globalIndex])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,4 +88,31 @@ class SeriesPlaybackResolverTest {
|
|||
assertEquals("Up Next • S1E3", action.label)
|
||||
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)
|
||||
}
|
||||
|
||||
@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
|
||||
fun decideSeriesPrimaryAction_falls_back_to_specials_when_no_main_season() {
|
||||
val specialsOnly = listOf(
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
#Kotlin
|
||||
kotlin.code.style=official
|
||||
kotlin.daemon.jvmargs=-Xmx4096M
|
||||
kotlin.native.jvmArgs=-Xmx6144M
|
||||
kotlin.daemon.jvmargs=-Xmx6144M
|
||||
kotlin.native.jvmArgs=-Xmx12288M
|
||||
kotlin.mpp.enableCInteropCommonization=true
|
||||
|
||||
#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.caching=true
|
||||
|
||||
#Android
|
||||
android.nonTransitiveRClass=true
|
||||
android.useAndroidX=true
|
||||
android.useAndroidX=true
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
CURRENT_PROJECT_VERSION=56
|
||||
MARKETING_VERSION=0.1.17
|
||||
CURRENT_PROJECT_VERSION=58
|
||||
MARKETING_VERSION=0.1.0
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue