diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 6326f282f..1744b6b22 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -434,6 +434,9 @@ kotlin { implementation("io.coil-kt.coil3:coil-network-ktor3:${libs.versions.coil.get()}") { exclude(group = "org.jetbrains.skiko", module = "skiko") } + implementation("io.coil-kt.coil3:coil-network-cache-control:${libs.versions.coil.get()}") { + exclude(group = "org.jetbrains.skiko", module = "skiko") + } implementation("io.coil-kt.coil3:coil-svg:${libs.versions.coil.get()}") { exclude(group = "org.jetbrains.skiko", module = "skiko") } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 0a8583013..74179d8f1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -446,6 +446,11 @@ fun App( .memoryCachePolicy(CachePolicy.ENABLED) .components { add(SvgDecoder.Factory()) + add( + coil3.network.ktor3.KtorNetworkFetcherFactory( + cacheStrategy = { coil3.network.cachecontrol.CacheControlCacheStrategy() }, + ) + ) } .configurePlatformImageLoader() .build() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 2f6b9bb8c..bcbc4ebfe 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -1796,12 +1796,16 @@ private fun ContinueWatchingItem.withFallbackMetadata( hasPlaceholderHomeTitle() && fallbackTitle != null -> fallbackTitle else -> title }, - subtitle = subtitle.takeIf { it.isNotBlank() } - ?: fallback?.subtitle?.takeIf { it.isNotBlank() }.orEmpty(), + subtitle = when { + subtitle.isBlank() -> fallback?.subtitle?.takeIf { it.isNotBlank() }.orEmpty() + fallback?.subtitle.isNullOrBlank() -> subtitle + else -> fallback.subtitle + }, imageUrl = imageUrl.orNonBlank(fallback?.imageUrl), logo = logo.orNonBlank(fallback?.logo), poster = poster.orNonBlank(fallback?.poster), background = background.orNonBlank(fallback?.background), + videoId = fallback?.videoId?.takeIf { it.isNotBlank() } ?: videoId, episodeTitle = episodeTitle.orNonBlank(fallback?.episodeTitle), episodeThumbnail = episodeThumbnail.orNonBlank(fallback?.episodeThumbnail), pauseDescription = pauseDescription.orNonBlank(fallback?.pauseDescription), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index b19437c8b..292afb191 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -290,7 +290,11 @@ private fun HomeContinueWatchingSectionContent( key(dataSourceKey) { val disintegration = remember { ScopedDisintegrationTracker( - itemKey = ContinueWatchingItem::videoId, + itemKey = { item -> + val season = item.seasonNumber ?: -1 + val episode = item.episodeNumber ?: -1 + "${item.parentMetaId}:$season:$episode" + }, ) } val displayEntries = disintegration.sync(dataSourceKey, items) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt index 451e037b5..e9678c21d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt @@ -28,7 +28,7 @@ internal fun SimklSyncSnapshot.toSimklWatchedProjection(): SimklWatchedProjectio entries.forEach { entry -> val media = entry.media ?: return@forEach val contentId = media.canonicalContentId() ?: return@forEach - val contentType = if (entry.mediaType == SimklMediaType.MOVIES) "movie" else "series" + val contentType = if (entry.isMovieEntry()) "movie" else "series" val title = media.title?.takeIf(String::isNotBlank) ?: contentId val poster = entry.resolvedPosterUrl() val trackingProviderItemId = media.simklTrackingProviderItemId() @@ -37,7 +37,7 @@ internal fun SimklSyncSnapshot.toSimklWatchedProjection(): SimklWatchedProjectio ?: parseSimklUtcEpochMs(entry.addedToWatchlistAt) ?: 0L - if (entry.mediaType == SimklMediaType.MOVIES) { + if (entry.isMovieEntry()) { if (entry.lastWatchedAt != null || entry.status == SimklListStatus.COMPLETED) { watchedItems += WatchedItem( id = contentId, @@ -115,6 +115,7 @@ internal fun SimklSyncSnapshot.animeAlternateWatchedKeys(): Set { val extraKeys = linkedSetOf() entries.forEach { entry -> if (entry.mediaType != SimklMediaType.ANIME) return@forEach + if (entry.isMovieEntry()) return@forEach val media = entry.media ?: return@forEach val contentId = media.canonicalContentId() ?: return@forEach val contentType = "series" @@ -150,7 +151,7 @@ internal fun SimklSyncSnapshot.animeAlternateWatchedKeys(): Set { internal fun SimklSyncSnapshot.movieAlternateWatchedKeys(): Set { val extraKeys = linkedSetOf() entries.forEach { entry -> - if (entry.mediaType != SimklMediaType.MOVIES) return@forEach + if (!entry.isMovieEntry()) return@forEach if (entry.lastWatchedAt == null && entry.status != SimklListStatus.COMPLETED) return@forEach val media = entry.media ?: return@forEach val contentId = media.canonicalContentId() ?: return@forEach @@ -379,7 +380,8 @@ internal fun parseSimklUtcEpochMs(value: String?): Long? { internal fun SimklPlaybackSession.toWatchProgressEntry(): WatchProgressEntry? { val media = media ?: return null val parentId = media.canonicalContentId() ?: return null - val isMovie = mediaType == SimklMediaType.MOVIES + val isMovie = mediaType == SimklMediaType.MOVIES || + (mediaType == SimklMediaType.ANIME && episode == null) val season = episode?.tvdbSeason ?: episode?.season val episodeNumber = episode?.tvdbNumber ?: episode?.number if (!isMovie && episodeNumber == null) return null diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklScrobbleReconciliation.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklScrobbleReconciliation.kt index fd7a3d99b..9b8f4fd34 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklScrobbleReconciliation.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklScrobbleReconciliation.kt @@ -32,15 +32,17 @@ private fun SimklSyncSnapshot.withPausedScrobble( .mergeMissing(existingEntry?.media) .mergeMissing(existingSession?.media) val mediaType = existingEntry?.mediaType ?: result.mediaType + val isAnimeMovie = mediaType == SimklMediaType.ANIME && + (existingEntry?.animeType == "movie" || result.episode == null) val session = SimklPlaybackSession( id = result.playbackId ?: existingSession?.id, progress = result.progress, pausedAt = committedAt, - type = if (mediaType == SimklMediaType.MOVIES) "movie" else "episode", + type = if (mediaType == SimklMediaType.MOVIES || isAnimeMovie) "movie" else "episode", episode = result.episode?.mergeMissing(existingSession?.episode), show = media.takeIf { mediaType == SimklMediaType.SHOWS }, - anime = media.takeIf { mediaType == SimklMediaType.ANIME }, - movie = media.takeIf { mediaType == SimklMediaType.MOVIES }, + anime = media.takeIf { mediaType == SimklMediaType.ANIME && !isAnimeMovie }, + movie = media.takeIf { mediaType == SimklMediaType.MOVIES || isAnimeMovie }, ) return copy( playback = playback.filterNot { candidate -> @@ -56,11 +58,13 @@ private fun SimklSyncSnapshot.withCompletedScrobble( val updatedEntries = entries.toMutableList() val index = updatedEntries.indexOfMatchingEntry(result) val existing = updatedEntries.getOrNull(index) - val updated = when (result.mediaType) { - SimklMediaType.MOVIES -> existing + val updated = when { + result.mediaType == SimklMediaType.MOVIES || + (result.mediaType == SimklMediaType.ANIME && + (existing?.animeType == "movie" || result.episode == null)) -> existing ?.withWatchedMovie(result, committedAt) ?: result.toWatchedMovieEntry(committedAt) - SimklMediaType.SHOWS, SimklMediaType.ANIME -> existing + else -> existing ?.withWatchedEpisode(result, committedAt) ?: result.toWatchedSeriesEntry(committedAt) } @@ -81,7 +85,8 @@ private fun SimklLibraryEntry.withWatchedMovie( result: SimklScrobbleResult, committedAt: String, ): SimklLibraryEntry = copy( - mediaType = SimklMediaType.MOVIES, + mediaType = result.mediaType, + animeType = if (result.mediaType == SimklMediaType.ANIME) "movie" else animeType, lastWatchedAt = committedAt, status = SimklListStatus.COMPLETED, movie = result.media.mergeMissing(media), @@ -90,7 +95,8 @@ private fun SimklLibraryEntry.withWatchedMovie( private fun SimklScrobbleResult.toWatchedMovieEntry(committedAt: String): SimklLibraryEntry = SimklLibraryEntry( - mediaType = SimklMediaType.MOVIES, + mediaType = mediaType, + animeType = if (mediaType == SimklMediaType.ANIME) "movie" else null, lastWatchedAt = committedAt, status = SimklListStatus.COMPLETED, movie = media, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncModels.kt index f46381e23..7da534543 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncModels.kt @@ -80,6 +80,11 @@ data class SimklLibraryEntry( fun stableKey(): String? = media?.ids?.stableMediaId()?.let { id -> "${mediaType.apiValue}:$id" } + + /** True when this entry represents a movie — either a regular movie or an anime movie. */ + fun isMovieEntry(): Boolean = + mediaType == SimklMediaType.MOVIES || + (mediaType == SimklMediaType.ANIME && animeType == "movie") } @Serializable diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 391e4cc82..3c8c1c38c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -59,6 +59,7 @@ compottie = { module = "io.github.alexzhirkevich:compottie", version.ref = "comp coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" } coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } +coil-network-cache-control = { module = "io.coil-kt.coil3:coil-network-cache-control", version.ref = "coil" } coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "atomicfu" }