Merge branch 'cmp-rewrite' into patch-25

This commit is contained in:
AdityasahuX07 2026-05-08 20:30:43 +05:30 committed by GitHub
commit b481072a80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
226 changed files with 25319 additions and 3610 deletions

2
.gitignore vendored
View file

@ -25,3 +25,5 @@ keystore/
scripts/build-distribution.sh
asset
scripts/scrape_android_compose_animation_docs.py
tools
AGENTS.md

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "MPVKit"]
path = MPVKit
url = https://github.com/tapframe/MPVNuvio.git

View file

@ -76,6 +76,20 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
)
}
outDir.resolve("com/nuvio/app/features/details").apply {
mkdirs()
resolve("ImdbEpisodeRatingsConfig.kt").writeText(
"""
|package com.nuvio.app.features.details
|
|object ImdbEpisodeRatingsConfig {
| const val IMDB_RATINGS_API_BASE_URL = "${props.getProperty("IMDB_RATINGS_API_BASE_URL", "")}"
| const val IMDB_TAPFRAME_API_BASE_URL = "${props.getProperty("IMDB_TAPFRAME_API_BASE_URL", "")}"
|}
""".trimMargin()
)
}
outDir.resolve("com/nuvio/app/core/build").apply {
mkdirs()
resolve("AppVersionConfig.kt").writeText(
@ -97,6 +111,7 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|package com.nuvio.app.features.settings
|
|object CommunityConfig {
| const val CONTRIBUTIONS_URL = "${props.getProperty("CONTRIBUTIONS_URL", "")}"
| const val DONATIONS_BASE_URL = "${props.getProperty("DONATIONS_BASE_URL", "")}"
| const val DONATIONS_DONATE_URL = "${props.getProperty("DONATIONS_DONATE_URL", "")}"
|}
@ -219,6 +234,7 @@ kotlin {
}
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.work.runtime)
@ -270,12 +286,13 @@ kotlin {
afterEvaluate {
dependencies {
add("fullImplementation", libs.quickjs.kt)
add("fullImplementation", files("libs/quickjs-kt-android-1.0.5-nuvio.aar"))
add("fullImplementation", libs.ksoup)
}
}
dependencies {
coreLibraryDesugaring(libs.desugar.jdk.libs)
debugImplementation(libs.compose.uiTooling)
}
@ -348,6 +365,7 @@ android {
}
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}

Binary file not shown.

View file

@ -10,6 +10,7 @@
android:label="@string/app_name"
android:usesCleartextTraffic="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:localeConfig="@xml/locale_config"
android:supportsRtl="true"
android:theme="@style/Theme.Nuvio">
<activity

View file

@ -7,6 +7,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.SystemBarStyle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.nuvio.app.core.auth.AuthStorage
import com.nuvio.app.core.deeplink.handleAppUrl
@ -34,6 +35,7 @@ import com.nuvio.app.features.settings.ThemeSettingsStorage
import com.nuvio.app.features.trakt.TraktAuthStorage
import com.nuvio.app.features.trakt.TraktCommentsStorage
import com.nuvio.app.features.trakt.TraktLibraryStorage
import com.nuvio.app.features.trakt.TraktSettingsStorage
import com.nuvio.app.features.tmdb.TmdbSettingsStorage
import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform
import com.nuvio.app.core.ui.PosterCardStyleStorage
@ -44,7 +46,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage
import com.nuvio.app.features.watchprogress.ResumePromptStorage
import com.nuvio.app.features.watchprogress.WatchProgressStorage
class MainActivity : ComponentActivity() {
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
enableEdgeToEdge(
@ -52,6 +54,7 @@ class MainActivity : ComponentActivity() {
scrim = 0xFF020404.toInt(),
),
)
ThemeSettingsStorage.initialize(applicationContext)
super.onCreate(savedInstanceState)
window.setBackgroundDrawableResource(R.color.nuvio_background)
AddonStorage.initialize(applicationContext)
@ -66,13 +69,13 @@ class MainActivity : ComponentActivity() {
ProfilePinCacheStorage.initialize(applicationContext)
SearchHistoryStorage.initialize(applicationContext)
SeasonViewModeStorage.initialize(applicationContext)
ThemeSettingsStorage.initialize(applicationContext)
PosterCardStyleStorage.initialize(applicationContext)
TmdbSettingsStorage.initialize(applicationContext)
MdbListSettingsStorage.initialize(applicationContext)
TraktAuthStorage.initialize(applicationContext)
TraktCommentsStorage.initialize(applicationContext)
TraktLibraryStorage.initialize(applicationContext)
TraktSettingsStorage.initialize(applicationContext)
ContinueWatchingPreferencesStorage.initialize(applicationContext)
ResumePromptStorage.initialize(applicationContext)
ContinueWatchingEnrichmentStorage.initialize(applicationContext)

View file

@ -16,12 +16,14 @@ internal actual object PlatformLocalAccountDataCleaner {
"nuvio_mdblist_settings",
"nuvio_trakt_auth",
"nuvio_trakt_library",
"nuvio_trakt_settings",
"nuvio_watched",
"nuvio_stream_link_cache",
"nuvio_continue_watching_preferences",
"nuvio_episode_release_notifications",
"nuvio_episode_release_notifications_platform",
"nuvio_watch_progress",
"nuvio_collections",
"nuvio_plugins",
)

View file

@ -0,0 +1,18 @@
package com.nuvio.app.core.ui
internal actual fun isLiquidGlassNativeTabBarSupported(): Boolean = false
internal actual fun publishLiquidGlassNativeTabBarEnabled(enabled: Boolean) = Unit
internal actual fun publishNativeTabBarVisible(visible: Boolean) = Unit
internal actual fun publishNativeSelectedTab(tabName: String) = Unit
internal actual fun publishNativeTabAccentColor(hexColor: String) = Unit
internal actual fun publishNativeProfileTabIcon(
name: String?,
avatarColorHex: String?,
avatarImageUrl: String?,
avatarBackgroundColorHex: String?,
) = Unit

View file

@ -210,6 +210,7 @@ actual suspend fun httpRequestRaw(
url: String,
headers: Map<String, String>,
body: String,
followRedirects: Boolean,
): RawHttpResponse =
withContext(Dispatchers.IO) {
val normalizedMethod = method.uppercase()
@ -228,7 +229,16 @@ actual suspend fun httpRequestRaw(
builder.method(normalizedMethod, null)
}.build()
addonHttpClient.newCall(request).execute().use { response ->
val client = if (followRedirects) {
addonHttpClient
} else {
addonHttpClient.newBuilder()
.followRedirects(false)
.followSslRedirects(false)
.build()
}
client.newCall(request).execute().use { response ->
RawHttpResponse(
status = response.code,
statusText = response.message,

View file

@ -12,12 +12,13 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.nuvio.app.core.deeplink.buildDownloadsDeepLinkUrl
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
import kotlin.math.abs
internal actual object DownloadsLiveStatusPlatform {
private const val channelId = "downloads_live_status"
private const val channelName = "Downloads"
private const val channelDescription = "Shows live download progress and controls."
private const val notificationsPrefName = "nuvio_download_live_notifications"
private const val trackedDownloadIdsKey = "tracked_download_ids"
@ -143,7 +144,7 @@ internal actual object DownloadsLiveStatusPlatform {
.setProgress(0, 0, false)
.addAction(
0,
"Resume",
runBlocking { getString(Res.string.action_resume) },
buildActionPendingIntent(
context = context,
action = DownloadsNotificationActionReceiver.actionResume,
@ -163,15 +164,15 @@ internal actual object DownloadsLiveStatusPlatform {
val downloaded = formatBytes(item.downloadedBytes)
val total = item.totalBytes?.let(::formatBytes)
if (total != null) {
"Downloading $detail$downloaded / $total"
runBlocking { getString(Res.string.downloads_live_downloading_with_total, detail, downloaded, total) }
} else {
"Downloading $detail$downloaded"
runBlocking { getString(Res.string.downloads_live_downloading, detail, downloaded) }
}
}
DownloadStatus.Paused -> "Paused $detail"
DownloadStatus.Failed -> item.errorMessage?.takeIf { it.isNotBlank() } ?: "Download failed"
DownloadStatus.Completed -> "Download completed"
DownloadStatus.Paused -> runBlocking { getString(Res.string.downloads_live_paused, detail) }
DownloadStatus.Failed -> item.errorMessage?.takeIf { it.isNotBlank() } ?: runBlocking { getString(Res.string.downloads_live_failed) }
DownloadStatus.Completed -> runBlocking { getString(Res.string.downloads_live_completed) }
}
}
@ -224,8 +225,12 @@ internal actual object DownloadsLiveStatusPlatform {
if (manager.getNotificationChannel(channelId) != null) return
manager.createNotificationChannel(
NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW).apply {
description = channelDescription
NotificationChannel(
channelId,
runBlocking { getString(Res.string.downloads_channel_name) },
NotificationManager.IMPORTANCE_LOW,
).apply {
description = runBlocking { getString(Res.string.downloads_channel_description) }
},
)
}

View file

@ -8,9 +8,12 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import okhttp3.Call
import okhttp3.OkHttpClient
import okhttp3.Request
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
import java.io.File
import java.io.FileOutputStream
import java.net.URI
@ -44,7 +47,7 @@ internal actual object DownloadsPlatformDownloader {
scope.launch {
val context = appContext
if (context == null) {
onFailure("Download system is not initialized")
onFailure(runBlocking { getString(Res.string.downloads_error_not_initialized) })
return@launch
}
@ -69,7 +72,9 @@ internal actual object DownloadsPlatformDownloader {
var attemptedRangeRequest = resumeFromBytes > 0L
var httpRequest = buildRequest(if (attemptedRangeRequest) resumeFromBytes else null)
call = downloadHttpClient.newCall(httpRequest)
var response = call?.execute() ?: error("Download request failed")
var response = call?.execute() ?: error(
runBlocking { getString(Res.string.downloads_error_request_failed) },
)
if (attemptedRangeRequest && response.code == 416) {
response.close()
@ -78,12 +83,18 @@ internal actual object DownloadsPlatformDownloader {
attemptedRangeRequest = false
httpRequest = buildRequest(null)
call = downloadHttpClient.newCall(httpRequest)
response = call?.execute() ?: error("Download request failed")
response = call?.execute() ?: error(
runBlocking { getString(Res.string.downloads_error_request_failed) },
)
}
response.use { response ->
if (!response.isSuccessful) {
error("Request failed with HTTP ${response.code}")
error(
runBlocking {
getString(Res.string.downloads_error_http_failed, response.code)
},
)
}
val isPartialResume = attemptedRangeRequest && response.code == 206 && resumeFromBytes > 0L
@ -94,7 +105,9 @@ internal actual object DownloadsPlatformDownloader {
tempFile.delete()
}
val body = response.body ?: error("Empty response body")
val body = response.body ?: error(
runBlocking { getString(Res.string.downloads_error_empty_body) },
)
val totalBytes = resolveTotalBytes(
startingBytes = startingBytes,
isPartialResume = isPartialResume,
@ -131,7 +144,7 @@ internal actual object DownloadsPlatformDownloader {
onSuccess(destination.toURI().toString(), totalBytes ?: finalSize)
}
} catch (error: Throwable) {
onFailure(error.message ?: "Download failed")
onFailure(error.message ?: runBlocking { getString(Res.string.download_failed) })
}
}
@ -155,6 +168,24 @@ internal actual object DownloadsPlatformDownloader {
if (!tempFile.exists()) return true
return runCatching { tempFile.delete() }.getOrDefault(false)
}
actual fun resolveLocalFileUri(localFileUri: String?, destinationFileName: String): String? {
localFileUri
?.toLocalFileOrNull()
?.takeIf { it.exists() }
?.let { return it.toURI().toString() }
val context = appContext ?: return null
val fileName = destinationFileName.trim().takeIf { it.isNotBlank() }
?: localFileUri
?.toLocalFileOrNull()
?.name
?.takeIf { it.isNotBlank() }
?: return null
val downloadsDir = File(context.filesDir, "downloads")
val localFile = File(downloadsDir, fileName)
return localFile.takeIf { it.exists() }?.toURI()?.toString()
}
}
private class AndroidDownloadsTaskHandle(

View file

@ -23,6 +23,9 @@ import androidx.work.WorkManager
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.android.Android
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.get
import java.time.LocalDate
@ -285,13 +288,13 @@ internal actual object EpisodeReleaseNotificationPlatform {
val channel = NotificationChannel(
channelId,
"Episode Releases",
runBlocking { getString(Res.string.notifications_channel_episode_releases_name) },
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Alerts when a saved show's new episode is released."
description = runBlocking { getString(Res.string.notifications_channel_episode_releases_description) }
}
notificationManager.createNotificationChannel(channel)
}
private fun uniqueWorkName(requestId: String): String = "$workTag:$requestId"
}
}

View file

@ -0,0 +1,91 @@
package com.nuvio.app.features.player
import android.content.res.Resources
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.DefaultTrackNameProvider
@UnstableApi
class CustomDefaultTrackNameProvider(resources: Resources) : DefaultTrackNameProvider(resources) {
override fun getTrackName(format: Format): String {
var trackName = super.getTrackName(format)
if (format.sampleMimeType != null) {
var sampleFormat = formatNameFromMime(format.sampleMimeType)
if (sampleFormat == null) {
sampleFormat = formatNameFromMime(format.codecs)
}
if (sampleFormat == null) {
sampleFormat = format.sampleMimeType
}
if (sampleFormat != null) {
trackName += " ($sampleFormat)"
}
}
if (format.label != null) {
if (!trackName.startsWith(format.label!!)) {
trackName += " - ${format.label}"
}
}
return trackName
}
companion object {
fun formatNameFromMime(mimeType: String?): String? {
if (mimeType == null) return null
return when (mimeType) {
MimeTypes.AUDIO_DTS -> "DTS"
MimeTypes.AUDIO_DTS_HD -> "DTS-HD"
MimeTypes.AUDIO_DTS_EXPRESS -> "DTS Express"
MimeTypes.AUDIO_TRUEHD -> "TrueHD"
MimeTypes.AUDIO_AC3 -> "AC-3"
MimeTypes.AUDIO_E_AC3 -> "E-AC-3"
MimeTypes.AUDIO_E_AC3_JOC -> "E-AC-3-JOC"
MimeTypes.AUDIO_AC4 -> "AC-4"
MimeTypes.AUDIO_AAC -> "AAC"
MimeTypes.AUDIO_MPEG -> "MP3"
MimeTypes.AUDIO_MPEG_L2 -> "MP2"
MimeTypes.AUDIO_VORBIS -> "Vorbis"
MimeTypes.AUDIO_OPUS -> "Opus"
MimeTypes.AUDIO_FLAC -> "FLAC"
MimeTypes.AUDIO_ALAC -> "ALAC"
MimeTypes.AUDIO_WAV -> "WAV"
MimeTypes.AUDIO_AMR -> "AMR"
MimeTypes.AUDIO_AMR_NB -> "AMR-NB"
MimeTypes.AUDIO_AMR_WB -> "AMR-WB"
MimeTypes.AUDIO_IAMF -> "IAMF"
MimeTypes.AUDIO_MPEGH_MHA1 -> "MPEG-H"
MimeTypes.AUDIO_MPEGH_MHM1 -> "MPEG-H"
MimeTypes.VIDEO_H264 -> "AVC"
MimeTypes.VIDEO_H265 -> "HEVC"
MimeTypes.VIDEO_AV1 -> "AV1"
MimeTypes.VIDEO_VP8 -> "VP8"
MimeTypes.VIDEO_VP9 -> "VP9"
MimeTypes.VIDEO_DOLBY_VISION -> "Dolby Vision"
"application/pgs" -> "PGS"
MimeTypes.APPLICATION_SUBRIP -> "SRT"
MimeTypes.TEXT_SSA -> "SSA"
MimeTypes.TEXT_VTT -> "VTT"
MimeTypes.APPLICATION_TTML -> "TTML"
MimeTypes.APPLICATION_TX3G -> "TX3G"
MimeTypes.APPLICATION_DVBSUBS -> "DVB"
else -> null
}
}
fun getChannelLayoutName(channelCount: Int): String? {
return when (channelCount) {
1 -> "Mono"
2 -> "Stereo"
6 -> "5.1"
8 -> "7.1"
else -> if (channelCount > 0) "${channelCount}ch" else null
}
}
}
}

View file

@ -23,6 +23,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.C
@ -55,7 +58,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
import java.util.Locale
private const val TAG = "NuvioPlayer"
@ -177,6 +179,10 @@ actual fun PlatformPlayerSurface(
var currentSubtitleStyle by remember { mutableStateOf(SubtitleStyleState.DEFAULT) }
var subtitleSelectionJob by remember { mutableStateOf<Job?>(null) }
fun syncPlayerViewKeepScreenOn() {
playerViewRef?.keepScreenOn = exoPlayer.shouldKeepPlayerScreenOn()
}
DisposableEffect(exoPlayer) {
PlayerPictureInPictureManager.registerPausePlaybackCallback {
exoPlayer.pause()
@ -184,7 +190,8 @@ actual fun PlatformPlayerSurface(
val listener = object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
latestOnError.value(error.localizedMessage ?: "Unable to play this stream.")
syncPlayerViewKeepScreenOn()
latestOnError.value(error.localizedMessage ?: runBlocking { getString(Res.string.player_unable_to_play_stream) })
}
override fun onPlaybackStateChanged(playbackState: Int) {
@ -200,10 +207,12 @@ actual fun PlatformPlayerSurface(
latestOnError.value(null)
exoPlayer.logCurrentTracks("STATE_READY")
}
syncPlayerViewKeepScreenOn()
latestOnSnapshot.value(exoPlayer.snapshot())
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
syncPlayerViewKeepScreenOn()
latestOnSnapshot.value(exoPlayer.snapshot())
}
@ -233,6 +242,7 @@ actual fun PlatformPlayerSurface(
onDispose {
PlayerPictureInPictureManager.registerPausePlaybackCallback(null)
exoPlayer.removeListener(listener)
playerViewRef?.keepScreenOn = false
subtitleSelectionJob?.cancel()
}
}
@ -262,6 +272,7 @@ actual fun PlatformPlayerSurface(
LaunchedEffect(exoPlayer, playWhenReady) {
exoPlayer.playWhenReady = playWhenReady
syncPlayerViewKeepScreenOn()
latestOnSnapshot.value(exoPlayer.snapshot())
}
@ -295,10 +306,10 @@ actual fun PlatformPlayerSurface(
}
override fun getAudioTracks(): List<AudioTrack> =
exoPlayer.extractAudioTracks()
exoPlayer.extractAudioTracks(context)
override fun getSubtitleTracks(): List<SubtitleTrack> {
val tracks = exoPlayer.extractSubtitleTracks()
val tracks = exoPlayer.extractSubtitleTracks(context)
Log.d(TAG, "getSubtitleTracks: found ${tracks.size} tracks")
tracks.forEach { t ->
Log.d(TAG, " track idx=${t.index} id=${t.id} label='${t.label}' lang=${t.language} selected=${t.isSelected}")
@ -422,7 +433,7 @@ actual fun PlatformPlayerSurface(
useController = useNativeController
layoutParams = android.view.ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
player = exoPlayer
keepScreenOn = true
keepScreenOn = exoPlayer.shouldKeepPlayerScreenOn()
this.resizeMode = resizeMode.toExoResizeMode()
setShutterBackgroundColor(android.graphics.Color.BLACK)
playerViewRef = this
@ -439,6 +450,7 @@ actual fun PlatformPlayerSurface(
playerView.useController = useNativeController
playerView.resizeMode = resizeMode.toExoResizeMode()
playerViewRef = playerView
syncPlayerViewKeepScreenOn()
playerView.syncLibassOverlay(
player = exoPlayer,
enabled = useLibass,
@ -467,6 +479,11 @@ private fun ExoPlayer.snapshot(): PlayerPlaybackSnapshot =
playbackSpeed = playbackParameters.speed,
)
private fun ExoPlayer.shouldKeepPlayerScreenOn(): Boolean =
playerError == null &&
playWhenReady &&
playbackState in setOf(Player.STATE_BUFFERING, Player.STATE_READY)
private fun PlayerResizeMode.toExoResizeMode(): Int =
when (this) {
PlayerResizeMode.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT
@ -556,44 +573,20 @@ private fun PlayerView.applySubtitleStyle(style: SubtitleStyleState) {
}
}
private fun ExoPlayer.extractAudioTracks(): List<AudioTrack> {
private fun ExoPlayer.extractAudioTracks(context: Context): List<AudioTrack> {
val tracks = mutableListOf<AudioTrack>()
val trackNameProvider = CustomDefaultTrackNameProvider(context.resources)
var idx = 0
for (group in currentTracks.groups) {
if (group.type != C.TRACK_TYPE_AUDIO) continue
val format = group.mediaTrackGroup.getFormat(0)
val channelLabel = when {
format.channelCount == 1 -> "Mono"
format.channelCount == 2 -> "Stereo"
format.channelCount == 6 -> "5.1"
format.channelCount == 8 -> "7.1"
format.channelCount > 0 -> "${format.channelCount}ch"
else -> null
}
val mime = format.sampleMimeType?.lowercase()
val codecLabel = when {
mime == null -> null
mime.contains("eac3-joc") -> "Dolby Atmos"
mime.contains("truehd") && format.channelCount >= 8 -> "Dolby Atmos"
mime.contains("truehd") -> "Dolby TrueHD"
mime.contains("eac3") -> "Dolby Digital Plus"
mime.contains("ac3") -> "Dolby Digital"
mime.contains("opus") -> "Opus"
mime.contains("aac") -> "AAC"
mime.contains("dts-hd") -> "DTS-HD"
mime.contains("dts") -> "DTS"
else -> null
}
val resolvedLanguage = format.language?.let { lang -> Locale(lang).displayLanguage.takeIf { name -> name.isNotBlank() && name != lang } }
val baseName = format.label?.takeIf { it.isNotBlank() } ?: resolvedLanguage ?: format.language ?: "Track ${idx + 1}"
val suffix = listOfNotNull(channelLabel, codecLabel)
.joinToString(" ")
.let { if (it.isNotBlank()) " ($it)" else "" }
val label = trackNameProvider.getTrackName(format).takeIf { it.isNotBlank() }
?: runBlocking { getString(Res.string.compose_player_track_number, idx + 1) }
tracks.add(
AudioTrack(
index = idx,
id = format.id ?: idx.toString(),
label = "$baseName$suffix",
label = label,
language = format.language,
isSelected = group.isSelected,
)
@ -603,8 +596,9 @@ private fun ExoPlayer.extractAudioTracks(): List<AudioTrack> {
return tracks
}
private fun ExoPlayer.extractSubtitleTracks(): List<SubtitleTrack> {
private fun ExoPlayer.extractSubtitleTracks(context: Context): List<SubtitleTrack> {
val tracks = mutableListOf<SubtitleTrack>()
val trackNameProvider = CustomDefaultTrackNameProvider(context.resources)
var idx = 0
for (group in currentTracks.groups) {
if (group.type != C.TRACK_TYPE_TEXT) continue
@ -614,7 +608,7 @@ private fun ExoPlayer.extractSubtitleTracks(): List<SubtitleTrack> {
SubtitleTrack(
index = idx,
id = format.id ?: idx.toString(),
label = format.label ?: "",
label = trackNameProvider.getTrackName(format),
language = format.language,
isSelected = group.isSelected,
isForced = inferForcedSubtitleTrack(

View file

@ -35,7 +35,7 @@ actual fun LockPlayerToLandscape() {
}
@Composable
actual fun EnterImmersivePlayerMode() {
actual fun EnterImmersivePlayerMode(keepScreenAwake: Boolean) {
val activity = LocalContext.current.findActivity() ?: return
DisposableEffect(activity) {

View file

@ -45,6 +45,8 @@ actual object PlayerSettingsStorage {
private const val skipIntroEnabledKey = "skip_intro_enabled"
private const val animeSkipEnabledKey = "animeskip_enabled"
private const val animeSkipClientIdKey = "animeskip_client_id"
private const val introDbApiKeyKey = "introdb_api_key"
private const val introSubmitEnabledKey = "intro_submit_enabled"
private const val streamAutoPlayNextEpisodeEnabledKey = "stream_auto_play_next_episode_enabled"
private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group"
private const val nextEpisodeThresholdModeKey = "next_episode_threshold_mode"
@ -480,6 +482,33 @@ actual object PlayerSettingsStorage {
?.apply()
}
actual fun loadIntroDbApiKey(): String? =
preferences?.getString(ProfileScopedKey.of(introDbApiKeyKey), null)
actual fun saveIntroDbApiKey(apiKey: String) {
preferences
?.edit()
?.putString(ProfileScopedKey.of(introDbApiKeyKey), apiKey)
?.apply()
}
actual fun loadIntroSubmitEnabled(): Boolean? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(introSubmitEnabledKey)
if (sharedPreferences.contains(key)) {
sharedPreferences.getBoolean(key, false)
} else {
null
}
}
actual fun saveIntroSubmitEnabled(enabled: Boolean) {
preferences
?.edit()
?.putBoolean(ProfileScopedKey.of(introSubmitEnabledKey), enabled)
?.apply()
}
actual fun loadStreamAutoPlayNextEpisodeEnabled(): Boolean? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(streamAutoPlayNextEpisodeEnabledKey)
@ -652,6 +681,8 @@ actual object PlayerSettingsStorage {
payload.decodeSyncBoolean(skipIntroEnabledKey)?.let(::saveSkipIntroEnabled)
payload.decodeSyncBoolean(animeSkipEnabledKey)?.let(::saveAnimeSkipEnabled)
payload.decodeSyncString(animeSkipClientIdKey)?.let(::saveAnimeSkipClientId)
payload.decodeSyncString(introDbApiKeyKey)?.let(::saveIntroDbApiKey)
payload.decodeSyncBoolean(introSubmitEnabledKey)?.let(::saveIntroSubmitEnabled)
payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled)
payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup)
payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode)

View file

@ -2,6 +2,8 @@ package com.nuvio.app.features.settings
import android.content.Context
import android.content.SharedPreferences
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import com.nuvio.app.core.sync.decodeSyncBoolean
import com.nuvio.app.core.sync.decodeSyncString
import com.nuvio.app.core.sync.encodeSyncBoolean
@ -15,12 +17,20 @@ actual object ThemeSettingsStorage {
private const val preferencesName = "nuvio_theme_settings"
private const val selectedThemeKey = "selected_theme"
private const val amoledEnabledKey = "amoled_enabled"
private val syncKeys = listOf(selectedThemeKey, amoledEnabledKey)
private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled"
private const val selectedAppLanguageKey = "selected_app_language"
private val profileScopedSyncKeys = listOf(
selectedThemeKey,
amoledEnabledKey,
liquidGlassNativeTabBarEnabledKey,
)
private val globalSyncKeys = listOf(selectedAppLanguageKey)
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
}
actual fun loadSelectedTheme(): String? =
@ -46,17 +56,57 @@ actual object ThemeSettingsStorage {
?.apply()
}
actual fun loadLiquidGlassNativeTabBarEnabled(): Boolean? =
preferences?.let { prefs ->
val key = ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey)
if (prefs.contains(key)) prefs.getBoolean(key, false) else null
}
actual fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean) {
preferences
?.edit()
?.putBoolean(ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey), enabled)
?.apply()
}
actual fun loadSelectedAppLanguage(): String? {
val value = preferences?.getString(selectedAppLanguageKey, null)
if (value != null) return value
val legacy = preferences?.getString(ProfileScopedKey.of(selectedAppLanguageKey), null)
if (legacy != null) saveSelectedAppLanguage(legacy)
return legacy
}
actual fun saveSelectedAppLanguage(languageCode: String) {
preferences
?.edit()
?.putString(selectedAppLanguageKey, languageCode)
?.apply()
}
actual fun applySelectedAppLanguage(languageCode: String) {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags(languageCode),
)
}
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) }
loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) }
}
actual fun replaceFromSyncPayload(payload: JsonObject) {
preferences?.edit()?.apply {
syncKeys.forEach { remove(ProfileScopedKey.of(it)) }
profileScopedSyncKeys.forEach { remove(ProfileScopedKey.of(it)) }
globalSyncKeys.forEach { remove(it) }
}?.apply()
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled)
payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage)
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
}
}

View file

@ -0,0 +1,26 @@
package com.nuvio.app.features.trakt
import android.content.Context
import android.content.SharedPreferences
import com.nuvio.app.core.storage.ProfileScopedKey
internal actual object TraktSettingsStorage {
private const val preferencesName = "nuvio_trakt_settings"
private const val payloadKey = "trakt_settings_payload"
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
}
actual fun loadPayload(): String? =
preferences?.getString(ProfileScopedKey.of(payloadKey), null)
actual fun savePayload(payload: String) {
preferences
?.edit()
?.putString(ProfileScopedKey.of(payloadKey), payload)
?.apply()
}
}

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Nuvio</string>
</resources>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Nuvio" parent="@android:style/Theme.Material.NoActionBar">
<style name="Theme.Nuvio" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowBackground">@color/nuvio_background</item>
</style>
@ -9,4 +9,4 @@
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash_logo</item>
<item name="postSplashScreenTheme">@style/Theme.Nuvio</item>
</style>
</resources>
</resources>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Nuvio" parent="@android:style/Theme.Material.NoActionBar">
<style name="Theme.Nuvio" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowBackground">@color/nuvio_background</item>
</style>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en"/>
<locale android:name="fr"/>
<locale android:name="es"/>
<locale android:name="pt"/>
<locale android:name="tr"/>
<locale android:name="it"/>
<locale android:name="el"/>
<locale android:name="pl"/>
<locale android:name="de"/>
</locale-config>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -39,6 +39,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
@ -60,6 +61,8 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@ -92,6 +95,10 @@ import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.core.ui.NuvioFloatingPrompt
import com.nuvio.app.core.ui.TraktListPickerDialog
import com.nuvio.app.core.ui.NuvioTheme
import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding
import com.nuvio.app.core.ui.NativeNavigationTab
import com.nuvio.app.core.ui.NativeTabBridge
import com.nuvio.app.core.ui.isLiquidGlassNativeTabBarSupported
import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle
import com.nuvio.app.features.auth.AuthScreen
import com.nuvio.app.features.addons.AddonRepository
@ -123,11 +130,13 @@ import com.nuvio.app.features.player.PlayerRoute
import com.nuvio.app.features.player.PlayerScreen
import com.nuvio.app.features.player.sanitizePlaybackHeaders
import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders
import com.nuvio.app.features.profiles.AvatarRepository
import com.nuvio.app.features.profiles.NuvioProfile
import com.nuvio.app.features.profiles.ProfileEditScreen
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.profiles.ProfileSelectionScreen
import com.nuvio.app.features.profiles.ProfileSwitcherTab
import com.nuvio.app.features.profiles.profileAvatarImageUrl
import com.nuvio.app.features.search.SearchScreen
import com.nuvio.app.features.settings.SettingsScreen
import com.nuvio.app.features.settings.HomescreenSettingsScreen
@ -153,8 +162,6 @@ import com.nuvio.app.features.streams.StreamsRepository
import com.nuvio.app.features.streams.StreamsScreen
import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktConnectionMode
import com.nuvio.app.features.trakt.TraktListTab
import com.nuvio.app.features.updater.AppUpdaterHost
import com.nuvio.app.features.updater.rememberAppUpdaterController
@ -263,6 +270,20 @@ enum class AppScreenTab {
Settings,
}
private fun AppScreenTab.toNativeNavigationTab(): NativeNavigationTab = when (this) {
AppScreenTab.Home -> NativeNavigationTab.Home
AppScreenTab.Search -> NativeNavigationTab.Search
AppScreenTab.Library -> NativeNavigationTab.Library
AppScreenTab.Settings -> NativeNavigationTab.Settings
}
private fun NativeNavigationTab.toAppScreenTab(): AppScreenTab = when (this) {
NativeNavigationTab.Home -> AppScreenTab.Home
NativeNavigationTab.Search -> AppScreenTab.Search
NativeNavigationTab.Library -> AppScreenTab.Library
NativeNavigationTab.Settings -> AppScreenTab.Settings
}
private enum class AppGateScreen {
Loading,
Auth,
@ -296,13 +317,36 @@ fun App() {
LaunchedEffect(Unit) {
NetworkStatusRepository.ensureStarted()
ProfileRepository.loadCachedProfiles()
AvatarRepository.fetchAvatars()
}
val authState by AuthRepository.state.collectAsStateWithLifecycle()
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val profileAvatars by AvatarRepository.avatars.collectAsStateWithLifecycle()
val networkStatusUiState by remember {
NetworkStatusRepository.uiState
}.collectAsStateWithLifecycle()
LaunchedEffect(
profileState.activeProfile?.profileIndex,
profileState.activeProfile?.name,
profileState.activeProfile?.avatarColorHex,
profileState.activeProfile?.avatarId,
profileState.activeProfile?.avatarUrl,
profileAvatars,
) {
val activeProfile = profileState.activeProfile
val avatarItem = activeProfile?.avatarId?.let { avatarId ->
profileAvatars.find { it.id == avatarId }
}
NativeTabBridge.publishProfileTabIcon(
name = activeProfile?.name,
avatarColorHex = activeProfile?.avatarColorHex,
avatarImageUrl = activeProfile?.let { profileAvatarImageUrl(it, avatarItem) },
avatarBackgroundColorHex = avatarItem?.bgColor,
)
}
var gateScreen by rememberSaveable { mutableStateOf(AppGateScreen.Loading.name) }
var editingProfile by remember { mutableStateOf<NuvioProfile?>(null) }
var isNewProfile by remember { mutableStateOf(false) }
@ -452,47 +496,48 @@ fun App() {
private fun MainAppContent(
onSwitchProfile: () -> Unit = {},
) {
val navController = rememberNavController()
val appUpdaterController = rememberAppUpdaterController()
remember {
EpisodeReleaseNotificationsRepository.ensureLoaded()
}
remember {
CollectionSyncService.startObserving()
}
remember {
HomeCatalogSettingsSyncService.startObserving()
}
remember {
ProfileSettingsSync.startObserving()
}
val hapticFeedback = LocalHapticFeedback.current
val coroutineScope = rememberCoroutineScope()
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
var showExitConfirmation by rememberSaveable { mutableStateOf(false) }
var selectedPosterForActions by remember { mutableStateOf<MetaPreview?>(null) }
var selectedContinueWatchingForActions by remember { mutableStateOf<ContinueWatchingItem?>(null) }
var showLibraryListPicker by remember { mutableStateOf(false) }
var pickerItem by remember { mutableStateOf<LibraryItem?>(null) }
var pickerTitle by remember { mutableStateOf("") }
var pickerTabs by remember { mutableStateOf<List<TraktListTab>>(emptyList()) }
var pickerMembership by remember { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
var pickerPending by remember { mutableStateOf(false) }
var pickerError by remember { mutableStateOf<String?>(null) }
val addonsUiState by remember {
AddonRepository.initialize()
AddonRepository.uiState
}.collectAsStateWithLifecycle()
val libraryUiState by remember {
LibraryRepository.ensureLoaded()
LibraryRepository.uiState
}.collectAsStateWithLifecycle()
val traktAuthUiState by remember {
TraktAuthRepository.ensureLoaded()
TraktAuthRepository.uiState
}.collectAsStateWithLifecycle()
val authState by AuthRepository.state.collectAsStateWithLifecycle()
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val navController = rememberNavController()
val appUpdaterController = rememberAppUpdaterController()
remember {
EpisodeReleaseNotificationsRepository.ensureLoaded()
}
remember {
CollectionSyncService.startObserving()
}
remember {
HomeCatalogSettingsSyncService.startObserving()
}
remember {
ProfileSettingsSync.startObserving()
}
val hapticFeedback = LocalHapticFeedback.current
val coroutineScope = rememberCoroutineScope()
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle()
val liquidGlassNativeTabBarEnabled by remember {
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
}.collectAsStateWithLifecycle()
val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() }
var showExitConfirmation by rememberSaveable { mutableStateOf(false) }
var selectedPosterForActions by remember { mutableStateOf<MetaPreview?>(null) }
var selectedContinueWatchingForActions by remember { mutableStateOf<ContinueWatchingItem?>(null) }
var showLibraryListPicker by remember { mutableStateOf(false) }
var pickerItem by remember { mutableStateOf<LibraryItem?>(null) }
var pickerTitle by remember { mutableStateOf("") }
var pickerTabs by remember { mutableStateOf<List<TraktListTab>>(emptyList()) }
var pickerMembership by remember { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
var pickerPending by remember { mutableStateOf(false) }
var pickerError by remember { mutableStateOf<String?>(null) }
val addonsUiState by remember {
AddonRepository.initialize()
AddonRepository.uiState
}.collectAsStateWithLifecycle()
val libraryUiState by remember {
LibraryRepository.ensureLoaded()
LibraryRepository.uiState
}.collectAsStateWithLifecycle()
val authState by AuthRepository.state.collectAsStateWithLifecycle()
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val playerSettingsUiState by remember {
PlayerSettingsRepository.ensureLoaded()
PlayerSettingsRepository.uiState
@ -509,7 +554,7 @@ private fun MainAppContent(
NetworkStatusRepository.uiState
}.collectAsStateWithLifecycle()
val downloadedProviderLabel = stringResource(Res.string.provider_downloaded)
val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED
val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT
var initialHomeReady by rememberSaveable { mutableStateOf(false) }
var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) }
var networkToastBaselineReady by rememberSaveable { mutableStateOf(false) }
@ -522,6 +567,42 @@ private fun MainAppContent(
.sorted()
}
LaunchedEffect(nativeRequestedTab) {
if (liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled) {
selectedTab = nativeRequestedTab.toAppScreenTab()
}
}
LaunchedEffect(selectedTab) {
NativeTabBridge.publishSelectedTab(selectedTab.toNativeNavigationTab())
}
DisposableEffect(
navController,
liquidGlassNativeTabBarSupported,
liquidGlassNativeTabBarEnabled,
initialHomeReady,
) {
fun publishNativeTabVisibilityForCurrentRoute() {
val visible = liquidGlassNativeTabBarSupported &&
liquidGlassNativeTabBarEnabled &&
initialHomeReady &&
navController.currentDestination?.hasRoute<TabsRoute>() == true
NativeTabBridge.publishTabBarVisible(visible)
}
val destinationChangedListener = NavController.OnDestinationChangedListener { _, _, _ ->
publishNativeTabVisibilityForCurrentRoute()
}
publishNativeTabVisibilityForCurrentRoute()
navController.addOnDestinationChangedListener(destinationChangedListener)
onDispose {
navController.removeOnDestinationChangedListener(destinationChangedListener)
NativeTabBridge.publishTabBarVisible(false)
}
}
LaunchedEffect(Unit) {
NetworkStatusRepository.ensureStarted()
EpisodeReleaseNotificationsRepository.refreshAsync()
@ -597,7 +678,9 @@ private fun MainAppContent(
NetworkCondition.ServersUnreachable,
-> {
offlineLaunchRouteHandled = true
val hasPlayableDownload = downloadsUiState.completedItems.any { it.isPlayable }
val hasPlayableDownload = downloadsUiState.completedItems.any {
DownloadsRepository.playableLocalFileUri(it) != null
}
if (hasPlayableDownload) {
selectedTab = AppScreenTab.Settings
navController.navigate(DownloadsSettingsRoute) {
@ -656,7 +739,66 @@ private fun MainAppContent(
AppDeepLinkRepository.markConsumed(deepLink)
}
null -> Unit
fun launchPlaybackWithDownloadPreference(
type: String,
videoId: String,
parentMetaId: String,
parentMetaType: String,
title: String,
logo: String?,
poster: String?,
background: String?,
seasonNumber: Int?,
episodeNumber: Int?,
episodeTitle: String?,
episodeThumbnail: String?,
pauseDescription: String?,
resumePositionMs: Long?,
resumeProgressFraction: Float?,
manualSelection: Boolean,
startFromBeginning: Boolean,
) {
val targetResumePositionMs = if (startFromBeginning) 0L else (resumePositionMs ?: 0L)
val targetResumeProgressFraction = if (startFromBeginning) null else resumeProgressFraction
if (!manualSelection) {
val downloadedItem = DownloadsRepository.findPlayableDownload(
parentMetaId = parentMetaId,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
videoId = videoId,
)
val localSourceUrl = downloadedItem?.let(DownloadsRepository::playableLocalFileUri)
if (!localSourceUrl.isNullOrBlank()) {
val launchId = PlayerLaunchStore.put(
PlayerLaunch(
title = title,
sourceUrl = localSourceUrl,
sourceHeaders = emptyMap(),
sourceResponseHeaders = emptyMap(),
logo = logo,
poster = poster,
background = background,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
streamTitle = downloadedItem.streamTitle.ifBlank { title },
streamSubtitle = downloadedItem.streamSubtitle,
pauseDescription = pauseDescription,
providerName = downloadedItem.providerName.ifBlank { downloadedProviderLabel },
providerAddonId = downloadedItem.providerAddonId,
contentType = type,
videoId = videoId,
parentMetaId = parentMetaId,
parentMetaType = parentMetaType,
initialPositionMs = targetResumePositionMs,
initialProgressFraction = targetResumeProgressFraction,
),
)
navController.navigate(PlayerRoute(launchId = launchId))
return
}
}
}
}
@ -723,52 +865,22 @@ private fun MainAppContent(
}
}
val streamLaunchId = StreamLaunchStore.put(
StreamLaunch(
type = type,
videoId = videoId,
parentMetaId = parentMetaId,
parentMetaType = parentMetaType,
title = title,
logo = logo,
poster = poster,
background = background,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
pauseDescription = pauseDescription,
resumePositionMs = if (startFromBeginning) 0L else resumePositionMs,
resumeProgressFraction = targetResumeProgressFraction,
manualSelection = manualSelection,
startFromBeginning = startFromBeginning,
),
)
navController.navigate(
StreamRoute(launchId = streamLaunchId),
)
}
val librarySectionSubtitle = if (libraryUiState.sourceMode == LibrarySourceMode.TRAKT) {
stringResource(Res.string.compose_catalog_subtitle_trakt_library)
} else {
stringResource(Res.string.compose_catalog_subtitle_library)
}
val onPlay: (String, String, String, String, String, String?, String?, String?, Int?, Int?, String?, String?, String?, Long?) -> Unit =
{ type, videoId, parentMetaId, parentMetaType, title, logo, poster, background, seasonNumber, episodeNumber, episodeTitle, episodeThumbnail, pauseDescription, resumePositionMs ->
launchPlaybackWithDownloadPreference(
type = type,
videoId = videoId,
parentMetaId = parentMetaId,
parentMetaType = parentMetaType,
title = title,
logo = logo,
poster = poster,
background = background,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
pauseDescription = pauseDescription,
resumePositionMs = resumePositionMs,
resumeProgressFraction = null,
manualSelection = false,
startFromBeginning = false,
val onLibrarySectionViewAllClick: (LibrarySection) -> Unit = { section ->
navController.navigate(
CatalogRoute(
title = section.displayTitle,
subtitle = librarySectionSubtitle,
manifestUrl = INTERNAL_LIBRARY_MANIFEST_URL,
type = section.items.firstOrNull()?.type ?: "movie",
catalogId = section.type,
supportsPagination = false,
),
)
}
@ -891,6 +1003,8 @@ private fun MainAppContent(
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val isTabletLayout = maxWidth >= 768.dp
val useNativeBottomTabs =
liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady
val onProfileSelected: (NuvioProfile) -> Unit = { profile ->
profileSwitchLoading = true
selectedTab = AppScreenTab.Home
@ -905,7 +1019,7 @@ private fun MainAppContent(
containerColor = Color.Transparent,
contentWindowInsets = WindowInsets(0),
bottomBar = {
if (!isTabletLayout) {
if (!isTabletLayout && !useNativeBottomTabs) {
NuvioNavigationBar {
NavItem(
selected = selectedTab == AppScreenTab.Home,
@ -941,62 +1055,62 @@ private fun MainAppContent(
},
) { innerPadding ->
Box(modifier = Modifier.fillMaxSize()) {
AppTabHost(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
selectedTab = selectedTab,
onCatalogClick = onCatalogClick,
onPosterClick = { meta ->
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
},
onPosterLongClick = { meta ->
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
selectedPosterForActions = meta
},
onLibraryPosterClick = { item ->
navController.navigate(DetailRoute(type = item.type, id = item.id))
},
onLibraryPosterLongClick = { item ->
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
selectedPosterForActions = item.toMetaPreview()
},
onLibrarySectionViewAllClick = onLibrarySectionViewAllClick,
onContinueWatchingClick = onContinueWatchingClick,
onContinueWatchingLongPress = onContinueWatchingLongPress,
onSwitchProfile = onSwitchProfile,
onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) },
onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) },
onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) },
onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) },
onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) },
onPluginsSettingsClick = {
if (AppFeaturePolicy.pluginsEnabled) {
navController.navigate(PluginsSettingsRoute)
}
},
onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) },
onSupportersContributorsSettingsClick = {
navController.navigate(SupportersContributorsSettingsRoute)
},
onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) {
{
appUpdaterController.checkForUpdates(
force = true,
showNoUpdateFeedback = true,
)
}
} else {
null
},
onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) },
onFolderClick = { collectionId, folderId ->
navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
},
onInitialHomeContentRendered = { initialHomeReady = true },
)
CompositionLocalProvider(
LocalNuvioBottomNavigationOverlayPadding provides if (useNativeBottomTabs) 49.dp else 0.dp,
) {
AppTabHost(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
selectedTab = selectedTab,
onCatalogClick = onCatalogClick,
onPosterClick = { meta ->
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
},
onPosterLongClick = { meta ->
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
selectedPosterForActions = meta
},
onLibraryPosterClick = { item ->
navController.navigate(DetailRoute(type = item.type, id = item.id))
},
onLibrarySectionViewAllClick = onLibrarySectionViewAllClick,
onContinueWatchingClick = onContinueWatchingClick,
onContinueWatchingLongPress = onContinueWatchingLongPress,
onSwitchProfile = onSwitchProfile,
onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) },
onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) },
onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) },
onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) },
onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) },
onPluginsSettingsClick = {
if (AppFeaturePolicy.pluginsEnabled) {
navController.navigate(PluginsSettingsRoute)
}
},
onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) },
onSupportersContributorsSettingsClick = {
navController.navigate(SupportersContributorsSettingsRoute)
},
onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) {
{
appUpdaterController.checkForUpdates(
force = true,
showNoUpdateFeedback = true,
)
}
} else {
null
},
onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) },
onFolderClick = { collectionId, folderId ->
navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
},
onInitialHomeContentRendered = { initialHomeReady = true },
)
}
if (isTabletLayout) {
if (isTabletLayout && !useNativeBottomTabs) {
TabletFloatingTopBar(
selectedTab = selectedTab,
onTabSelected = { selectedTab = it },
@ -1053,11 +1167,11 @@ private fun MainAppContent(
castAvatarTransitionKey = avatarTransitionKey,
preferCrew = person.role?.let {
it.equals("Director", ignoreCase = true) ||
it.equals(directorRole, ignoreCase = true) ||
it.equals("Writer", ignoreCase = true) ||
it.equals(writerRole, ignoreCase = true) ||
it.equals("Creator", ignoreCase = true)
|| it.equals(creatorRole, ignoreCase = true)
it.equals(directorRole, ignoreCase = true) ||
it.equals("Writer", ignoreCase = true) ||
it.equals(writerRole, ignoreCase = true) ||
it.equals("Creator", ignoreCase = true)
|| it.equals(creatorRole, ignoreCase = true)
} ?: false,
),
)
@ -1332,6 +1446,7 @@ private fun MainAppContent(
)
)
StreamsRepository.consumeAutoPlay()
StreamsRepository.cancelLoading()
navController.navigate(PlayerRoute(launchId = launchId)) {
popUpTo<StreamRoute> { inclusive = true }
}
@ -1410,6 +1525,7 @@ private fun MainAppContent(
initialProgressFraction = resolvedResumeProgressFraction,
)
)
StreamsRepository.cancelLoading()
navController.navigate(
PlayerRoute(launchId = launchId)
)
@ -1536,7 +1652,7 @@ private fun MainAppContent(
DownloadsScreen(
onBack = onBack,
onOpenDownload = { item ->
val sourceUrl = item.localFileUri ?: return@DownloadsScreen
val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return@DownloadsScreen
val resumeEntry = item.videoId
.takeIf { it.isNotBlank() }
?.let(WatchProgressRepository::progressForVideo)
@ -1650,38 +1766,41 @@ private fun MainAppContent(
}
}
NuvioPosterActionSheet(
item = selectedPosterForActions,
isSaved = selectedPosterForActions?.let { preview ->
LibraryRepository.isSaved(preview.id, preview.type)
} == true,
isWatched = selectedPosterForActions?.let { preview ->
WatchingState.isPosterWatched(
watchedKeys = watchedUiState.watchedKeys,
item = preview,
)
} == true,
onDismiss = { selectedPosterForActions = null },
onToggleLibrary = {
selectedPosterForActions?.let { preview ->
val libraryItem = preview.toLibraryItem(savedAtEpochMs = 0L)
if (!isTraktConnected) {
LibraryRepository.toggleSaved(libraryItem)
} else {
pickerItem = libraryItem
pickerTitle = preview.name
pickerTabs = LibraryRepository.traktListTabs()
pickerMembership = pickerTabs.associate { it.key to false }
pickerPending = true
pickerError = null
showLibraryListPicker = true
coroutineScope.launch {
runCatching {
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
val tabs = LibraryRepository.traktListTabs()
pickerTabs = tabs
pickerMembership = tabs.associate { tab ->
tab.key to (snapshot[tab.key] == true)
NuvioPosterActionSheet(
item = selectedPosterForActions,
isSaved = selectedPosterForActions?.let { preview ->
LibraryRepository.isSaved(preview.id, preview.type)
} == true,
isWatched = selectedPosterForActions?.let { preview ->
WatchingState.isPosterWatched(
watchedKeys = watchedUiState.watchedKeys,
item = preview,
)
} == true,
onDismiss = { selectedPosterForActions = null },
onToggleLibrary = {
selectedPosterForActions?.let { preview ->
val libraryItem = preview.toLibraryItem(savedAtEpochMs = 0L)
if (!isTraktLibrarySource) {
LibraryRepository.toggleSaved(libraryItem)
} else {
pickerItem = libraryItem
pickerTitle = preview.name
pickerTabs = LibraryRepository.libraryListTabs()
pickerMembership = pickerTabs.associate { it.key to false }
pickerPending = true
pickerError = null
showLibraryListPicker = true
coroutineScope.launch {
runCatching {
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
val tabs = LibraryRepository.libraryListTabs()
pickerTabs = tabs
pickerMembership = tabs.associate { tab ->
tab.key to (snapshot[tab.key] == true)
}
}.onFailure { error ->
pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed)
}
}.onFailure { error ->
pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed)
@ -1772,25 +1891,43 @@ private fun MainAppContent(
}.onFailure { error ->
pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed)
}
pickerPending = false
}
},
)
},
onSave = {
val item = pickerItem ?: return@TraktListPickerDialog
coroutineScope.launch {
pickerPending = true
pickerError = null
runCatching {
LibraryRepository.applyMembershipChanges(
item = item,
desiredMembership = pickerMembership,
)
}.onSuccess {
showLibraryListPicker = false
pickerItem = null
pickerError = null
}.onFailure { error ->
pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed)
}
pickerPending = false
}
},
)
NuvioStatusModal(
title = stringResource(Res.string.app_exit_title),
message = stringResource(Res.string.app_exit_message),
isVisible = showExitConfirmation,
confirmText = stringResource(Res.string.action_yes),
dismissText = stringResource(Res.string.action_no),
onConfirm = {
showExitConfirmation = false
platformExitApp()
},
onDismiss = {
showExitConfirmation = false
},
)
NuvioStatusModal(
title = stringResource(Res.string.app_exit_title),
message = stringResource(Res.string.app_exit_message),
isVisible = showExitConfirmation,
confirmText = stringResource(Res.string.action_yes),
dismissText = stringResource(Res.string.action_no),
onConfirm = {
showExitConfirmation = false
platformExitApp()
},
onDismiss = {
showExitConfirmation = false
},
)
androidx.compose.animation.AnimatedVisibility(
visible = !initialHomeReady || profileSwitchLoading,
@ -1809,23 +1946,23 @@ private fun MainAppContent(
}
}
NuvioFloatingPrompt(
visible = resumePromptItem != null,
imageUrl = resumePromptItem?.poster ?: resumePromptItem?.imageUrl,
title = resumePromptItem?.title.orEmpty(),
subtitle = resumePromptItem?.let { localizedContinueWatchingSubtitle(it) }.orEmpty(),
progressFraction = resumePromptItem?.progressFraction ?: 0f,
actionLabel = stringResource(Res.string.resume_prompt_action),
onAction = {
val item = resumePromptItem ?: return@NuvioFloatingPrompt
resumePromptItem = null
openContinueWatching(item, false, false)
},
onDismiss = { resumePromptItem = null },
modifier = Modifier
.align(Alignment.BottomCenter)
.zIndex(15f),
)
NuvioFloatingPrompt(
visible = resumePromptItem != null,
imageUrl = resumePromptItem?.poster ?: resumePromptItem?.imageUrl,
title = resumePromptItem?.title.orEmpty(),
subtitle = resumePromptItem?.let { localizedContinueWatchingSubtitle(it) }.orEmpty(),
progressFraction = resumePromptItem?.progressFraction ?: 0f,
actionLabel = stringResource(Res.string.resume_prompt_action),
onAction = {
val item = resumePromptItem ?: return@NuvioFloatingPrompt
resumePromptItem = null
openContinueWatching(item, false, false)
},
onDismiss = { resumePromptItem = null },
modifier = Modifier
.align(Alignment.BottomCenter)
.zIndex(15f),
)
NuvioToastHost(
modifier = Modifier

View file

@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
object AuthRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -89,7 +91,7 @@ object AuthRepository {
Unit
}.onFailure { e ->
log.e(e) { "Email sign-up failed" }
_error.value = e.message ?: "Sign-up failed"
_error.value = e.message ?: getString(Res.string.auth_sign_up_failed)
}
suspend fun signInWithEmail(email: String, password: String): Result<Unit> = runCatching {
@ -100,7 +102,7 @@ object AuthRepository {
}
}.onFailure { e ->
log.e(e) { "Email sign-in failed" }
_error.value = e.message ?: "Sign-in failed"
_error.value = e.message ?: getString(Res.string.auth_sign_in_failed)
}
suspend fun signOut(): Result<Unit> = runCatching {
@ -114,7 +116,7 @@ object AuthRepository {
LocalAccountDataCleaner.wipe()
}.onFailure { e ->
log.e(e) { "Sign-out failed" }
_error.value = e.message ?: "Sign-out failed"
_error.value = e.message ?: getString(Res.string.auth_sign_out_failed)
}
suspend fun deleteAccount(): Result<Unit> = runCatching {
@ -124,7 +126,7 @@ object AuthRepository {
LocalAccountDataCleaner.wipe()
}.onFailure { e ->
log.e(e) { "Account deletion failed" }
_error.value = e.message ?: "Account deletion failed"
_error.value = e.message ?: getString(Res.string.auth_account_deletion_failed)
}
fun clearError() {

View file

@ -1,19 +1,6 @@
package com.nuvio.app.core.format
private val MONTH_NAMES = listOf(
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
)
import com.nuvio.app.core.i18n.localizedMonthName
/**
* Formats ISO calendar dates (yyyy-MM-dd or yyyy-MM-ddTHH:mm:ss) for UI as "2025 February 1".
@ -28,7 +15,7 @@ fun formatReleaseDateForDisplay(raw: String): String {
val year = parts[0].toIntOrNull() ?: return raw
val month = parts[1].toIntOrNull()?.takeIf { it in 1..12 } ?: return raw
val day = parts[2].toIntOrNull()?.takeIf { it in 1..31 } ?: return raw
return "$year ${MONTH_NAMES[month - 1]} $day"
return "$year ${localizedMonthName(month)} $day"
}
/**

View file

@ -0,0 +1,150 @@
package com.nuvio.app.core.i18n
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_play
import nuvio.composeapp.generated.resources.action_play_episode
import nuvio.composeapp.generated.resources.action_resume
import nuvio.composeapp.generated.resources.action_resume_episode
import nuvio.composeapp.generated.resources.compose_player_episode_code_episode_only
import nuvio.composeapp.generated.resources.compose_player_episode_code_full
import nuvio.composeapp.generated.resources.continue_watching_up_next
import nuvio.composeapp.generated.resources.continue_watching_up_next_episode
import nuvio.composeapp.generated.resources.date_month_april
import nuvio.composeapp.generated.resources.date_month_august
import nuvio.composeapp.generated.resources.date_month_december
import nuvio.composeapp.generated.resources.date_month_february
import nuvio.composeapp.generated.resources.date_month_january
import nuvio.composeapp.generated.resources.date_month_july
import nuvio.composeapp.generated.resources.date_month_june
import nuvio.composeapp.generated.resources.date_month_march
import nuvio.composeapp.generated.resources.date_month_may
import nuvio.composeapp.generated.resources.date_month_november
import nuvio.composeapp.generated.resources.date_month_october
import nuvio.composeapp.generated.resources.date_month_september
import nuvio.composeapp.generated.resources.date_month_short_apr
import nuvio.composeapp.generated.resources.date_month_short_aug
import nuvio.composeapp.generated.resources.date_month_short_dec
import nuvio.composeapp.generated.resources.date_month_short_feb
import nuvio.composeapp.generated.resources.date_month_short_jan
import nuvio.composeapp.generated.resources.date_month_short_jul
import nuvio.composeapp.generated.resources.date_month_short_jun
import nuvio.composeapp.generated.resources.date_month_short_mar
import nuvio.composeapp.generated.resources.date_month_short_may
import nuvio.composeapp.generated.resources.date_month_short_nov
import nuvio.composeapp.generated.resources.date_month_short_oct
import nuvio.composeapp.generated.resources.date_month_short_sep
import nuvio.composeapp.generated.resources.media_anime
import nuvio.composeapp.generated.resources.media_channels
import nuvio.composeapp.generated.resources.media_movie
import nuvio.composeapp.generated.resources.media_movies
import nuvio.composeapp.generated.resources.media_series
import nuvio.composeapp.generated.resources.media_tv
import nuvio.composeapp.generated.resources.unit_bytes_b
import nuvio.composeapp.generated.resources.unit_bytes_gb
import nuvio.composeapp.generated.resources.unit_bytes_kb
import nuvio.composeapp.generated.resources.unit_bytes_mb
import org.jetbrains.compose.resources.getString
fun localizedMediaTypeLabel(type: String): String {
val fallback = type.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
return when (type.trim().lowercase()) {
"movie" -> resourceString("Movies") { getString(Res.string.media_movies) }
"series" -> resourceString("Series") { getString(Res.string.media_series) }
"anime" -> resourceString("Anime") { getString(Res.string.media_anime) }
"channel" -> resourceString("Channels") { getString(Res.string.media_channels) }
"tv" -> resourceString("TV") { getString(Res.string.media_tv) }
else -> fallback
}
}
fun localizedMovieTypeLabel(): String = resourceString("Movie") { getString(Res.string.media_movie) }
fun localizedSeasonEpisodeCode(seasonNumber: Int?, episodeNumber: Int?): String? =
when {
seasonNumber != null && episodeNumber != null ->
resourceString("S${seasonNumber}E${episodeNumber}") {
getString(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber)
}
episodeNumber != null ->
resourceString("E${episodeNumber}") {
getString(Res.string.compose_player_episode_code_episode_only, episodeNumber)
}
else -> null
}
fun localizedPlayLabel(seasonNumber: Int?, episodeNumber: Int?): String {
val episodeCode = localizedSeasonEpisodeCode(seasonNumber, episodeNumber)
return if (episodeCode != null) {
resourceString("Play $episodeCode") { getString(Res.string.action_play_episode, episodeCode) }
} else {
resourceString("Play") { getString(Res.string.action_play) }
}
}
fun localizedResumeLabel(seasonNumber: Int?, episodeNumber: Int?): String {
val episodeCode = localizedSeasonEpisodeCode(seasonNumber, episodeNumber)
return if (episodeCode != null) {
resourceString("Resume $episodeCode") { getString(Res.string.action_resume_episode, episodeCode) }
} else {
resourceString("Resume") { getString(Res.string.action_resume) }
}
}
fun localizedUpNextLabel(seasonNumber: Int?, episodeNumber: Int?): String =
if (seasonNumber != null && episodeNumber != null) {
resourceString("Up Next • S${seasonNumber}E${episodeNumber}") {
getString(Res.string.continue_watching_up_next_episode, seasonNumber, episodeNumber)
}
} else {
resourceString("Up Next") { getString(Res.string.continue_watching_up_next) }
}
fun localizedMonthName(month: Int): String =
when (month) {
1 -> resourceString("January") { getString(Res.string.date_month_january) }
2 -> resourceString("February") { getString(Res.string.date_month_february) }
3 -> resourceString("March") { getString(Res.string.date_month_march) }
4 -> resourceString("April") { getString(Res.string.date_month_april) }
5 -> resourceString("May") { getString(Res.string.date_month_may) }
6 -> resourceString("June") { getString(Res.string.date_month_june) }
7 -> resourceString("July") { getString(Res.string.date_month_july) }
8 -> resourceString("August") { getString(Res.string.date_month_august) }
9 -> resourceString("September") { getString(Res.string.date_month_september) }
10 -> resourceString("October") { getString(Res.string.date_month_october) }
11 -> resourceString("November") { getString(Res.string.date_month_november) }
12 -> resourceString("December") { getString(Res.string.date_month_december) }
else -> month.toString()
}
fun localizedShortMonthName(month: Int): String =
when (month) {
1 -> resourceString("Jan") { getString(Res.string.date_month_short_jan) }
2 -> resourceString("Feb") { getString(Res.string.date_month_short_feb) }
3 -> resourceString("Mar") { getString(Res.string.date_month_short_mar) }
4 -> resourceString("Apr") { getString(Res.string.date_month_short_apr) }
5 -> resourceString("May") { getString(Res.string.date_month_short_may) }
6 -> resourceString("Jun") { getString(Res.string.date_month_short_jun) }
7 -> resourceString("Jul") { getString(Res.string.date_month_short_jul) }
8 -> resourceString("Aug") { getString(Res.string.date_month_short_aug) }
9 -> resourceString("Sep") { getString(Res.string.date_month_short_sep) }
10 -> resourceString("Oct") { getString(Res.string.date_month_short_oct) }
11 -> resourceString("Nov") { getString(Res.string.date_month_short_nov) }
12 -> resourceString("Dec") { getString(Res.string.date_month_short_dec) }
else -> month.toString()
}
fun localizedByteUnit(unit: String): String =
when (unit) {
"GB" -> resourceString("GB") { getString(Res.string.unit_bytes_gb) }
"MB" -> resourceString("MB") { getString(Res.string.unit_bytes_mb) }
"KB" -> resourceString("KB") { getString(Res.string.unit_bytes_kb) }
else -> resourceString("B") { getString(Res.string.unit_bytes_b) }
}
private fun resourceString(
fallback: String,
provider: suspend () -> String,
): String = runCatching {
runBlocking { provider() }
}.getOrDefault(fallback)

View file

@ -21,6 +21,7 @@ import com.nuvio.app.features.streams.StreamContextStore
import com.nuvio.app.features.streams.StreamLaunchStore
import com.nuvio.app.features.streams.StreamsRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.core.ui.PosterCardStyleRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.WatchProgressRepository
@ -47,6 +48,7 @@ internal object LocalAccountDataCleaner {
ThemeSettingsRepository.clearLocalState()
PosterCardStyleRepository.clearLocalState()
TraktAuthRepository.clearLocalState()
TraktSettingsRepository.clearLocalState()
PlayerSettingsRepository.clearLocalState()
CatalogRepository.clear()
StreamsRepository.clear()

View file

@ -21,6 +21,8 @@ import com.nuvio.app.features.tmdb.TmdbSettingsStorage
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.trakt.TraktCommentsStorage
import com.nuvio.app.features.trakt.TraktCommentsSettings
import com.nuvio.app.features.trakt.TraktSettingsStorage
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import io.github.jan.supabase.postgrest.postgrest
@ -150,12 +152,14 @@ object ProfileSettingsSync {
val signatureFlows = listOf(
ThemeSettingsRepository.selectedTheme.map { "theme" },
ThemeSettingsRepository.amoledEnabled.map { "amoled" },
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" },
PosterCardStyleRepository.uiState.map { "poster_card_style" },
PlayerSettingsRepository.uiState.map { "player" },
TmdbSettingsRepository.uiState.map { "tmdb" },
MdbListSettingsRepository.uiState.map { "mdblist" },
MetaScreenSettingsRepository.uiState.map { "meta" },
ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" },
TraktSettingsRepository.uiState.map { "trakt_settings" },
TraktCommentsSettings.enabled.map { "trakt_comments" },
EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" },
)
@ -199,6 +203,7 @@ object ProfileSettingsSync {
mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(),
metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(),
continueWatchingSettingsPayload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim(),
traktSettingsPayload = TraktSettingsStorage.loadPayload().orEmpty().trim(),
traktCommentsSettings = TraktCommentsStorage.exportToSyncPayload(),
notificationsSettings = NotificationsSettingsPayload(
episodeReleaseAlertsEnabled = EpisodeReleaseNotificationsRepository.uiState.value.isEnabled,
@ -230,6 +235,9 @@ object ProfileSettingsSync {
ContinueWatchingPreferencesStorage.savePayload(blob.features.continueWatchingSettingsPayload)
ContinueWatchingPreferencesRepository.onProfileChanged()
TraktSettingsStorage.savePayload(blob.features.traktSettingsPayload)
TraktSettingsRepository.onProfileChanged()
TraktCommentsStorage.replaceFromSyncPayload(blob.features.traktCommentsSettings)
TraktCommentsSettings.onProfileChanged()
@ -244,6 +252,7 @@ object ProfileSettingsSync {
MdbListSettingsRepository.ensureLoaded()
MetaScreenSettingsRepository.ensureLoaded()
ContinueWatchingPreferencesRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
TraktCommentsSettings.ensureLoaded()
EpisodeReleaseNotificationsRepository.ensureLoaded()
}
@ -257,12 +266,14 @@ object ProfileSettingsSync {
private fun currentObservedStateSignature(): String = listOf(
"theme=${ThemeSettingsRepository.selectedTheme.value.name}",
"amoled=${ThemeSettingsRepository.amoledEnabled.value}",
"liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}",
"poster_card_style=${PosterCardStyleRepository.uiState.value}",
"player=${PlayerSettingsRepository.uiState.value}",
"tmdb=${TmdbSettingsRepository.uiState.value}",
"mdblist=${MdbListSettingsRepository.uiState.value}",
"meta=${MetaScreenSettingsRepository.uiState.value}",
"continue=${ContinueWatchingPreferencesRepository.uiState.value}",
"trakt_settings=${TraktSettingsRepository.uiState.value}",
"trakt_comments=${TraktCommentsSettings.enabled.value}",
"episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}",
).joinToString(separator = "||")
@ -283,6 +294,7 @@ private data class MobileProfileSettingsFeatures(
@SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "",
@SerialName("continue_watching_settings_payload") val continueWatchingSettingsPayload: String = "",
@SerialName("trakt_settings_payload") val traktSettingsPayload: String = "",
@SerialName("trakt_comments_settings") val traktCommentsSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("notifications_settings") val notificationsSettings: NotificationsSettingsPayload = NotificationsSettingsPayload(),
)

View file

@ -1,11 +1,32 @@
package com.nuvio.app.core.ui
enum class AppTheme(val displayName: String) {
CRIMSON("Crimson"),
OCEAN("Ocean"),
VIOLET("Violet"),
EMERALD("Emerald"),
AMBER("Amber"),
ROSE("Rose"),
WHITE("White"),
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.theme_amber
import nuvio.composeapp.generated.resources.theme_crimson
import nuvio.composeapp.generated.resources.theme_emerald
import nuvio.composeapp.generated.resources.theme_ocean
import nuvio.composeapp.generated.resources.theme_rose
import nuvio.composeapp.generated.resources.theme_violet
import nuvio.composeapp.generated.resources.theme_white
import org.jetbrains.compose.resources.StringResource
enum class AppTheme {
CRIMSON,
OCEAN,
VIOLET,
EMERALD,
AMBER,
ROSE,
WHITE,
}
val AppTheme.labelRes: StringResource
get() = when (this) {
AppTheme.CRIMSON -> Res.string.theme_crimson
AppTheme.OCEAN -> Res.string.theme_ocean
AppTheme.VIOLET -> Res.string.theme_violet
AppTheme.EMERALD -> Res.string.theme_emerald
AppTheme.AMBER -> Res.string.theme_amber
AppTheme.ROSE -> Res.string.theme_rose
AppTheme.WHITE -> Res.string.theme_white
}

View file

@ -0,0 +1,26 @@
package com.nuvio.app.core.ui
import androidx.compose.runtime.Composable
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun localizedContinueWatchingSubtitle(item: ContinueWatchingItem): String {
val seasonNumber = item.seasonNumber
val episodeNumber = item.episodeNumber
val episodeTitle = item.episodeTitle?.takeIf { it.isNotBlank() }
val base = when {
seasonNumber != null && episodeNumber != null && item.isNextUp ->
stringResource(Res.string.continue_watching_up_next_episode, seasonNumber, episodeNumber)
seasonNumber != null && episodeNumber != null ->
stringResource(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber)
item.isNextUp ->
stringResource(Res.string.continue_watching_up_next)
else ->
stringResource(Res.string.media_movie)
}
return episodeTitle?.let { "$base$it" } ?: base
}

View file

@ -0,0 +1,23 @@
package com.nuvio.app.core.ui
internal data class DuplicateSafeLazyEntry<T>(
val value: T,
val lazyKey: Any,
)
internal fun <T> List<T>.withDuplicateSafeLazyKeys(key: (T) -> Any): List<DuplicateSafeLazyEntry<T>> {
val keyCounts = groupingBy(key).eachCount()
val occurrences = mutableMapOf<Any, Int>()
return map { entry ->
val baseKey = key(entry)
val lazyKey = if (keyCounts[baseKey] == 1) {
baseKey
} else {
val occurrence = occurrences.getOrElse(baseKey) { 0 }
occurrences[baseKey] = occurrence + 1
"$baseKey#$occurrence"
}
DuplicateSafeLazyEntry(value = entry, lazyKey = lazyKey)
}
}

View file

@ -0,0 +1,78 @@
package com.nuvio.app.core.ui
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
internal enum class NativeNavigationTab {
Home,
Search,
Library,
Settings,
;
companion object {
fun fromName(name: String): NativeNavigationTab =
entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: Home
}
}
internal object NativeTabBridge {
private val _requestedTab = MutableStateFlow(NativeNavigationTab.Home)
val requestedTab: StateFlow<NativeNavigationTab> = _requestedTab.asStateFlow()
fun requestTab(tabName: String) {
_requestedTab.value = NativeNavigationTab.fromName(tabName)
}
fun publishSelectedTab(tab: NativeNavigationTab) {
publishNativeSelectedTab(tab.name)
}
fun publishTabBarVisible(visible: Boolean) {
publishNativeTabBarVisible(visible && isLiquidGlassNativeTabBarSupported())
}
fun publishLiquidGlassEnabled(enabled: Boolean) {
publishLiquidGlassNativeTabBarEnabled(enabled && isLiquidGlassNativeTabBarSupported())
}
fun publishAccentColor(hexColor: String) {
publishNativeTabAccentColor(hexColor)
}
fun publishProfileTabIcon(
name: String?,
avatarColorHex: String?,
avatarImageUrl: String?,
avatarBackgroundColorHex: String?,
) {
publishNativeProfileTabIcon(
name = name,
avatarColorHex = avatarColorHex,
avatarImageUrl = avatarImageUrl,
avatarBackgroundColorHex = avatarBackgroundColorHex,
)
}
}
fun nativeTabSelect(tabName: String) {
NativeTabBridge.requestTab(tabName)
}
internal expect fun isLiquidGlassNativeTabBarSupported(): Boolean
internal expect fun publishLiquidGlassNativeTabBarEnabled(enabled: Boolean)
internal expect fun publishNativeTabBarVisible(visible: Boolean)
internal expect fun publishNativeSelectedTab(tabName: String)
internal expect fun publishNativeTabAccentColor(hexColor: String)
internal expect fun publishNativeProfileTabIcon(
name: String?,
avatarColorHex: String?,
avatarImageUrl: String?,
avatarBackgroundColorHex: String?,
)

View file

@ -65,6 +65,10 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_back
import nuvio.composeapp.generated.resources.action_ok
import org.jetbrains.compose.resources.stringResource
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -141,7 +145,7 @@ fun NuvioScreenHeader(
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "Back",
contentDescription = stringResource(Res.string.action_back),
tint = MaterialTheme.colorScheme.onBackground,
)
}
@ -233,7 +237,7 @@ fun NuvioBackButton(
contentColor: Color = MaterialTheme.colorScheme.onSurface,
buttonSize: Dp = 40.dp,
iconSize: Dp = 22.dp,
contentDescription: String = "Back",
contentDescription: String = stringResource(Res.string.action_back),
) {
Box(
modifier = modifier
@ -375,7 +379,7 @@ fun NuvioStatusModal(
modifier: Modifier = Modifier,
isVisible: Boolean,
isBusy: Boolean = false,
confirmText: String = "OK",
confirmText: String = stringResource(Res.string.action_ok),
dismissText: String? = null,
onConfirm: () -> Unit,
onDismiss: (() -> Unit)? = null,

View file

@ -30,6 +30,12 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.cw_action_go_to_details
import nuvio.composeapp.generated.resources.cw_action_remove
import nuvio.composeapp.generated.resources.cw_action_start_from_beginning
import nuvio.composeapp.generated.resources.play_manually
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -70,14 +76,14 @@ fun NuvioContinueWatchingActionSheet(
NuvioBottomSheetDivider()
NuvioBottomSheetActionRow(
icon = Icons.Default.Info,
title = "Go to details",
title = stringResource(Res.string.cw_action_go_to_details),
onClick = { dismissAfter(onOpenDetails) },
)
if (showManualPlayOption && onPlayManually != null) {
NuvioBottomSheetDivider()
NuvioBottomSheetActionRow(
icon = Icons.Default.PlayArrow,
title = "Play manually",
title = stringResource(Res.string.play_manually),
onClick = { dismissAfter(onPlayManually) },
)
}
@ -85,14 +91,14 @@ fun NuvioContinueWatchingActionSheet(
NuvioBottomSheetDivider()
NuvioBottomSheetActionRow(
icon = Icons.Default.Replay,
title = "Start from beginning",
title = stringResource(Res.string.cw_action_start_from_beginning),
onClick = { dismissAfter(onStartFromBeginning) },
)
}
NuvioBottomSheetDivider()
NuvioBottomSheetActionRow(
icon = Icons.Default.DeleteOutline,
title = "Remove",
title = stringResource(Res.string.cw_action_remove),
onClick = { dismissAfter(onRemove) },
)
}
@ -152,7 +158,7 @@ private fun ContinueWatchingSheetHeader(
overflow = TextOverflow.Ellipsis,
)
Text(
text = item.subtitle,
text = localizedContinueWatchingSubtitle(item),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
@ -160,4 +166,4 @@ private fun ContinueWatchingSheetHeader(
)
}
}
}
}

View file

@ -52,6 +52,9 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.floating_prompt_continue_where_left_off
import org.jetbrains.compose.resources.stringResource
import kotlin.math.roundToInt
private const val AutoDismissDelayMs = 15_000L
@ -202,7 +205,7 @@ fun NuvioFloatingPrompt(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "Continue where you left off",
text = stringResource(Res.string.floating_prompt_continue_where_left_off),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)

View file

@ -10,6 +10,9 @@ import androidx.compose.ui.unit.dp
import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.network.messageForEmptyState
import com.nuvio.app.core.network.titleForEmptyState
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_retry
import org.jetbrains.compose.resources.stringResource
@Composable
fun NuvioNetworkOfflineCard(
@ -32,9 +35,9 @@ fun NuvioNetworkOfflineCard(
if (onRetry != null) {
Spacer(modifier = Modifier.height(16.dp))
NuvioPrimaryButton(
text = "Retry",
text = stringResource(Res.string.action_retry),
onClick = onRetry,
)
}
}
}
}

View file

@ -3,6 +3,7 @@ package com.nuvio.app.core.ui
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@ -12,10 +13,14 @@ internal expect val nuvioBottomNavigationExtraVerticalPadding: Dp
@Composable
internal expect fun nuvioBottomNavigationBarInsets(): WindowInsets
internal val LocalNuvioBottomNavigationOverlayPadding = staticCompositionLocalOf { 0.dp }
@Composable
internal fun nuvioSafeBottomPadding(extra: Dp = 0.dp): Dp {
val navigationBarBottom = nuvioBottomNavigationBarInsets()
.asPaddingValues()
.calculateBottomPadding()
return navigationBarBottom.coerceAtLeast(nuvioPlatformExtraBottomPadding) + extra
return navigationBarBottom.coerceAtLeast(nuvioPlatformExtraBottomPadding) +
LocalNuvioBottomNavigationOverlayPadding.current +
extra
}

View file

@ -37,6 +37,13 @@ import coil3.compose.AsyncImage
import com.nuvio.app.core.format.formatReleaseDateForDisplay
import com.nuvio.app.features.home.MetaPreview
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.episodes_cd_watched
import nuvio.composeapp.generated.resources.hero_add_to_library
import nuvio.composeapp.generated.resources.hero_mark_unwatched
import nuvio.composeapp.generated.resources.hero_mark_watched
import nuvio.composeapp.generated.resources.hero_remove_from_library
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -72,7 +79,11 @@ fun NuvioPosterActionSheet(
NuvioBottomSheetDivider()
NuvioBottomSheetActionRow(
icon = if (isSaved) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
title = if (isSaved) "Remove from Library" else "Add to Library",
title = if (isSaved) {
stringResource(Res.string.hero_remove_from_library)
} else {
stringResource(Res.string.hero_add_to_library)
},
onClick = {
onToggleLibrary()
coroutineScope.launch {
@ -86,7 +97,11 @@ fun NuvioPosterActionSheet(
NuvioBottomSheetDivider()
NuvioBottomSheetActionRow(
icon = if (isWatched) Icons.Default.CheckCircle else Icons.Default.CheckCircleOutline,
title = if (isWatched) "Mark as Unwatched" else "Mark as Watched",
title = if (isWatched) {
stringResource(Res.string.hero_mark_unwatched)
} else {
stringResource(Res.string.hero_mark_watched)
},
onClick = {
onToggleWatched()
coroutineScope.launch {
@ -114,7 +129,7 @@ fun NuvioWatchedBadge(
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Watched",
contentDescription = stringResource(Res.string.episodes_cd_watched),
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(12.dp),
)
@ -200,4 +215,3 @@ private fun PosterSheetHeader(
}
}
}

View file

@ -33,6 +33,10 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.home_view_all
import nuvio.composeapp.generated.resources.poster_logo_content_description
import org.jetbrains.compose.resources.stringResource
enum class NuvioPosterShape {
Poster,
@ -78,10 +82,10 @@ fun <T> NuvioShelfSection(
) {
if (key != null) {
items(
items = entries,
key = key,
) { entry ->
itemContent(entry)
items = entries.withDuplicateSafeLazyKeys(key),
key = { entry -> entry.lazyKey },
) { keyedEntry ->
itemContent(keyedEntry.value)
}
} else {
items(entries) { entry ->
@ -156,7 +160,7 @@ fun NuvioPosterCard(
if (!bottomLeftLogoUrl.isNullOrBlank()) {
AsyncImage(
model = bottomLeftLogoUrl,
contentDescription = "$title logo",
contentDescription = stringResource(Res.string.poster_logo_content_description, title),
modifier = Modifier
.width(catalogLogoOverlaySize.width)
.height(catalogLogoOverlaySize.height),
@ -280,7 +284,7 @@ private fun NuvioViewAllPill(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "View All",
text = stringResource(Res.string.home_view_all),
style = textStyle,
color = MaterialTheme.colorScheme.onSurface,
)

View file

@ -28,6 +28,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.nuvio.app.features.trakt.TraktListTab
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_cancel
import nuvio.composeapp.generated.resources.action_save
import nuvio.composeapp.generated.resources.compose_trakt_list_picker_loading
import nuvio.composeapp.generated.resources.compose_trakt_list_picker_subtitle
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -62,7 +68,7 @@ fun TraktListPickerDialog(
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Choose where to save this title on Trakt",
text = stringResource(Res.string.compose_trakt_list_picker_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -91,7 +97,7 @@ fun TraktListPickerDialog(
modifier = Modifier.size(24.dp),
)
Text(
text = "Loading your Trakt lists…",
text = stringResource(Res.string.compose_trakt_list_picker_loading),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -151,7 +157,7 @@ fun TraktListPickerDialog(
contentColor = MaterialTheme.colorScheme.onSurface,
),
) {
Text("Cancel")
Text(stringResource(Res.string.action_cancel))
}
Button(
onClick = onSave,
@ -164,11 +170,11 @@ fun TraktListPickerDialog(
modifier = Modifier.size(16.dp),
)
} else {
Text("Save")
Text(stringResource(Res.string.action_save))
}
}
}
}
}
}
}
}

View file

@ -1,5 +1,10 @@
package com.nuvio.app.features.addons
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.generic_addon
import org.jetbrains.compose.resources.getString
data class AddonManifest(
val id: String,
val name: String,
@ -54,7 +59,9 @@ data class ManagedAddon(
val displayTitle: String
get() = userSetName?.takeIf { it.isNotBlank() && it != manifest?.name }
?: manifest?.name
?: manifestUrl.substringBefore("?").substringAfterLast("/").ifBlank { "Addon" }
?: manifestUrl.substringBefore("?").substringAfterLast("/").ifBlank {
runBlocking { getString(Res.string.generic_addon) }
}
}
data class AddonsUiState(

View file

@ -33,4 +33,5 @@ expect suspend fun httpRequestRaw(
url: String,
headers: Map<String, String>,
body: String,
followRedirects: Boolean = true,
): RawHttpResponse

View file

@ -23,6 +23,8 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
@Serializable
private data class AddonRow(
@ -198,17 +200,17 @@ object AddonRepository {
suspend fun addAddon(rawUrl: String): AddAddonResult {
if (isUsingPrimaryAddonsFromSecondaryProfile()) {
return AddAddonResult.Error("This profile uses primary addons.")
return AddAddonResult.Error(getString(Res.string.profile_primary_addons_required))
}
log.i { "addAddon() — rawUrl=$rawUrl" }
val manifestUrl = try {
normalizeManifestUrl(rawUrl)
} catch (error: IllegalArgumentException) {
return AddAddonResult.Error(error.message ?: "Enter a valid addon URL")
return AddAddonResult.Error(error.message ?: getString(Res.string.addon_invalid_url))
}
if (_uiState.value.addons.any { it.manifestUrl == manifestUrl }) {
return AddAddonResult.Error("That addon is already installed.")
return AddAddonResult.Error(getString(Res.string.addon_already_installed))
}
val manifest = try {
@ -220,7 +222,7 @@ object AddonRepository {
)
}
} catch (error: Throwable) {
return AddAddonResult.Error(error.message ?: "Unable to load manifest")
return AddAddonResult.Error(error.message ?: getString(Res.string.addon_load_manifest_failed))
}
_uiState.update { current ->
@ -250,6 +252,27 @@ object AddonRepository {
pushToServer()
}
fun moveAddon(fromIndex: Int, toIndex: Int) {
if (isUsingPrimaryAddonsFromSecondaryProfile()) return
_uiState.update { current ->
val addons = current.addons
if (
fromIndex !in addons.indices ||
toIndex !in addons.indices ||
fromIndex == toIndex
) {
return@update current
}
val reordered = addons.toMutableList()
val movingAddon = reordered.removeAt(fromIndex)
reordered.add(toIndex, movingAddon)
current.copy(addons = reordered)
}
persist()
pushToServer()
}
fun refreshAll() {
_uiState.value.addons.distinctBy { it.manifestUrl }.forEach { addon ->
refreshAddon(addon.manifestUrl)
@ -289,7 +312,7 @@ object AddonRepository {
onFailure = { error ->
addon.copy(
isRefreshing = false,
errorMessage = error.message ?: "Unable to load manifest",
errorMessage = error.message ?: getString(Res.string.addon_load_manifest_failed),
)
},
)

View file

@ -12,12 +12,14 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ArrowDownward
import androidx.compose.material.icons.rounded.ArrowUpward
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -36,6 +38,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -50,17 +53,19 @@ import com.nuvio.app.core.ui.NuvioSectionLabel
import com.nuvio.app.core.ui.NuvioStatusModal
import com.nuvio.app.core.ui.NuvioSurfaceCard
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun AddonsScreen(
modifier: Modifier = Modifier,
title: String = "Addons",
title: String? = null,
onBack: (() -> Unit)? = null,
) {
NuvioScreen(modifier = modifier) {
stickyHeader {
NuvioScreenHeader(
title = title,
title = title ?: stringResource(Res.string.addon_title),
onBack = onBack,
) {
}
@ -80,10 +85,12 @@ internal fun AddonsSettingsPageContent(
}
val uiState by AddonRepository.uiState.collectAsStateWithLifecycle()
val uriHandler = LocalUriHandler.current
val coroutineScope = rememberCoroutineScope()
var addonUrl by rememberSaveable { mutableStateOf("") }
var formMessage by rememberSaveable { mutableStateOf<String?>(null) }
var installModalState by remember { mutableStateOf<AddonInstallModalState?>(null) }
val enterAddonUrlMessage = stringResource(Res.string.addons_error_enter_url)
val overview = remember(uiState.addons) { uiState.addons.toOverview() }
@ -91,10 +98,10 @@ internal fun AddonsSettingsPageContent(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
SectionHeader("OVERVIEW")
SectionHeader(stringResource(Res.string.addons_section_overview))
OverviewCard(overview = overview)
SectionHeader("ADD ADDON")
SectionHeader(stringResource(Res.string.addons_section_add_addon))
AddAddonCard(
addonUrl = addonUrl,
formMessage = formMessage,
@ -105,7 +112,7 @@ internal fun AddonsSettingsPageContent(
onAddClick = {
val requestedUrl = addonUrl.trim()
if (requestedUrl.isBlank()) {
formMessage = "Enter an addon URL."
formMessage = enterAddonUrlMessage
return@AddAddonCard
}
@ -127,14 +134,38 @@ internal fun AddonsSettingsPageContent(
},
)
SectionHeader("INSTALLED ADDONS")
SectionHeader(stringResource(Res.string.addons_section_installed))
if (uiState.addons.isEmpty()) {
EmptyStateCard()
} else {
uiState.addons.forEach { addon ->
val lastIndex = uiState.addons.lastIndex
uiState.addons.forEachIndexed { index, addon ->
val manifest = addon.manifest
val behaviorHints = manifest?.behaviorHints
val showConfigureAction = behaviorHints?.configurable == true || behaviorHints?.configurationRequired == true
val configureUrl = addon.manifestUrl.toConfigureUrl()
InstalledAddonCard(
addon = addon,
onMoveUpClick = if (index > 0) {
{ AddonRepository.moveAddon(index, index - 1) }
} else {
null
},
onMoveDownClick = if (index < lastIndex) {
{ AddonRepository.moveAddon(index, index + 1) }
} else {
null
},
onRefreshClick = { AddonRepository.refreshAddon(addon.manifestUrl) },
onConfigureClick = if (showConfigureAction && !configureUrl.isNullOrBlank()) {
{
runCatching {
uriHandler.openUri(configureUrl)
}
}
} else {
null
},
onDeleteClick = { AddonRepository.removeAddon(addon.manifestUrl) },
)
}
@ -143,12 +174,30 @@ internal fun AddonsSettingsPageContent(
val modalState = installModalState
if (modalState != null) {
val modalTitle = when (modalState) {
AddonInstallModalState.Checking -> stringResource(Res.string.addons_modal_checking_title)
is AddonInstallModalState.Success -> stringResource(Res.string.addons_modal_success_title)
is AddonInstallModalState.Error -> stringResource(Res.string.addons_modal_failure_title)
}
val modalMessage = when (modalState) {
AddonInstallModalState.Checking -> stringResource(Res.string.addons_modal_checking_message)
is AddonInstallModalState.Success -> stringResource(
Res.string.addons_modal_success_message,
modalState.addonName,
)
is AddonInstallModalState.Error -> modalState.reason
}
val modalConfirmText = when (modalState) {
AddonInstallModalState.Checking -> stringResource(Res.string.addon_installing)
is AddonInstallModalState.Success -> stringResource(Res.string.action_done)
is AddonInstallModalState.Error -> stringResource(Res.string.action_close)
}
NuvioStatusModal(
title = modalState.title,
message = modalState.message,
title = modalTitle,
message = modalMessage,
isVisible = true,
isBusy = modalState.isBusy,
confirmText = modalState.confirmText,
confirmText = modalConfirmText,
onConfirm = {
if (!modalState.isBusy) {
installModalState = null
@ -172,19 +221,19 @@ private fun OverviewCard(overview: AddonOverview) {
) {
OverviewStat(
value = overview.totalAddons.toString(),
label = "Addons",
label = stringResource(Res.string.addons_overview_addons),
modifier = Modifier.weight(1f),
)
VerticalSeparator()
OverviewStat(
value = overview.activeAddons.toString(),
label = "Active",
label = stringResource(Res.string.addons_overview_active),
modifier = Modifier.weight(1f),
)
VerticalSeparator()
OverviewStat(
value = overview.totalCatalogs.toString(),
label = "Catalogs",
label = stringResource(Res.string.addons_overview_catalogs),
modifier = Modifier.weight(1f),
)
}
@ -236,11 +285,11 @@ private fun AddAddonCard(
NuvioInputField(
value = addonUrl,
onValueChange = onAddonUrlChange,
placeholder = "Addon URL",
placeholder = stringResource(Res.string.addons_input_placeholder),
)
Spacer(modifier = Modifier.height(18.dp))
NuvioPrimaryButton(
text = "Install Addon",
text = stringResource(Res.string.addons_install_button),
enabled = addonUrl.isNotBlank(),
onClick = onAddClick,
)
@ -256,33 +305,21 @@ private fun AddAddonCard(
}
private sealed interface AddonInstallModalState {
val title: String
val message: String
val confirmText: String
val isBusy: Boolean
data object Checking : AddonInstallModalState {
override val title: String = "Checking Addon"
override val message: String = "Validating the manifest URL and loading addon details before install."
override val confirmText: String = "Installing"
override val isBusy: Boolean = true
}
data class Success(
private val addonName: String,
val addonName: String,
) : AddonInstallModalState {
override val title: String = "Addon Installed"
override val message: String = "$addonName was validated and added successfully."
override val confirmText: String = "Done"
override val isBusy: Boolean = false
}
data class Error(
private val reason: String,
val reason: String,
) : AddonInstallModalState {
override val title: String = "Install Failed"
override val message: String = reason
override val confirmText: String = "Close"
override val isBusy: Boolean = false
}
}
@ -291,13 +328,13 @@ private sealed interface AddonInstallModalState {
private fun EmptyStateCard() {
NuvioSurfaceCard {
Text(
text = "No addons installed yet.",
text = stringResource(Res.string.addons_empty_title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Add a manifest URL to start loading catalogs, metadata, streams or subtitles into Nuvio.",
text = stringResource(Res.string.addons_empty_subtitle),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -307,7 +344,10 @@ private fun EmptyStateCard() {
@Composable
private fun InstalledAddonCard(
addon: ManagedAddon,
onMoveUpClick: (() -> Unit)?,
onMoveDownClick: (() -> Unit)?,
onRefreshClick: () -> Unit,
onConfigureClick: (() -> Unit)?,
onDeleteClick: () -> Unit,
) {
val manifest = addon.manifest
@ -315,54 +355,79 @@ private fun InstalledAddonCard(
NuvioSurfaceCard {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.Top,
) {
AddonIconBadge(
imageUrl = manifest?.logoUrl,
icon = Icons.Rounded.Extension,
tint = if (manifest != null) Color(0xFF71BDE8) else MaterialTheme.colorScheme.onSurfaceVariant,
AddonIconBadge(
imageUrl = manifest?.logoUrl,
icon = Icons.Rounded.Extension,
tint = if (manifest != null) Color(0xFF71BDE8) else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = addon.displayTitle,
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
manifest?.version?.let { version ->
Spacer(modifier = Modifier.height(8.dp))
Text(
text = addon.displayTitle,
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
text = stringResource(Res.string.addons_version_format, version),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
manifest?.version?.let { version ->
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Version $version",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
NuvioIconActionButton(
icon = Icons.Rounded.Refresh,
contentDescription = "Refresh addon",
tint = MaterialTheme.colorScheme.primary,
onClick = onRefreshClick,
)
NuvioIconActionButton(
icon = Icons.Rounded.Delete,
contentDescription = "Delete addon",
tint = MaterialTheme.colorScheme.error,
onClick = onDeleteClick,
)
}
}
Spacer(modifier = Modifier.height(18.dp))
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically,
) {
onMoveUpClick?.let { onMoveUp ->
NuvioIconActionButton(
icon = Icons.Rounded.ArrowUpward,
contentDescription = stringResource(Res.string.addons_move_up),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
onClick = onMoveUp,
)
}
onMoveDownClick?.let { onMoveDown ->
NuvioIconActionButton(
icon = Icons.Rounded.ArrowDownward,
contentDescription = stringResource(Res.string.addons_move_down),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
onClick = onMoveDown,
)
}
NuvioIconActionButton(
icon = Icons.Rounded.Refresh,
contentDescription = stringResource(Res.string.addons_refresh),
tint = MaterialTheme.colorScheme.primary,
onClick = onRefreshClick,
)
onConfigureClick?.let { onConfigure ->
NuvioIconActionButton(
icon = Icons.Rounded.Settings,
contentDescription = stringResource(Res.string.addons_configure),
tint = MaterialTheme.colorScheme.tertiary,
onClick = onConfigure,
)
}
NuvioIconActionButton(
icon = Icons.Rounded.Delete,
contentDescription = stringResource(Res.string.addons_delete),
tint = MaterialTheme.colorScheme.error,
onClick = onDeleteClick,
)
}
Spacer(modifier = Modifier.height(12.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
Spacer(modifier = Modifier.height(18.dp))
@ -373,16 +438,16 @@ private fun InstalledAddonCard(
) {
NuvioInfoBadge(
text = when {
addon.isRefreshing -> "Refreshing"
manifest != null -> "Active"
else -> "Unavailable"
addon.isRefreshing -> stringResource(Res.string.addons_badge_refreshing)
manifest != null -> stringResource(Res.string.addons_badge_active)
else -> stringResource(Res.string.addons_badge_unavailable)
},
)
manifest?.let {
NuvioInfoBadge(text = "${it.resources.size} resources")
NuvioInfoBadge(text = "${it.catalogs.size} catalogs")
NuvioInfoBadge(text = stringResource(Res.string.addons_badge_resources, it.resources.size))
NuvioInfoBadge(text = stringResource(Res.string.addons_badge_catalogs, it.catalogs.size))
if (it.behaviorHints.configurable) {
NuvioInfoBadge(text = "Configurable")
NuvioInfoBadge(text = stringResource(Res.string.addons_badge_configurable))
}
}
}
@ -391,7 +456,7 @@ private fun InstalledAddonCard(
addon.isRefreshing -> {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Loading manifest details...",
text = stringResource(Res.string.addons_loading_manifest_details),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -468,6 +533,7 @@ private fun AddonIconBadge(
}
}
@Composable
private fun manifestSummary(manifest: AddonManifest): String {
val resources = manifest.resources.joinToString(separator = ", ") { it.name }
val types = manifest.types.joinToString(separator = " / ") { it.replaceFirstChar(Char::uppercase) }
@ -477,10 +543,19 @@ private fun manifestSummary(manifest: AddonManifest): String {
append(resources)
if (manifest.idPrefixes.isNotEmpty()) {
append("")
append("${manifest.idPrefixes.size} id rules")
append(stringResource(Res.string.addons_summary_id_rules, manifest.idPrefixes.size))
}
if (manifest.behaviorHints.p2p) {
append(" • P2P")
}
}
}
private fun String.toConfigureUrl(): String {
val base = substringBefore("?").trimEnd('/')
return if (base.endsWith("/manifest.json")) {
base.removeSuffix("/manifest.json") + "/configure"
} else {
"$base/configure"
}
}

View file

@ -62,7 +62,22 @@ import com.nuvio.app.core.ui.NuvioSurfaceCard
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.app_logo_wordmark
import nuvio.composeapp.generated.resources.compose_auth_already_have_account
import nuvio.composeapp.generated.resources.compose_auth_continue_without_account
import nuvio.composeapp.generated.resources.compose_auth_create_account
import nuvio.composeapp.generated.resources.compose_auth_dont_have_account
import nuvio.composeapp.generated.resources.compose_auth_email
import nuvio.composeapp.generated.resources.compose_auth_or_separator
import nuvio.composeapp.generated.resources.compose_auth_password
import nuvio.composeapp.generated.resources.compose_auth_sign_in
import nuvio.composeapp.generated.resources.compose_auth_sign_in_subtitle
import nuvio.composeapp.generated.resources.compose_auth_sign_up
import nuvio.composeapp.generated.resources.compose_auth_sign_up_subtitle
import nuvio.composeapp.generated.resources.compose_auth_store_locally
import nuvio.composeapp.generated.resources.compose_auth_tagline
import nuvio.composeapp.generated.resources.compose_auth_welcome_back
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
@Composable
fun AuthScreen(
@ -97,7 +112,7 @@ fun AuthScreen(
) {
Image(
painter = painterResource(Res.drawable.app_logo_wordmark),
contentDescription = "Nuvio",
contentDescription = null,
modifier = Modifier
.fillMaxWidth(0.6f)
.height(48.dp),
@ -105,7 +120,7 @@ fun AuthScreen(
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Stream everything, everywhere",
text = stringResource(Res.string.compose_auth_tagline),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -119,7 +134,8 @@ fun AuthScreen(
label = "heading",
) { signUp ->
Text(
text = if (signUp) "Create Account" else "Welcome Back",
text = if (signUp) stringResource(Res.string.compose_auth_create_account)
else stringResource(Res.string.compose_auth_welcome_back),
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onSurface,
)
@ -131,8 +147,8 @@ fun AuthScreen(
label = "subtitle",
) { signUp ->
Text(
text = if (signUp) "Sign up to sync your data across devices"
else "Sign in to access your library and progress",
text = if (signUp) stringResource(Res.string.compose_auth_sign_up_subtitle)
else stringResource(Res.string.compose_auth_sign_in_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -150,7 +166,7 @@ fun AuthScreen(
singleLine = true,
placeholder = {
Text(
text = "Email",
text = stringResource(Res.string.compose_auth_email),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
@ -183,7 +199,7 @@ fun AuthScreen(
singleLine = true,
placeholder = {
Text(
text = "Password",
text = stringResource(Res.string.compose_auth_password),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
@ -240,7 +256,13 @@ fun AuthScreen(
Spacer(modifier = Modifier.height(24.dp))
NuvioPrimaryButton(
text = if (isLoading) "" else if (isSignUp) "Create Account" else "Sign In",
text = if (isLoading) {
""
} else if (isSignUp) {
stringResource(Res.string.compose_auth_create_account)
} else {
stringResource(Res.string.compose_auth_sign_in)
},
enabled = email.isNotBlank() && password.length >= 6 && !isLoading,
onClick = {
isLoading = true
@ -279,7 +301,8 @@ fun AuthScreen(
label = "togglePrompt",
) { signUp ->
Text(
text = if (signUp) "Already have an account? " else "Don't have an account? ",
text = if (signUp) stringResource(Res.string.compose_auth_already_have_account)
else stringResource(Res.string.compose_auth_dont_have_account),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -290,7 +313,8 @@ fun AuthScreen(
label = "toggleAction",
) { signUp ->
Text(
text = if (signUp) "Sign In" else "Sign Up",
text = if (signUp) stringResource(Res.string.compose_auth_sign_in)
else stringResource(Res.string.compose_auth_sign_up),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
@ -317,7 +341,7 @@ fun AuthScreen(
.background(MaterialTheme.colorScheme.outline),
)
Text(
text = " or ",
text = stringResource(Res.string.compose_auth_or_separator),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -346,7 +370,7 @@ fun AuthScreen(
),
) {
Text(
text = "Continue Without Account",
text = stringResource(Res.string.compose_auth_continue_without_account),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
)
@ -354,7 +378,7 @@ fun AuthScreen(
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Your data will only be stored locally",
text = stringResource(Res.string.compose_auth_store_locally),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,

View file

@ -2,6 +2,9 @@ package com.nuvio.app.features.catalog
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.library.toMetaPreview
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.filterReleasedItems
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -10,6 +13,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
const val INTERNAL_LIBRARY_MANIFEST_URL = "nuvio://library"
@ -92,7 +97,7 @@ object CatalogRepository {
items = emptyList(),
isLoading = false,
nextSkip = null,
errorMessage = error.message ?: "Unable to load catalog items.",
errorMessage = error.message ?: getString(Res.string.catalog_load_failed),
)
},
)
@ -122,7 +127,7 @@ object CatalogRepository {
catalogId = request.catalogId,
genre = request.genre,
skip = requestedSkip.takeIf { it > 0 },
)
).withUnreleasedFilter()
}.fold(
onSuccess = { page ->
if (activeRequest != request) return@fold
@ -148,7 +153,7 @@ object CatalogRepository {
items = if (reset) emptyList() else current.items,
isLoading = false,
nextSkip = null,
errorMessage = error.message ?: "Unable to load catalog items.",
errorMessage = error.message ?: getString(Res.string.catalog_load_failed),
)
},
)
@ -156,6 +161,12 @@ object CatalogRepository {
}
}
private fun CatalogPage.withUnreleasedFilter(): CatalogPage {
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate())
return if (filteredItems.size == items.size) this else copy(items = filteredItems)
}
private data class CatalogRequest(
val manifestUrl: String,
val type: String,

View file

@ -50,12 +50,16 @@ import com.nuvio.app.core.ui.NuvioBackButton
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
import com.nuvio.app.core.ui.posterCardClickable
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.home.stableKey
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun CatalogScreen(
@ -71,20 +75,21 @@ fun CatalogScreen(
modifier: Modifier = Modifier,
) {
val uiState by CatalogRepository.uiState.collectAsStateWithLifecycle()
val homeCatalogSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle()
val posterCardStyle = rememberPosterCardStyleUiState()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState()
var headerHeightPx by remember { mutableIntStateOf(0) }
var observedOfflineState by remember { mutableStateOf(false) }
LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination) {
LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination, homeCatalogSettingsUiState.hideUnreleasedContent) {
CatalogRepository.load(
manifestUrl = manifestUrl,
type = type,
catalogId = catalogId,
genre = genre,
supportsPagination = supportsPagination,
force = false,
force = true,
)
}
@ -173,9 +178,10 @@ fun CatalogScreen(
}
} else {
items(
items = uiState.items,
key = { item -> item.stableKey() },
) { item ->
items = uiState.items.withDuplicateSafeLazyKeys { item -> item.stableKey() },
key = { item -> item.lazyKey },
) { keyedItem ->
val item = keyedItem.value
CatalogPosterTile(
item = item,
cornerRadiusDp = posterCardStyle.cornerRadiusDp,
@ -329,12 +335,12 @@ private fun CatalogEmptyState(
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = "No titles found",
text = stringResource(Res.string.catalog_empty_title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = errorMessage ?: "This catalog did not return any items.",
text = errorMessage ?: stringResource(Res.string.catalog_empty_message),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)

View file

@ -0,0 +1,43 @@
package com.nuvio.app.features.collection
import com.nuvio.app.features.addons.AddonCatalog
import com.nuvio.app.features.addons.ManagedAddon
internal data class ResolvedCollectionCatalog(
val addon: ManagedAddon,
val catalog: AddonCatalog,
)
internal fun List<ManagedAddon>.findCollectionCatalog(
source: CollectionCatalogSource,
): ResolvedCollectionCatalog? {
val declaredAddon = firstOrNull { it.manifest?.id == source.addonId }
val declaredCatalog = declaredAddon?.manifest?.catalogs?.findSourceCatalog(source)
if (declaredAddon != null && declaredCatalog != null) {
return ResolvedCollectionCatalog(addon = declaredAddon, catalog = declaredCatalog)
}
return firstNotNullOfOrNull { addon ->
val catalog = addon.manifest?.catalogs?.find {
it.id == source.catalogId && it.type == source.type
} ?: return@firstNotNullOfOrNull null
ResolvedCollectionCatalog(addon = addon, catalog = catalog)
}
}
internal fun List<AvailableCatalog>.findAvailableCatalog(
source: CollectionCatalogSource,
): AvailableCatalog? {
val declaredCatalogs = filter { it.addonId == source.addonId }
return declaredCatalogs.findSourceCatalog(source)
?: firstOrNull { it.catalogId == source.catalogId && it.type == source.type }
}
private fun List<AddonCatalog>.findSourceCatalog(source: CollectionCatalogSource): AddonCatalog? =
find { it.id == source.catalogId && it.type == source.type }
?: find { it.id == source.catalogId.substringBefore(",") && it.type == source.type }
private fun List<AvailableCatalog>.findSourceCatalog(source: CollectionCatalogSource): AvailableCatalog? =
find { it.catalogId == source.catalogId && it.type == source.type }
?: find { it.catalogId == source.catalogId.substringBefore(",") && it.type == source.type }

View file

@ -2,9 +2,15 @@ package com.nuvio.app.features.collection
import co.touchlab.kermit.Logger
import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.trakt.TraktPublicListSearchResult
import com.nuvio.app.features.trakt.TraktPublicListSourceResolver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@ -22,11 +28,46 @@ data class CollectionEditorUiState(
val editingFolder: CollectionFolder? = null,
val showFolderEditor: Boolean = false,
val showCatalogPicker: Boolean = false,
val showTmdbSourcePicker: Boolean = false,
val showTraktSourcePicker: Boolean = false,
val editingTraktSourceIndex: Int? = null,
val genrePickerSourceIndex: Int? = null,
val tmdbBuilderMode: TmdbBuilderMode = TmdbBuilderMode.PRESETS,
val tmdbInput: String = "",
val tmdbTitleInput: String = "",
val tmdbMediaType: TmdbCollectionMediaType = TmdbCollectionMediaType.MOVIE,
val tmdbMediaBoth: Boolean = false,
val tmdbSortBy: String = TmdbCollectionSort.POPULAR_DESC.value,
val tmdbFilters: TmdbCollectionFilters = TmdbCollectionFilters(),
val tmdbCompanyResults: List<TmdbCompanySearchResult> = emptyList(),
val tmdbCollectionResults: List<TmdbCollectionSearchResult> = emptyList(),
val tmdbSearchError: String? = null,
val traktInput: String = "",
val traktTitleInput: String = "",
val traktMediaType: TmdbCollectionMediaType = TmdbCollectionMediaType.MOVIE,
val traktMediaBoth: Boolean = true,
val traktSortBy: String = TraktListSort.RANK.value,
val traktSortHow: String = TraktSortHow.ASC.value,
val traktSearchResults: List<TraktPublicListSearchResult> = emptyList(),
val traktTrendingResults: List<TraktPublicListSearchResult> = emptyList(),
val traktPopularResults: List<TraktPublicListSearchResult> = emptyList(),
val traktSearchError: String? = null,
)
enum class TmdbBuilderMode {
PRESETS,
LIST,
PRODUCTION,
NETWORK,
COLLECTION,
PERSON,
DIRECTOR,
DISCOVER,
}
object CollectionEditorRepository {
private val log = Logger.withTag("CollectionEditorRepository")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _uiState = MutableStateFlow(CollectionEditorUiState())
val uiState: StateFlow<CollectionEditorUiState> = _uiState.asStateFlow()
@ -93,10 +134,10 @@ object CollectionEditorRepository {
}
@OptIn(ExperimentalUuidApi::class)
fun addFolder() {
fun addFolder(defaultTitle: String) {
val newFolder = CollectionFolder(
id = Uuid.random().toString(),
title = "New Folder",
title = defaultTitle,
)
_uiState.value = _uiState.value.copy(
editingFolder = newFolder,
@ -177,13 +218,8 @@ object CollectionEditorRepository {
fun updateFolderTileShape(shape: PosterShape) {
val folder = _uiState.value.editingFolder ?: return
val shapeStr = when (shape) {
PosterShape.Poster -> "Poster"
PosterShape.Landscape -> "Landscape"
PosterShape.Square -> "Square"
}
_uiState.value = _uiState.value.copy(
editingFolder = folder.copy(tileShape = shapeStr),
editingFolder = folder.copy(tileShape = shape.name.lowercase()),
)
}
@ -203,39 +239,44 @@ object CollectionEditorRepository {
catalogId = catalog.catalogId,
genre = defaultGenre,
)
if (folder.catalogSources.any {
if (folder.resolvedCatalogSources.any {
it.addonId == source.addonId && it.type == source.type && it.catalogId == source.catalogId
}) return
_uiState.value = _uiState.value.copy(
editingFolder = folder.copy(catalogSources = folder.catalogSources + source),
editingFolder = folder.withSources(folder.resolvedSources + source.toCollectionSource()),
)
}
fun removeCatalogSource(index: Int) {
val folder = _uiState.value.editingFolder ?: return
if (index !in folder.catalogSources.indices) return
val sources = folder.resolvedSources
if (index !in sources.indices) return
_uiState.value = _uiState.value.copy(
editingFolder = folder.copy(
catalogSources = folder.catalogSources.toMutableList().apply { removeAt(index) },
),
editingFolder = folder.withSources(sources.toMutableList().apply { removeAt(index) }),
genrePickerSourceIndex = null,
)
}
fun updateCatalogSourceGenre(index: Int, genre: String?) {
val folder = _uiState.value.editingFolder ?: return
if (index !in folder.catalogSources.indices) return
val updated = folder.catalogSources.toMutableList()
val sources = folder.resolvedSources
if (index !in sources.indices || sources[index].addonCatalogSource() == null) return
val updated = sources.toMutableList()
updated[index] = updated[index].copy(genre = genre)
_uiState.value = _uiState.value.copy(
editingFolder = folder.copy(catalogSources = updated),
editingFolder = folder.withSources(updated),
)
}
fun toggleCatalogSource(catalog: AvailableCatalog) {
val folder = _uiState.value.editingFolder ?: return
val existingIndex = folder.catalogSources.indexOfFirst {
it.addonId == catalog.addonId && it.type == catalog.type && it.catalogId == catalog.catalogId
val sources = folder.resolvedSources
val existingIndex = sources.indexOfFirst {
!it.isTmdb &&
!it.isTrakt &&
it.addonId == catalog.addonId &&
it.type == catalog.type &&
it.catalogId == catalog.catalogId
}
if (existingIndex >= 0) {
removeCatalogSource(existingIndex)
@ -247,6 +288,9 @@ object CollectionEditorRepository {
fun showCatalogPicker() {
_uiState.value = _uiState.value.copy(
showCatalogPicker = true,
showTmdbSourcePicker = false,
showTraktSourcePicker = false,
editingTraktSourceIndex = null,
genrePickerSourceIndex = null,
)
}
@ -255,12 +299,154 @@ object CollectionEditorRepository {
_uiState.value = _uiState.value.copy(showCatalogPicker = false)
}
fun showTmdbSourcePicker() {
_uiState.value = _uiState.value.copy(
showTmdbSourcePicker = true,
showCatalogPicker = false,
showTraktSourcePicker = false,
editingTraktSourceIndex = null,
genrePickerSourceIndex = null,
tmdbSearchError = null,
)
}
fun hideTmdbSourcePicker() {
_uiState.value = _uiState.value.copy(showTmdbSourcePicker = false, tmdbSearchError = null)
}
fun showTraktSourcePicker() {
_uiState.value = _uiState.value.copy(
showTraktSourcePicker = true,
showCatalogPicker = false,
showTmdbSourcePicker = false,
editingTraktSourceIndex = null,
genrePickerSourceIndex = null,
traktInput = "",
traktTitleInput = "",
traktMediaType = TmdbCollectionMediaType.MOVIE,
traktMediaBoth = true,
traktSortBy = TraktListSort.RANK.value,
traktSortHow = TraktSortHow.ASC.value,
traktSearchResults = emptyList(),
traktSearchError = null,
)
loadTraktFeaturedLists()
}
fun hideTraktSourcePicker() {
_uiState.value = _uiState.value.copy(
showTraktSourcePicker = false,
editingTraktSourceIndex = null,
traktSearchError = null,
)
}
fun editTraktSource(index: Int) {
val folder = _uiState.value.editingFolder ?: return
val source = folder.resolvedSources.getOrNull(index) ?: return
if (!source.isTrakt) return
_uiState.value = _uiState.value.copy(
showTraktSourcePicker = true,
showCatalogPicker = false,
showTmdbSourcePicker = false,
editingTraktSourceIndex = index,
genrePickerSourceIndex = null,
traktInput = source.traktListId?.toString().orEmpty(),
traktTitleInput = source.title.orEmpty(),
traktMediaType = TmdbCollectionMediaType.fromString(source.mediaType),
traktMediaBoth = false,
traktSortBy = TraktListSort.normalize(source.sortBy),
traktSortHow = TraktSortHow.normalize(source.sortHow),
traktSearchResults = emptyList(),
traktSearchError = null,
)
loadTraktFeaturedLists()
}
fun setTraktInput(value: String) {
_uiState.value = _uiState.value.copy(traktInput = value, traktSearchError = null)
}
fun setTraktTitleInput(value: String) {
_uiState.value = _uiState.value.copy(traktTitleInput = value)
}
fun setTraktMediaType(value: TmdbCollectionMediaType) {
_uiState.value = _uiState.value.copy(traktMediaType = value, traktMediaBoth = false)
}
fun setTraktMediaBoth(value: Boolean) {
_uiState.value = _uiState.value.copy(
traktMediaBoth = value,
traktMediaType = if (value) TmdbCollectionMediaType.MOVIE else _uiState.value.traktMediaType,
)
}
fun setTraktSortBy(value: String) {
_uiState.value = _uiState.value.copy(traktSortBy = TraktListSort.normalize(value))
}
fun setTraktSortHow(value: String) {
_uiState.value = _uiState.value.copy(traktSortHow = TraktSortHow.normalize(value))
}
fun searchTraktLists() {
val state = _uiState.value
val query = state.traktInput.trim()
if (query.isBlank()) {
_uiState.value = state.copy(traktSearchError = "Enter a Trakt list name, URL, or ID")
return
}
scope.launch {
val results = if (query.isTraktListIdentifierInput()) {
runCatching {
val metadata = TraktPublicListSourceResolver.listImportMetadata(query)
val id = metadata.traktListId ?: error("Could not load Trakt list")
listOf(
TraktPublicListSearchResult(
traktListId = id,
title = metadata.title ?: "Trakt List $id",
subtitle = "Resolved Trakt list",
coverImageUrl = metadata.coverImageUrl,
),
)
}
} else {
runCatching { TraktPublicListSourceResolver.searchPublicLists(query) }
}
val mapped = results.getOrDefault(emptyList())
_uiState.value = _uiState.value.copy(
traktSearchResults = mapped,
traktSearchError = results.exceptionOrNull()?.message
?: if (mapped.isEmpty()) "No Trakt lists found" else null,
)
}
}
private fun loadTraktFeaturedLists() {
scope.launch {
val trending = runCatching { TraktPublicListSourceResolver.trendingPublicLists() }
val popular = runCatching { TraktPublicListSourceResolver.popularPublicLists() }
_uiState.value = _uiState.value.copy(
traktTrendingResults = trending.getOrDefault(_uiState.value.traktTrendingResults),
traktPopularResults = popular.getOrDefault(_uiState.value.traktPopularResults),
traktSearchError = _uiState.value.traktSearchError
?: trending.exceptionOrNull()?.message
?: popular.exceptionOrNull()?.message,
)
}
}
fun showGenrePicker(index: Int) {
val folder = _uiState.value.editingFolder ?: return
if (index !in folder.catalogSources.indices) return
val sources = folder.resolvedSources
if (index !in sources.indices || sources[index].addonCatalogSource() == null) return
_uiState.value = _uiState.value.copy(
genrePickerSourceIndex = index,
showCatalogPicker = false,
showTmdbSourcePicker = false,
showTraktSourcePicker = false,
)
}
@ -270,17 +456,21 @@ object CollectionEditorRepository {
fun saveFolderEdit() {
val folder = _uiState.value.editingFolder ?: return
val normalizedFolder = folder.withSources(folder.resolvedSources)
val existing = _uiState.value.folders
val updated = if (existing.any { it.id == folder.id }) {
existing.map { if (it.id == folder.id) folder else it }
val updated = if (existing.any { it.id == normalizedFolder.id }) {
existing.map { if (it.id == normalizedFolder.id) normalizedFolder else it }
} else {
existing + folder
existing + normalizedFolder
}
_uiState.value = _uiState.value.copy(
folders = updated,
editingFolder = null,
showFolderEditor = false,
showCatalogPicker = false,
showTmdbSourcePicker = false,
showTraktSourcePicker = false,
editingTraktSourceIndex = null,
genrePickerSourceIndex = null,
)
}
@ -290,10 +480,320 @@ object CollectionEditorRepository {
editingFolder = null,
showFolderEditor = false,
showCatalogPicker = false,
showTmdbSourcePicker = false,
showTraktSourcePicker = false,
editingTraktSourceIndex = null,
genrePickerSourceIndex = null,
)
}
fun setTmdbBuilderMode(mode: TmdbBuilderMode) {
val mediaType = if (mode == TmdbBuilderMode.NETWORK) {
TmdbCollectionMediaType.TV
} else {
_uiState.value.tmdbMediaType
}
val sortBy = when (mode) {
TmdbBuilderMode.LIST,
TmdbBuilderMode.COLLECTION -> TmdbCollectionSort.ORIGINAL.value
else -> TmdbCollectionSort.POPULAR_DESC.value
}
_uiState.value = _uiState.value.copy(
tmdbBuilderMode = mode,
tmdbMediaType = mediaType,
tmdbSortBy = sortBy,
tmdbMediaBoth = if (
mode == TmdbBuilderMode.NETWORK ||
mode == TmdbBuilderMode.LIST ||
mode == TmdbBuilderMode.COLLECTION
) {
false
} else {
_uiState.value.tmdbMediaBoth
},
tmdbCompanyResults = emptyList(),
tmdbCollectionResults = emptyList(),
tmdbSearchError = null,
)
}
fun setTmdbInput(value: String) {
_uiState.value = _uiState.value.copy(tmdbInput = value, tmdbSearchError = null)
}
fun setTmdbTitleInput(value: String) {
_uiState.value = _uiState.value.copy(tmdbTitleInput = value)
}
fun setTmdbMediaType(value: TmdbCollectionMediaType) {
_uiState.value = _uiState.value.copy(tmdbMediaType = value, tmdbMediaBoth = false)
}
fun setTmdbMediaBoth(value: Boolean) {
_uiState.value = _uiState.value.copy(
tmdbMediaBoth = value,
tmdbMediaType = if (value) TmdbCollectionMediaType.MOVIE else _uiState.value.tmdbMediaType,
)
}
fun setTmdbSortBy(value: String) {
_uiState.value = _uiState.value.copy(tmdbSortBy = value)
}
fun updateTmdbFilters(transform: (TmdbCollectionFilters) -> TmdbCollectionFilters) {
_uiState.value = _uiState.value.copy(tmdbFilters = transform(_uiState.value.tmdbFilters))
}
fun addTmdbPreset(source: CollectionSource) {
addTmdbSource(source)
}
fun searchTmdbCompanies() {
val query = _uiState.value.tmdbInput.trim()
if (query.isBlank()) return
scope.launch {
val results = runCatching { TmdbCollectionSourceResolver.searchCompanies(query) }
_uiState.value = _uiState.value.copy(
tmdbCompanyResults = results.getOrDefault(emptyList()),
tmdbSearchError = results.exceptionOrNull()?.message,
)
}
}
fun searchTmdbCollections() {
val query = _uiState.value.tmdbInput.trim()
if (query.isBlank()) return
scope.launch {
val results = runCatching { TmdbCollectionSourceResolver.searchCollections(query) }
_uiState.value = _uiState.value.copy(
tmdbCollectionResults = results.getOrDefault(emptyList()),
tmdbSearchError = results.exceptionOrNull()?.message,
)
}
}
fun addTmdbSource(source: CollectionSource) {
val sourceType = source.tmdbType()
if (source.tmdbId != null && sourceType in coverMetadataSourceTypes) {
scope.launch {
val metadata = runCatching { TmdbCollectionSourceResolver.importMetadata(sourceType, source.tmdbId) }
val resolved = metadata.getOrNull()
addTmdbSources(
sources = listOf(
if (source.title.isNullOrBlank()) {
source.copy(title = resolved?.title)
} else {
source
},
),
coverImageUrl = resolved?.coverImageUrl,
)
}
return
}
addTmdbSources(listOf(source))
}
fun addTmdbSourcesFromPicker(sources: List<CollectionSource>) {
val metadataSource = sources.firstOrNull {
it.tmdbId != null && it.tmdbType() in coverMetadataSourceTypes
}
if (metadataSource != null) {
scope.launch {
val sourceType = metadataSource.tmdbType()
val metadata = runCatching { TmdbCollectionSourceResolver.importMetadata(sourceType, metadataSource.tmdbId!!) }
addTmdbSources(sources, metadata.getOrNull()?.coverImageUrl)
}
return
}
addTmdbSources(sources)
}
fun addTmdbSourceFromInput() {
val state = _uiState.value
val mode = state.tmdbBuilderMode
val sourceType = when (mode) {
TmdbBuilderMode.PRESETS -> TmdbCollectionSourceType.DISCOVER
TmdbBuilderMode.LIST -> TmdbCollectionSourceType.LIST
TmdbBuilderMode.COLLECTION -> TmdbCollectionSourceType.COLLECTION
TmdbBuilderMode.PRODUCTION -> TmdbCollectionSourceType.COMPANY
TmdbBuilderMode.NETWORK -> TmdbCollectionSourceType.NETWORK
TmdbBuilderMode.PERSON -> TmdbCollectionSourceType.PERSON
TmdbBuilderMode.DIRECTOR -> TmdbCollectionSourceType.DIRECTOR
TmdbBuilderMode.DISCOVER -> TmdbCollectionSourceType.DISCOVER
}
val id = TmdbCollectionSourceResolver.parseTmdbId(state.tmdbInput)
if (sourceType != TmdbCollectionSourceType.DISCOVER && id == null) {
_uiState.value = state.copy(tmdbSearchError = "Enter a valid TMDB ID or URL.")
return
}
val mediaTypes = selectedMediaTypes(state, sourceType)
val baseTitle = state.tmdbTitleInput.ifBlank {
when (sourceType) {
TmdbCollectionSourceType.LIST -> "TMDB List ${id ?: ""}".trim()
TmdbCollectionSourceType.COLLECTION -> "TMDB Collection ${id ?: ""}".trim()
TmdbCollectionSourceType.COMPANY -> "TMDB Production ${id ?: ""}".trim()
TmdbCollectionSourceType.NETWORK -> "TMDB Network ${id ?: ""}".trim()
TmdbCollectionSourceType.PERSON -> "TMDB Person ${id ?: ""}".trim()
TmdbCollectionSourceType.DIRECTOR -> "TMDB Director ${id ?: ""}".trim()
TmdbCollectionSourceType.DISCOVER -> "TMDB Discover"
}
}
val sources = mediaTypes.map { mediaType ->
CollectionSource(
provider = "tmdb",
tmdbSourceType = sourceType.name,
title = titleForMedia(baseTitle, mediaType, mediaTypes.size > 1),
tmdbId = id,
mediaType = mediaType.name,
sortBy = state.tmdbSortBy,
filters = state.tmdbFilters,
)
}
if (sourceType == TmdbCollectionSourceType.LIST || sourceType == TmdbCollectionSourceType.COLLECTION) {
scope.launch {
val metadata = runCatching { TmdbCollectionSourceResolver.importMetadata(sourceType, id!!) }
val resolved = metadata.getOrNull()
if (metadata.isFailure) {
_uiState.value = _uiState.value.copy(
tmdbSearchError = metadata.exceptionOrNull()?.message ?: "Could not load TMDB source",
)
return@launch
}
addTmdbSources(
sources.map { source ->
source.copy(title = state.tmdbTitleInput.ifBlank { resolved?.title ?: baseTitle })
},
coverImageUrl = resolved?.coverImageUrl,
)
}
return
}
addTmdbSourcesFromPicker(sources)
}
private fun addTmdbSources(sources: List<CollectionSource>, coverImageUrl: String? = null) {
val folder = _uiState.value.editingFolder ?: return
val existingKeys = folder.resolvedSources.mapTo(mutableSetOf(), ::collectionSourceKey)
val newSources = sources.filter { existingKeys.add(collectionSourceKey(it)) }
if (newSources.isEmpty()) return
val shouldApplyCover = newSources.any { it.tmdbType() in coverMetadataSourceTypes } &&
!coverImageUrl.isNullOrBlank() &&
folder.coverImageUrl.isNullOrBlank()
val updatedFolder = if (shouldApplyCover) {
folder.withSources(folder.resolvedSources + newSources)
.copy(coverImageUrl = coverImageUrl, coverEmoji = null)
} else {
folder.withSources(folder.resolvedSources + newSources)
}
_uiState.value = _uiState.value.copy(
editingFolder = updatedFolder,
showTmdbSourcePicker = false,
tmdbInput = "",
tmdbTitleInput = "",
tmdbCompanyResults = emptyList(),
tmdbCollectionResults = emptyList(),
tmdbSearchError = null,
)
}
fun addTraktSourceFromInput() {
val state = _uiState.value
val input = state.traktInput.trim()
if (input.isBlank()) {
_uiState.value = state.copy(traktSearchError = "Enter a Trakt list ID or URL")
return
}
scope.launch {
val metadata = runCatching { TraktPublicListSourceResolver.listImportMetadata(input) }
val resolved = metadata.getOrNull()
val listId = resolved?.traktListId
if (metadata.isFailure || listId == null) {
_uiState.value = _uiState.value.copy(
traktSearchError = metadata.exceptionOrNull()?.message ?: "Could not load Trakt list",
)
return@launch
}
val title = state.traktTitleInput.ifBlank { resolved.title ?: "Trakt List $listId" }
addTraktSourcesToFolder(
sources = selectedTraktMediaTypes(state).map { mediaType ->
CollectionSource(
provider = "trakt",
title = titleForMedia(title, mediaType, state.traktMediaBoth),
traktListId = listId,
mediaType = mediaType.name,
sortBy = TraktListSort.normalize(state.traktSortBy),
sortHow = TraktSortHow.normalize(state.traktSortHow),
)
},
coverImageUrl = resolved.coverImageUrl,
)
}
}
fun addTraktSourceFromResult(result: TraktPublicListSearchResult) {
val state = _uiState.value
val title = state.traktTitleInput.ifBlank { result.title }
addTraktSourcesToFolder(
sources = selectedTraktMediaTypes(state).map { mediaType ->
CollectionSource(
provider = "trakt",
title = titleForMedia(title, mediaType, state.traktMediaBoth),
traktListId = result.traktListId,
mediaType = mediaType.name,
sortBy = TraktListSort.normalize(state.traktSortBy),
sortHow = TraktSortHow.normalize(state.traktSortHow),
)
},
coverImageUrl = result.coverImageUrl,
)
}
private fun addTraktSourcesToFolder(sources: List<CollectionSource>, coverImageUrl: String? = null) {
val state = _uiState.value
val folder = state.editingFolder ?: return
val editingIndex = state.editingTraktSourceIndex
val existingKeys = folder.resolvedSources
.mapIndexedNotNull { index, source ->
collectionSourceKey(source).takeUnless { index == editingIndex }
}
.toMutableSet()
val newSources = sources.filter { existingKeys.add(collectionSourceKey(it)) }
if (newSources.isEmpty()) return
val updatedSources = if (
editingIndex != null &&
editingIndex in folder.resolvedSources.indices &&
folder.resolvedSources[editingIndex].isTrakt
) {
folder.resolvedSources.toMutableList().also {
it.removeAt(editingIndex)
it.addAll(editingIndex, newSources)
}
} else {
folder.resolvedSources + newSources
}
val shouldApplyCover = !coverImageUrl.isNullOrBlank() && folder.coverImageUrl.isNullOrBlank()
val updatedFolder = if (shouldApplyCover) {
folder.withSources(updatedSources)
.copy(coverImageUrl = coverImageUrl, coverEmoji = null)
} else {
folder.withSources(updatedSources)
}
_uiState.value = state.copy(
editingFolder = updatedFolder,
showTraktSourcePicker = false,
editingTraktSourceIndex = null,
traktInput = "",
traktTitleInput = "",
traktSearchResults = emptyList(),
traktSearchError = null,
)
}
fun save(): Boolean {
val state = _uiState.value
if (state.title.isBlank()) return false
@ -316,3 +816,92 @@ object CollectionEditorRepository {
return true
}
}
private val coverMetadataSourceTypes = setOf(
TmdbCollectionSourceType.COLLECTION,
TmdbCollectionSourceType.COMPANY,
TmdbCollectionSourceType.NETWORK,
TmdbCollectionSourceType.PERSON,
TmdbCollectionSourceType.DIRECTOR,
)
private fun CollectionCatalogSource.toCollectionSource(): CollectionSource =
CollectionSource(
provider = "addon",
addonId = addonId,
type = type,
catalogId = catalogId,
genre = genre,
)
private fun CollectionFolder.withSources(nextSources: List<CollectionSource>): CollectionFolder =
copy(
sources = nextSources,
catalogSources = nextSources.mapNotNull { it.addonCatalogSource() },
)
private fun collectionSourceKey(source: CollectionSource): String =
when {
source.isTmdb -> {
"tmdb_${source.tmdbSourceType}_${source.tmdbId}_${source.mediaType}_${source.sortBy}_${source.filters.hashCode()}"
}
source.isTrakt -> {
"trakt_${source.traktListId}_${source.mediaType}_${TraktListSort.normalize(source.sortBy)}_${TraktSortHow.normalize(source.sortHow)}"
}
else -> {
"addon_${source.addonId}_${source.type}_${source.catalogId}_${source.genre.orEmpty()}"
}
}
private fun selectedMediaTypes(
state: CollectionEditorUiState,
sourceType: TmdbCollectionSourceType,
): List<TmdbCollectionMediaType> =
when (sourceType) {
TmdbCollectionSourceType.COMPANY,
TmdbCollectionSourceType.PERSON,
TmdbCollectionSourceType.DIRECTOR,
TmdbCollectionSourceType.DISCOVER -> if (state.tmdbMediaBoth) {
listOf(TmdbCollectionMediaType.MOVIE, TmdbCollectionMediaType.TV)
} else {
listOf(state.tmdbMediaType)
}
TmdbCollectionSourceType.NETWORK -> listOf(TmdbCollectionMediaType.TV)
TmdbCollectionSourceType.COLLECTION,
TmdbCollectionSourceType.LIST -> listOf(TmdbCollectionMediaType.MOVIE)
}
private fun titleForMedia(
title: String,
mediaType: TmdbCollectionMediaType,
addSuffix: Boolean,
): String {
if (!addSuffix) return title
val suffix = when (mediaType) {
TmdbCollectionMediaType.MOVIE -> "Movies"
TmdbCollectionMediaType.TV -> "Series"
}
return "$title $suffix"
}
private fun selectedTraktMediaTypes(state: CollectionEditorUiState): List<TmdbCollectionMediaType> =
if (state.traktMediaBoth) {
listOf(TmdbCollectionMediaType.MOVIE, TmdbCollectionMediaType.TV)
} else {
listOf(state.traktMediaType)
}
private fun CollectionSource.tmdbType(): TmdbCollectionSourceType =
tmdbSourceType
?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() }
?: TmdbCollectionSourceType.DISCOVER
private fun String.isTraktListIdentifierInput(): Boolean {
val trimmed = trim()
if (trimmed.isBlank()) return false
if (trimmed.toLongOrNull() != null) return true
if (trimmed.contains("trakt.tv/", ignoreCase = true)) return true
return Regex("""[?&]id=([^&#/]+)""").containsMatchIn(trimmed)
}

View file

@ -0,0 +1,170 @@
package com.nuvio.app.features.collection
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
internal object CollectionJsonPreserver {
fun merge(
json: Json,
rawCollectionsJson: JsonElement,
collections: List<Collection>,
): JsonArray {
val rawById = rawCollectionsJson.asObjectArrayById()
return buildJsonArray {
collections.forEach { collection ->
add(
mergeCollection(
json = json,
raw = rawById[collection.id],
collection = collection,
),
)
}
}
}
private fun mergeCollection(
json: Json,
raw: JsonObject?,
collection: Collection,
): JsonObject {
val encoded = json.encodeToJsonElement(Collection.serializer(), collection).jsonObject
val rawFoldersById = raw?.get("folders").asObjectArrayById()
val mergedFolders = buildJsonArray {
collection.folders.forEach { folder ->
add(
mergeFolder(
json = json,
raw = rawFoldersById[folder.id],
folder = folder,
),
)
}
}
return mergeObjects(raw, encoded, mapOf("folders" to mergedFolders))
}
private fun mergeFolder(
json: Json,
raw: JsonObject?,
folder: CollectionFolder,
): JsonObject {
val encoded = json.encodeToJsonElement(CollectionFolder.serializer(), folder).jsonObject
val rawUnifiedSourcesByKey = raw?.get("sources").asObjectArrayByKey(::unifiedSourceKey)
val mergedUnifiedSources = buildJsonArray {
folder.resolvedSources.forEach { source ->
val sourceElement = json.encodeToJsonElement(CollectionSource.serializer(), source)
add(
mergeUnifiedSource(
json = json,
raw = rawUnifiedSourcesByKey[unifiedSourceKey(sourceElement)],
source = source,
),
)
}
}
val rawSourcesByKey = raw?.get("catalogSources").asObjectArrayByKey(::sourceKey)
val mergedSources = buildJsonArray {
folder.resolvedCatalogSources.forEach { source ->
val sourceElement =
json.encodeToJsonElement(CollectionCatalogSource.serializer(), source)
add(
mergeSource(
json = json,
raw = rawSourcesByKey[sourceKey(sourceElement)],
source = source,
),
)
}
}
return mergeObjects(
raw,
encoded,
mapOf(
"sources" to mergedUnifiedSources,
"catalogSources" to mergedSources,
),
)
}
private fun mergeUnifiedSource(
json: Json,
raw: JsonObject?,
source: CollectionSource,
): JsonObject {
val encoded = json.encodeToJsonElement(CollectionSource.serializer(), source).jsonObject
return mergeObjects(raw, encoded)
}
private fun mergeSource(
json: Json,
raw: JsonObject?,
source: CollectionCatalogSource,
): JsonObject {
val encoded = json.encodeToJsonElement(CollectionCatalogSource.serializer(), source).jsonObject
return mergeObjects(raw, encoded)
}
private fun mergeObjects(
raw: JsonObject?,
encoded: JsonObject,
overrides: Map<String, JsonElement> = emptyMap(),
): JsonObject = buildJsonObject {
raw?.forEach { (key, value) -> put(key, value) }
encoded.forEach { (key, value) -> put(key, overrides[key] ?: value) }
}
private fun JsonElement?.asObjectArrayById(): Map<String, JsonObject> =
asObjectArrayByKey { obj -> obj["id"]?.jsonPrimitive?.contentOrNull }
private fun JsonElement?.asObjectArrayByKey(keySelector: (JsonObject) -> String?): Map<String, JsonObject> =
(this as? JsonArray)
?.mapNotNull { element ->
val obj = element as? JsonObject ?: return@mapNotNull null
keySelector(obj)?.let { key -> key to obj }
}
?.toMap()
.orEmpty()
private fun sourceKey(element: JsonElement): String? {
val obj = element as? JsonObject ?: return null
val addonId = obj["addonId"]?.jsonPrimitive?.contentOrNull ?: return null
val type = obj["type"]?.jsonPrimitive?.contentOrNull ?: return null
val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null
return "$addonId|$type|$catalogId"
}
private fun unifiedSourceKey(element: JsonElement): String? {
val obj = element as? JsonObject ?: return null
val provider = obj["provider"]?.jsonPrimitive?.contentOrNull ?: "addon"
return when {
provider.equals("tmdb", ignoreCase = true) -> {
val sourceType = obj["tmdbSourceType"]?.jsonPrimitive?.contentOrNull ?: return null
val tmdbId = obj["tmdbId"]?.jsonPrimitive?.contentOrNull.orEmpty()
val mediaType = obj["mediaType"]?.jsonPrimitive?.contentOrNull.orEmpty()
val sortBy = obj["sortBy"]?.jsonPrimitive?.contentOrNull.orEmpty()
"$provider|$sourceType|$tmdbId|$mediaType|$sortBy"
}
provider.equals("trakt", ignoreCase = true) -> {
val listId = obj["traktListId"]?.jsonPrimitive?.contentOrNull ?: return null
val mediaType = obj["mediaType"]?.jsonPrimitive?.contentOrNull.orEmpty()
val sortBy = obj["sortBy"]?.jsonPrimitive?.contentOrNull.orEmpty()
val sortHow = obj["sortHow"]?.jsonPrimitive?.contentOrNull.orEmpty()
"$provider|$listId|$mediaType|$sortBy|$sortHow"
}
else -> {
val addonId = obj["addonId"]?.jsonPrimitive?.contentOrNull ?: return null
val type = obj["type"]?.jsonPrimitive?.contentOrNull ?: return null
val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null
"$provider|$addonId|$type|$catalogId"
}
}
}
}

View file

@ -55,6 +55,8 @@ import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.NuvioSectionLabel
import com.nuvio.app.core.ui.NuvioStatusModal
import com.nuvio.app.core.ui.NuvioSurfaceCard
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
import sh.calvin.reorderable.ReorderableCollectionItemScope
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
@ -75,7 +77,7 @@ fun CollectionManagementScreen(
NuvioScreen {
stickyHeader {
NuvioScreenHeader(
title = "Collections",
title = stringResource(Res.string.collections_header),
onBack = onBack,
) {
IconButton(onClick = {
@ -84,14 +86,14 @@ fun CollectionManagementScreen(
}) {
Icon(
imageVector = Icons.Rounded.ContentCopy,
contentDescription = "Copy JSON",
contentDescription = stringResource(Res.string.collections_copy_json),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = { showImportDialog = true }) {
Icon(
imageVector = Icons.Rounded.ContentPaste,
contentDescription = "Import",
contentDescription = stringResource(Res.string.collections_import),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@ -100,8 +102,11 @@ fun CollectionManagementScreen(
item {
NuvioSurfaceCard {
Text(
text = "${collections.size} collection${if (collections.size != 1) "s" else ""}, " +
"${collections.sumOf { it.folders.size }} folder${if (collections.sumOf { it.folders.size } != 1) "s" else ""}",
text = stringResource(
Res.string.collections_count_summary,
collections.size,
collections.sumOf { it.folders.size },
),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -110,13 +115,13 @@ fun CollectionManagementScreen(
item {
NuvioPrimaryButton(
text = "New Collection",
text = stringResource(Res.string.collections_new),
onClick = { onNavigateToEditor(null) },
)
}
if (collections.isNotEmpty()) {
item { NuvioSectionLabel(text = "YOUR COLLECTIONS") }
item { NuvioSectionLabel(text = stringResource(Res.string.collections_your_collections)) }
}
if (collections.isNotEmpty()) {
@ -142,13 +147,13 @@ fun CollectionManagementScreen(
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "No collections yet",
text = stringResource(Res.string.collections_empty_title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Create one to organize your catalogs.",
text = stringResource(Res.string.collections_empty_subtitle),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -187,11 +192,11 @@ fun CollectionManagementScreen(
val deleteId = showDeleteConfirm
val deleteCollection = deleteId?.let { id -> collections.find { it.id == id } }
NuvioStatusModal(
title = "Delete Collection",
message = "Delete \"${deleteCollection?.title ?: ""}\"? This cannot be undone.",
title = stringResource(Res.string.collections_delete_title),
message = stringResource(Res.string.collections_delete_message, deleteCollection?.title.orEmpty()),
isVisible = deleteId != null,
confirmText = "Delete",
dismissText = "Cancel",
confirmText = stringResource(Res.string.action_delete),
dismissText = stringResource(Res.string.action_cancel),
onConfirm = {
if (deleteId != null) {
CollectionRepository.removeCollection(deleteId)
@ -261,6 +266,13 @@ private fun CollectionListItem(
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
val summary = buildString {
append(stringResource(Res.string.collections_folder_count, collection.folders.size))
if (collection.pinToTop) {
append(" · ")
append(stringResource(Res.string.collections_pinned))
}
}
Text(
text = collection.title,
style = MaterialTheme.typography.bodyLarge,
@ -271,8 +283,7 @@ private fun CollectionListItem(
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "${collection.folders.size} folder${if (collection.folders.size != 1) "s" else ""}" +
if (collection.pinToTop) " · Pinned" else "",
text = summary,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -298,7 +309,7 @@ private fun CollectionListItem(
) {
Icon(
imageVector = Icons.Rounded.Menu,
contentDescription = "Reorder",
contentDescription = stringResource(Res.string.action_reorder),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -310,7 +321,7 @@ private fun CollectionListItem(
) {
Icon(
imageVector = Icons.Rounded.Edit,
contentDescription = "Edit",
contentDescription = stringResource(Res.string.action_edit),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
@ -321,7 +332,7 @@ private fun CollectionListItem(
) {
Icon(
imageVector = Icons.Rounded.Delete,
contentDescription = "Delete",
contentDescription = stringResource(Res.string.action_delete),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.error,
)
@ -349,13 +360,13 @@ private fun ImportDialog(
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
text = "Import Collections",
text = stringResource(Res.string.collections_import_header),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Paste your collections JSON below.",
text = stringResource(Res.string.collections_import_paste_description),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -366,7 +377,12 @@ private fun ImportDialog(
modifier = Modifier
.fillMaxWidth()
.height(160.dp),
placeholder = { Text("JSON", style = MaterialTheme.typography.bodyLarge) },
placeholder = {
Text(
stringResource(Res.string.collections_import_json_placeholder),
style = MaterialTheme.typography.bodyLarge,
)
},
isError = importError != null,
supportingText = importError?.let {
{ Text(it, color = MaterialTheme.colorScheme.error) }
@ -399,7 +415,7 @@ private fun ImportDialog(
contentColor = MaterialTheme.colorScheme.onSurface,
),
) {
Text("Cancel")
Text(stringResource(Res.string.action_cancel))
}
Spacer(modifier = Modifier.width(10.dp))
androidx.compose.material3.Button(
@ -407,7 +423,7 @@ private fun ImportDialog(
enabled = importText.isNotBlank(),
shape = RoundedCornerShape(16.dp),
) {
Text("Import")
Text(stringResource(Res.string.action_import))
}
}
}

View file

@ -30,6 +30,136 @@ data class CollectionCatalogSource(
val genre: String? = null,
)
@Immutable
@Serializable
data class CollectionSource(
val provider: String = "addon",
val addonId: String? = null,
val type: String? = null,
val catalogId: String? = null,
val genre: String? = null,
val tmdbSourceType: String? = null,
val title: String? = null,
val tmdbId: Int? = null,
val traktListId: Long? = null,
val mediaType: String? = null,
val sortBy: String? = null,
val sortHow: String? = null,
val filters: TmdbCollectionFilters? = null,
) {
val isTmdb: Boolean
get() = provider.equals("tmdb", ignoreCase = true)
val isTrakt: Boolean
get() = provider.equals("trakt", ignoreCase = true)
fun addonCatalogSource(): CollectionCatalogSource? {
if (isTmdb || isTrakt) return null
val sourceAddonId = addonId?.takeIf { it.isNotBlank() } ?: return null
val sourceType = type?.takeIf { it.isNotBlank() } ?: return null
val sourceCatalogId = catalogId?.takeIf { it.isNotBlank() } ?: return null
return CollectionCatalogSource(
addonId = sourceAddonId,
type = sourceType,
catalogId = sourceCatalogId,
genre = genre.normalizedOptionalGenre(),
)
}
}
internal fun CollectionSource.hasInvalidTraktListId(): Boolean =
isTrakt && (traktListId == null || traktListId <= 0L)
@Serializable
enum class TmdbCollectionSourceType {
LIST,
COLLECTION,
COMPANY,
NETWORK,
DISCOVER,
PERSON,
DIRECTOR,
}
@Serializable
enum class TmdbCollectionMediaType(val value: String) {
MOVIE("movie"),
TV("tv");
companion object {
fun fromString(value: String?): TmdbCollectionMediaType =
when (value?.trim()?.lowercase()) {
"tv", "series" -> TV
else -> MOVIE
}
}
}
enum class TmdbCollectionSort(val value: String) {
ORIGINAL("original"),
POPULAR_DESC("popularity.desc"),
VOTE_AVERAGE_DESC("vote_average.desc"),
RELEASE_DATE_DESC("primary_release_date.desc"),
FIRST_AIR_DATE_DESC("first_air_date.desc"),
}
enum class TraktListSort(val value: String) {
RANK("rank"),
ADDED("added"),
TITLE("title"),
RELEASED("released"),
RUNTIME("runtime"),
POPULARITY("popularity"),
PERCENTAGE("percentage"),
VOTES("votes");
companion object {
fun normalize(value: String?): String {
val raw = value?.trim()?.lowercase().orEmpty()
return entries.firstOrNull { it.value == raw }?.value ?: RANK.value
}
}
}
enum class TraktSortHow(val value: String) {
ASC("asc"),
DESC("desc");
companion object {
fun normalize(value: String?): String {
val raw = value?.trim()?.lowercase().orEmpty()
return entries.firstOrNull { it.value == raw }?.value ?: ASC.value
}
}
}
@Immutable
@Serializable
data class TmdbCollectionFilters(
val withGenres: String? = null,
val releaseDateGte: String? = null,
val releaseDateLte: String? = null,
val voteAverageGte: Double? = null,
val voteAverageLte: Double? = null,
val voteCountGte: Int? = null,
val withOriginalLanguage: String? = null,
val withOriginCountry: String? = null,
val withKeywords: String? = null,
val withCompanies: String? = null,
val withNetworks: String? = null,
val year: Int? = null,
)
data class TmdbSourceImportMetadata(
val title: String? = null,
val coverImageUrl: String? = null,
)
data class TmdbPresetSource(
val label: String,
val source: CollectionSource,
)
@Immutable
@Serializable
data class CollectionFolder(
@ -39,9 +169,13 @@ data class CollectionFolder(
val focusGifUrl: String? = null,
val focusGifEnabled: Boolean = true,
val coverEmoji: String? = null,
val tileShape: String = "Poster",
val tileShape: String = "poster",
val hideTitle: Boolean = false,
val sources: List<CollectionSource> = emptyList(),
val catalogSources: List<CollectionCatalogSource> = emptyList(),
val heroBackdropUrl: String? = null,
val heroVideoUrl: String? = null,
val titleLogoUrl: String? = null,
) {
val posterShape: PosterShape
get() = when (tileShape.lowercase()) {
@ -50,6 +184,22 @@ data class CollectionFolder(
"square" -> PosterShape.Square
else -> PosterShape.Poster
}
val resolvedSources: List<CollectionSource>
get() = sources.ifEmpty {
catalogSources.map { source ->
CollectionSource(
provider = "addon",
addonId = source.addonId,
type = source.type,
catalogId = source.catalogId,
genre = source.genre.normalizedOptionalGenre(),
)
}
}
val resolvedCatalogSources: List<CollectionCatalogSource>
get() = resolvedSources.mapNotNull { it.addonCatalogSource() }
}
@Immutable
@ -67,6 +217,11 @@ data class Collection(
get() = FolderViewMode.fromString(viewMode)
}
private fun String?.normalizedOptionalGenre(): String? =
this
?.trim()
?.takeIf { it.isNotEmpty() && !it.equals("none", ignoreCase = true) }
data class AvailableCatalog(
val addonId: String,
val addonName: String,

View file

@ -4,11 +4,27 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.ManagedAddon
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.collections_import_error_collection_blank_id
import nuvio.composeapp.generated.resources.collections_import_error_collection_blank_title
import nuvio.composeapp.generated.resources.collections_import_error_empty_json
import nuvio.composeapp.generated.resources.collections_import_error_folder_blank_id
import nuvio.composeapp.generated.resources.collections_import_error_folder_blank_title
import nuvio.composeapp.generated.resources.collections_import_error_invalid_json
import nuvio.composeapp.generated.resources.collections_import_error_source_blank_fields
import nuvio.composeapp.generated.resources.collections_import_error_trakt_list_id
import org.jetbrains.compose.resources.getString
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@ -21,6 +37,9 @@ object CollectionRepository {
private val _collections = MutableStateFlow<List<Collection>>(emptyList())
val collections: StateFlow<List<Collection>> = _collections.asStateFlow()
private val _localChangeEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
internal val localChangeEvents: SharedFlow<Unit> = _localChangeEvents.asSharedFlow()
private var rawCollectionsJson: JsonElement = JsonArray(emptyList())
private var hasLoaded = false
@ -31,6 +50,8 @@ object CollectionRepository {
if (payload.isNullOrBlank()) return
runCatching {
val parsed = json.parseToJsonElement(payload)
rawCollectionsJson = parsed
_collections.value = json.decodeFromString<List<Collection>>(payload)
}.onFailure { e ->
log.e(e) { "Failed to load collections from storage" }
@ -40,11 +61,13 @@ object CollectionRepository {
fun onProfileChanged() {
hasLoaded = false
_collections.value = emptyList()
rawCollectionsJson = JsonArray(emptyList())
}
fun clearLocalState() {
hasLoaded = false
_collections.value = emptyList()
rawCollectionsJson = JsonArray(emptyList())
}
fun getCollection(id: String): Collection? =
@ -71,6 +94,7 @@ object CollectionRepository {
}
fun setCollections(collections: List<Collection>) {
ensureLoaded()
_collections.value = collections
persist()
}
@ -96,11 +120,12 @@ object CollectionRepository {
fun exportToJson(): String {
ensureLoaded()
return json.encodeToString(_collections.value)
return mergedCollectionsJson().toString()
}
fun importFromJson(jsonString: String): Result<List<Collection>> {
return runCatching {
rawCollectionsJson = json.parseToJsonElement(jsonString)
val imported = json.decodeFromString<List<Collection>>(jsonString)
_collections.value = imported
persist()
@ -110,28 +135,85 @@ object CollectionRepository {
fun validateJson(jsonString: String): ValidationResult {
if (jsonString.isBlank()) {
return ValidationResult(valid = false, error = "JSON is empty.")
return ValidationResult(
valid = false,
error = runBlocking { getString(Res.string.collections_import_error_empty_json) },
)
}
return try {
val collections = json.decodeFromString<List<Collection>>(jsonString)
var totalFolders = 0
collections.forEachIndexed { ci, c ->
if (c.id.isBlank()) {
return ValidationResult(valid = false, error = "Collection ${ci + 1} has blank id.")
return ValidationResult(
valid = false,
error = runBlocking {
getString(Res.string.collections_import_error_collection_blank_id, ci + 1)
},
)
}
if (c.title.isBlank()) {
return ValidationResult(valid = false, error = "Collection '${c.id}' has blank title.")
return ValidationResult(
valid = false,
error = runBlocking {
getString(Res.string.collections_import_error_collection_blank_title, c.id)
},
)
}
c.folders.forEachIndexed { fi, f ->
if (f.id.isBlank()) {
return ValidationResult(valid = false, error = "Folder ${fi + 1} in '${c.title}' has blank id.")
return ValidationResult(
valid = false,
error = runBlocking {
getString(
Res.string.collections_import_error_folder_blank_id,
fi + 1,
c.title,
)
},
)
}
if (f.title.isBlank()) {
return ValidationResult(valid = false, error = "Folder '${f.id}' in '${c.title}' has blank title.")
return ValidationResult(
valid = false,
error = runBlocking {
getString(
Res.string.collections_import_error_folder_blank_title,
f.id,
c.title,
)
},
)
}
f.catalogSources.forEachIndexed { si, s ->
if (s.addonId.isBlank() || s.type.isBlank() || s.catalogId.isBlank()) {
return ValidationResult(valid = false, error = "Source ${si + 1} in folder '${f.title}' has blank fields.")
f.resolvedSources.forEachIndexed { si, s ->
if (s.hasInvalidTraktListId()) {
return ValidationResult(
valid = false,
error = runBlocking {
getString(
Res.string.collections_import_error_trakt_list_id,
si + 1,
f.title,
)
},
)
}
val invalidAddon = !s.isTmdb && !s.isTrakt &&
(s.addonId.isNullOrBlank() || s.type.isNullOrBlank() || s.catalogId.isNullOrBlank())
val invalidTmdb = s.isTmdb &&
s.tmdbSourceType.isNullOrBlank()
if (invalidAddon || invalidTmdb) {
return ValidationResult(
valid = false,
error = runBlocking {
getString(
Res.string.collections_import_error_source_blank_fields,
si + 1,
f.title,
)
},
)
}
}
totalFolders++
@ -143,7 +225,12 @@ object CollectionRepository {
folderCount = totalFolders,
)
} catch (e: Exception) {
ValidationResult(valid = false, error = "Invalid JSON: ${e.message}")
ValidationResult(
valid = false,
error = runBlocking {
getString(Res.string.collections_import_error_invalid_json, e.message.orEmpty())
},
)
}
}
@ -173,20 +260,29 @@ object CollectionRepository {
}
}
internal fun applyFromRemote(collections: List<Collection>) {
internal fun applyFromRemote(collections: List<Collection>, rawJson: JsonElement) {
rawCollectionsJson = rawJson
_collections.value = collections
persist()
persist(sync = false)
}
private fun ensureLoaded() {
if (!hasLoaded) initialize()
}
private fun persist() {
private fun persist(sync: Boolean = true) {
runCatching {
CollectionStorage.savePayload(json.encodeToString(_collections.value))
CollectionStorage.savePayload(mergedCollectionsJson().toString())
if (sync) {
_localChangeEvents.tryEmit(Unit)
}
}.onFailure { e ->
log.e(e) { "Failed to persist collections" }
}
}
private fun mergedCollectionsJson(): JsonArray =
CollectionJsonPreserver.merge(json, rawCollectionsJson, _collections.value).also {
rawCollectionsJson = it
}
}

View file

@ -15,11 +15,10 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
@ -58,16 +57,13 @@ object CollectionSyncService {
return
}
val remoteJson = blob.collectionsJson.toString()
val localJson = CollectionRepository.exportToJson()
if (remoteJson == "[]" || remoteJson == "null") {
val currentCollections = CollectionRepository.collections.value
if (currentCollections.isNotEmpty()) {
log.i { "pullFromServer — remote empty, preserving local ${currentCollections.size} collections" }
return
}
val remoteCollectionsJson = if (blob.collectionsJson == JsonNull) {
JsonArray(emptyList())
} else {
blob.collectionsJson
}
val remoteJson = remoteCollectionsJson.toString()
val localJson = CollectionRepository.exportToJson()
if (remoteJson == localJson) {
log.d { "pullFromServer — remote matches local, no update needed" }
@ -80,7 +76,7 @@ object CollectionSyncService {
if (remoteCollections != null) {
isSyncingFromRemote = true
CollectionRepository.applyFromRemote(remoteCollections)
CollectionRepository.applyFromRemote(remoteCollections, remoteCollectionsJson)
isSyncingFromRemote = false
log.i { "pullFromServer — applied ${remoteCollections.size} collections from remote" }
} else {
@ -125,9 +121,7 @@ object CollectionSyncService {
@OptIn(FlowPreview::class)
private fun observeLocalChangesAndPush() {
observeJob = scope.launch {
CollectionRepository.collections
.drop(1)
.distinctUntilChanged()
CollectionRepository.localChangeEvents
.debounce(PUSH_DEBOUNCE_MS)
.collect {
if (isSyncingFromRemote) return@collect

View file

@ -3,12 +3,18 @@ package com.nuvio.app.features.collection
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE
import com.nuvio.app.features.catalog.CatalogPage
import com.nuvio.app.features.catalog.fetchCatalogPage
import com.nuvio.app.features.catalog.mergeCatalogItems
import com.nuvio.app.features.catalog.supportsPagination
import com.nuvio.app.core.i18n.localizedMediaTypeLabel
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.filterReleasedItems
import com.nuvio.app.features.home.stableKey
import com.nuvio.app.features.trakt.TraktPublicListSourceResolver
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -17,10 +23,16 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.collections_folder_addon_not_found
import nuvio.composeapp.generated.resources.collections_tab_all
import org.jetbrains.compose.resources.getString
data class FolderTab(
val label: String,
val typeLabel: String = "",
val source: CollectionSource? = null,
val manifestUrl: String? = null,
val type: String = "",
val catalogId: String = "",
@ -108,36 +120,81 @@ object FolderDetailRepository {
return
}
val showAll = collection.showAllTab && folder.catalogSources.size > 1
val sources = folder.resolvedSources
val showAll = collection.showAllTab && sources.size > 1
val addons = AddonRepository.uiState.value.addons
val tabs = buildList {
if (showAll) {
add(FolderTab(label = "All", isAllTab = true, isLoading = true))
}
folder.catalogSources.forEach { source ->
val addon = addons.find { it.manifest?.id == source.addonId }
val catalog = addon?.manifest?.catalogs?.find {
it.id == source.catalogId && it.type == source.type
}
val label = catalog?.name ?: source.catalogId
val typeLabel = source.type.replaceFirstChar {
if (it.isLowerCase()) it.titlecase() else it.toString()
}
val genreSuffix = if (source.genre != null) " · ${source.genre}" else ""
add(
FolderTab(
label = "$label ($typeLabel)$genreSuffix",
typeLabel = typeLabel,
manifestUrl = addon?.manifestUrl,
type = source.type,
catalogId = source.catalogId,
genre = source.genre,
supportsPagination = catalog?.supportsPagination() == true,
label = runBlocking { getString(Res.string.collections_tab_all) },
isAllTab = true,
isLoading = true,
),
)
}
sources.forEach { source ->
if (source.isTmdb) {
val mediaType = TmdbCollectionMediaType.fromString(source.mediaType)
val type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie"
add(
FolderTab(
label = source.title?.takeIf { it.isNotBlank() } ?: "TMDB",
typeLabel = "TMDB",
source = source,
type = type,
catalogId = tmdbCatalogId(source),
supportsPagination = source.tmdbSourceType !in setOf(
TmdbCollectionSourceType.COLLECTION.name,
TmdbCollectionSourceType.PERSON.name,
TmdbCollectionSourceType.DIRECTOR.name,
),
isLoading = true,
),
)
} else if (source.isTrakt) {
val mediaType = TmdbCollectionMediaType.fromString(source.mediaType)
val type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie"
val typeLabel = if (mediaType == TmdbCollectionMediaType.TV) {
"Trakt Series List"
} else {
"Trakt Movie List"
}
add(
FolderTab(
label = source.title?.takeIf { it.isNotBlank() } ?: "Trakt",
typeLabel = typeLabel,
source = source,
type = type,
catalogId = traktCatalogId(source),
supportsPagination = true,
isLoading = true,
),
)
} else {
val catalogSource = source.addonCatalogSource() ?: return@forEach
val resolvedCatalog = addons.findCollectionCatalog(catalogSource)
val addon = resolvedCatalog?.addon
val catalog = resolvedCatalog?.catalog
val label = catalog?.name ?: catalogSource.catalogId
val typeLabel = localizedMediaTypeLabel(catalogSource.type)
val genreSuffix = if (catalogSource.genre != null) " · ${catalogSource.genre}" else ""
add(
FolderTab(
label = "$label ($typeLabel)$genreSuffix",
typeLabel = typeLabel,
source = source,
manifestUrl = addon?.manifestUrl,
type = catalogSource.type,
catalogId = catalogSource.catalogId,
genre = catalogSource.genre,
supportsPagination = catalog?.supportsPagination() == true,
isLoading = true,
),
)
}
}
}
_uiState.value = FolderDetailUiState(
@ -151,11 +208,19 @@ object FolderDetailRepository {
)
// Load catalog data for each source
folder.catalogSources.forEachIndexed { sourceIndex, source ->
sources.forEachIndexed { sourceIndex, source ->
val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex
val addon = addons.find { it.manifest?.id == source.addonId }
if (addon == null) {
updateTab(tabIndex) { it.copy(isLoading = false, error = "Addon not found: ${source.addonId}") }
val catalogSource = source.addonCatalogSource()
val resolvedCatalog = catalogSource?.let { addons.findCollectionCatalog(it) }
if (!source.isTmdb && !source.isTrakt && resolvedCatalog == null) {
updateTab(tabIndex) {
it.copy(
isLoading = false,
error = runBlocking {
getString(Res.string.collections_folder_addon_not_found, catalogSource?.addonId.orEmpty())
},
)
}
return@forEachIndexed
}
@ -163,7 +228,7 @@ object FolderDetailRepository {
}
// If no sources, mark as done
if (folder.catalogSources.isEmpty()) {
if (sources.isEmpty()) {
_uiState.value = _uiState.value.copy(isLoading = false)
}
}
@ -212,8 +277,13 @@ object FolderDetailRepository {
private fun loadTabPage(index: Int, reset: Boolean) {
val currentTab = _uiState.value.tabs.getOrNull(index) ?: return
val manifestUrl = currentTab.manifestUrl ?: return
val requestedSkip = if (reset) 0 else currentTab.nextSkip ?: return
val currentSource = currentTab.source
if (
currentSource?.isTmdb != true &&
currentSource?.isTrakt != true &&
currentTab.manifestUrl == null
) return
updateTab(index) { tab ->
if (reset) {
@ -235,13 +305,26 @@ object FolderDetailRepository {
loadJobs.remove(index)?.cancel()
val job = scope.launch {
runCatching {
fetchCatalogPage(
manifestUrl = manifestUrl,
type = currentTab.type,
catalogId = currentTab.catalogId,
genre = currentTab.genre,
skip = requestedSkip.takeIf { it > 0 },
)
val source = currentTab.source
when {
source?.isTmdb == true -> TmdbCollectionSourceResolver.resolve(
source = source,
page = if (reset) 1 else requestedSkip,
)
source?.isTrakt == true -> TraktPublicListSourceResolver.resolve(
source = source,
page = if (reset) 1 else requestedSkip,
)
else -> fetchCatalogPage(
manifestUrl = requireNotNull(currentTab.manifestUrl),
type = currentTab.type,
catalogId = currentTab.catalogId,
genre = currentTab.genre,
skip = requestedSkip.takeIf { it > 0 },
)
}.withUnreleasedFilter()
}.onSuccess { page ->
updateTab(index) { tab ->
val mergedItems = if (reset) {
@ -262,7 +345,7 @@ object FolderDetailRepository {
}
rebuildAllTab()
}.onFailure { error ->
log.e(error) { "Failed to load catalog ${currentTab.catalogId} from $manifestUrl" }
log.e(error) { "Failed to load source ${currentTab.catalogId}" }
updateTab(index) { tab ->
tab.copy(
isLoading = false,
@ -336,3 +419,33 @@ object FolderDetailRepository {
}
}
}
private fun Boolean?.orFalse(): Boolean = this == true
private fun CatalogPage.withUnreleasedFilter(): CatalogPage {
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate())
return if (filteredItems.size == items.size) this else copy(items = filteredItems)
}
private fun tmdbCatalogId(source: CollectionSource): String =
buildString {
append("tmdb_")
append(source.tmdbSourceType?.lowercase().orEmpty())
source.tmdbId?.let {
append("_")
append(it)
}
append("_")
append(source.mediaType?.lowercase().orEmpty())
}
private fun traktCatalogId(source: CollectionSource): String =
listOf(
"trakt",
"list",
source.traktListId?.toString().orEmpty(),
source.mediaType?.lowercase().orEmpty(),
TraktListSort.normalize(source.sortBy),
TraktSortHow.normalize(source.sortHow),
).joinToString("_")

View file

@ -54,6 +54,7 @@ import com.nuvio.app.core.ui.NuvioPosterCard
import com.nuvio.app.core.ui.NuvioPosterShape
import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
import com.nuvio.app.features.home.HomeCatalogSection
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.PosterShape
@ -63,6 +64,11 @@ import com.nuvio.app.features.home.components.HomeCatalogRowSection
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.collections_folder_empty_items
import nuvio.composeapp.generated.resources.collections_folder_not_found
import nuvio.composeapp.generated.resources.collections_tab_all
import org.jetbrains.compose.resources.stringResource
private val FolderCoverHeight = 176.dp
@ -143,7 +149,7 @@ fun FolderDetailScreen(
contentAlignment = Alignment.Center,
) {
Text(
text = "Folder not found",
text = stringResource(Res.string.collections_folder_not_found),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -229,7 +235,11 @@ private fun TabbedGridContent(
onClick = { onTabSelected(index) },
text = {
Text(
text = tab.label,
text = if (tab.isAllTab) {
stringResource(Res.string.collections_tab_all)
} else {
tab.label
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@ -266,9 +276,10 @@ private fun TabbedGridContent(
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
items(
items = selectedTab.items,
key = { item -> item.stableKey() },
) { item ->
items = selectedTab.items.withDuplicateSafeLazyKeys { item -> item.stableKey() },
key = { item -> item.lazyKey },
) { keyedItem ->
val item = keyedItem.value
NuvioPosterCard(
title = item.name,
imageUrl = item.poster,
@ -317,9 +328,10 @@ private fun RowsContent(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
items(
items = sections,
key = { it.key },
) { section ->
items = sections.withDuplicateSafeLazyKeys { it.key },
key = { it.lazyKey },
) { keyedSection ->
val section = keyedSection.value
HomeCatalogRowSection(
section = section,
entries = section.items.take(18),
@ -395,7 +407,7 @@ private fun EmptyMessage() {
contentAlignment = Alignment.Center,
) {
Text(
text = "No items found",
text = stringResource(Res.string.collections_folder_empty_items),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,

View file

@ -0,0 +1,691 @@
package com.nuvio.app.features.collection
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.catalog.CatalogPage
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.tmdb.buildTmdbUrl
import com.nuvio.app.features.tmdb.normalizeTmdbLanguage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.math.roundToInt
object TmdbCollectionSourceResolver {
private val log = Logger.withTag("TmdbCollectionSource")
private val json = Json { ignoreUnknownKeys = true }
suspend fun resolve(source: CollectionSource, page: Int = 1): CatalogPage = withContext(Dispatchers.Default) {
val settings = TmdbSettingsRepository.snapshot()
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
?: error("Add a TMDB API key in Settings to use TMDB sources.")
val language = normalizeTmdbLanguage(settings.language)
val sourceType = source.tmdbType()
when (sourceType) {
TmdbCollectionSourceType.LIST -> resolveList(source, apiKey, language, page)
TmdbCollectionSourceType.COLLECTION -> resolveCollection(source, apiKey, language)
TmdbCollectionSourceType.PERSON,
TmdbCollectionSourceType.DIRECTOR -> resolvePersonCredits(source, apiKey, language)
TmdbCollectionSourceType.COMPANY,
TmdbCollectionSourceType.NETWORK,
TmdbCollectionSourceType.DISCOVER -> resolveDiscover(source, apiKey, language, page)
}
}
suspend fun importMetadata(sourceType: TmdbCollectionSourceType, id: Int): TmdbSourceImportMetadata =
withContext(Dispatchers.Default) {
val settings = TmdbSettingsRepository.snapshot()
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
?: error("Add a TMDB API key in Settings to use TMDB sources.")
val language = normalizeTmdbLanguage(settings.language)
when (sourceType) {
TmdbCollectionSourceType.LIST -> {
val body = fetch<TmdbListResponse>(
endpoint = "list/$id",
apiKey = apiKey,
query = mapOf("language" to language, "page" to "1"),
) ?: error("TMDB list not found")
TmdbSourceImportMetadata(title = body.name?.takeIf { it.isNotBlank() })
}
TmdbCollectionSourceType.COLLECTION -> {
val body = fetch<TmdbCollectionResponse>(
endpoint = "collection/$id",
apiKey = apiKey,
query = mapOf("language" to language),
) ?: error("TMDB collection not found")
TmdbSourceImportMetadata(
title = body.name?.takeIf { it.isNotBlank() },
coverImageUrl = imageUrl(body.posterPath, "w500") ?: imageUrl(body.backdropPath, "w1280"),
)
}
TmdbCollectionSourceType.COMPANY -> {
val body = fetch<TmdbCompanyResponse>(
endpoint = "company/$id",
apiKey = apiKey,
) ?: error("TMDB company not found")
TmdbSourceImportMetadata(
title = body.name?.takeIf { it.isNotBlank() },
coverImageUrl = imageUrl(body.logoPath, "w500"),
)
}
TmdbCollectionSourceType.NETWORK -> {
val body = fetch<TmdbNetworkResponse>(
endpoint = "network/$id",
apiKey = apiKey,
) ?: error("TMDB network not found")
TmdbSourceImportMetadata(
title = body.name?.takeIf { it.isNotBlank() },
coverImageUrl = imageUrl(body.logoPath, "w500"),
)
}
TmdbCollectionSourceType.PERSON,
TmdbCollectionSourceType.DIRECTOR -> {
val body = fetch<TmdbPersonResponse>(
endpoint = "person/$id",
apiKey = apiKey,
query = mapOf("language" to language),
) ?: error("TMDB person not found")
TmdbSourceImportMetadata(
title = body.name?.takeIf { it.isNotBlank() },
coverImageUrl = imageUrl(body.profilePath, "w500"),
)
}
TmdbCollectionSourceType.DISCOVER -> TmdbSourceImportMetadata(title = "TMDB Discover")
}
}
suspend fun searchCompanies(query: String): List<TmdbCompanySearchResult> = withContext(Dispatchers.Default) {
val trimmed = query.trim()
if (trimmed.isBlank()) return@withContext emptyList()
val settings = TmdbSettingsRepository.snapshot()
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
?: error("Add a TMDB API key in Settings to use TMDB sources.")
fetch<TmdbCompanySearchResponse>(
endpoint = "search/company",
apiKey = apiKey,
query = mapOf("query" to trimmed),
)?.results.orEmpty()
}
suspend fun searchCollections(query: String): List<TmdbCollectionSearchResult> = withContext(Dispatchers.Default) {
val trimmed = query.trim()
if (trimmed.isBlank()) return@withContext emptyList()
val settings = TmdbSettingsRepository.snapshot()
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
?: error("Add a TMDB API key in Settings to use TMDB sources.")
val language = normalizeTmdbLanguage(settings.language)
fetch<TmdbCollectionSearchResponse>(
endpoint = "search/collection",
apiKey = apiKey,
query = mapOf("query" to trimmed, "language" to language),
)?.results.orEmpty()
}
suspend fun searchKeywords(query: String): Map<Int, String> = withContext(Dispatchers.Default) {
val trimmed = query.trim()
if (trimmed.isBlank()) return@withContext emptyMap()
val settings = TmdbSettingsRepository.snapshot()
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
?: error("Add a TMDB API key in Settings to use TMDB sources.")
fetch<TmdbKeywordSearchResponse>(
endpoint = "search/keyword",
apiKey = apiKey,
query = mapOf("query" to trimmed),
)?.results.orEmpty()
.mapNotNull { result ->
val name = result.name?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
result.id to name
}
.toMap()
}
suspend fun genres(mediaType: TmdbCollectionMediaType): Map<Int, String> = withContext(Dispatchers.Default) {
val settings = TmdbSettingsRepository.snapshot()
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
?: error("Add a TMDB API key in Settings to use TMDB sources.")
val language = normalizeTmdbLanguage(settings.language)
val endpoint = when (mediaType) {
TmdbCollectionMediaType.MOVIE -> "genre/movie/list"
TmdbCollectionMediaType.TV -> "genre/tv/list"
}
fetch<TmdbGenreResponse>(
endpoint = endpoint,
apiKey = apiKey,
query = mapOf("language" to language),
)?.genres.orEmpty().associate { it.id to it.name }
}
fun parseTmdbId(input: String): Int? {
val trimmed = input.trim()
trimmed.toIntOrNull()?.let { return it }
return Regex("""(?:list|collection|company|network|person)/(\d+)""")
.find(trimmed)
?.groupValues
?.getOrNull(1)
?.toIntOrNull()
?: Regex("""[?&]id=(\d+)""")
.find(trimmed)
?.groupValues
?.getOrNull(1)
?.toIntOrNull()
}
fun presets(): List<TmdbPresetSource> = listOf(
TmdbPresetSource("Marvel Studios", company("Marvel Studios", 420)),
TmdbPresetSource("Walt Disney Pictures", company("Walt Disney Pictures", 2)),
TmdbPresetSource("Pixar", company("Pixar", 3)),
TmdbPresetSource("Lucasfilm", company("Lucasfilm", 1)),
TmdbPresetSource("Warner Bros.", company("Warner Bros.", 174)),
TmdbPresetSource("Netflix", network("Netflix", 213)),
TmdbPresetSource("HBO", network("HBO", 49)),
TmdbPresetSource("Disney+", network("Disney+", 2739)),
TmdbPresetSource("Prime Video", network("Prime Video", 1024)),
TmdbPresetSource("Hulu", network("Hulu", 453)),
TmdbPresetSource("Apple TV+", network("Apple TV+", 2552)),
)
private suspend fun resolveList(
source: CollectionSource,
apiKey: String,
language: String,
page: Int,
): CatalogPage {
val id = source.tmdbId ?: error("Missing TMDB list ID")
val body = fetch<TmdbListResponse>(
endpoint = "list/$id",
apiKey = apiKey,
query = mapOf("language" to language, "page" to page.toString()),
) ?: error("TMDB list not found")
val items = body.items.orEmpty()
.mapNotNull { it.toPreview() }
.sortedFor(source.sortBy)
.distinctBy { "${it.type}:${it.id}" }
return CatalogPage(
items = items,
rawItemCount = items.size,
nextSkip = if ((body.page ?: page) < (body.totalPages ?: page) && items.isNotEmpty()) page + 1 else null,
)
}
private suspend fun resolveCollection(
source: CollectionSource,
apiKey: String,
language: String,
): CatalogPage {
val id = source.tmdbId ?: error("Missing TMDB collection ID")
val body = fetch<TmdbCollectionResponse>(
endpoint = "collection/$id",
apiKey = apiKey,
query = mapOf("language" to language),
) ?: error("TMDB collection not found")
val items = body.parts.orEmpty()
.mapNotNull { it.toPreview(TmdbCollectionMediaType.MOVIE) }
.sortedFor(source.sortBy)
.distinctBy { it.id }
return CatalogPage(items = items, rawItemCount = items.size, nextSkip = null)
}
private suspend fun resolvePersonCredits(
source: CollectionSource,
apiKey: String,
language: String,
): CatalogPage {
val id = source.tmdbId ?: error("Missing TMDB person ID")
val mediaType = source.tmdbMediaType()
val body = fetch<TmdbPersonCreditsResponse>(
endpoint = "person/$id/combined_credits",
apiKey = apiKey,
query = mapOf("language" to language),
) ?: error("TMDB person credits not found")
val items = when (source.tmdbType()) {
TmdbCollectionSourceType.DIRECTOR -> body.crew.orEmpty()
.filter { it.job.equals("Director", ignoreCase = true) }
.mapNotNull { it.toPreview(mediaType) }
else -> body.cast.orEmpty().mapNotNull { it.toPreview(mediaType) }
}
.distinctBy { "${it.type}:${it.id}" }
.sortedFor(source.sortBy)
return CatalogPage(items = items, rawItemCount = items.size, nextSkip = null)
}
private suspend fun resolveDiscover(
source: CollectionSource,
apiKey: String,
language: String,
page: Int,
): CatalogPage {
val sourceType = source.tmdbType()
val mediaType = if (sourceType == TmdbCollectionSourceType.NETWORK) {
TmdbCollectionMediaType.TV
} else {
source.tmdbMediaType()
}
val filters = source.filters ?: TmdbCollectionFilters()
val query = buildDiscoverQuery(
source = source,
sourceType = sourceType,
mediaType = mediaType,
language = language,
page = page,
filters = filters,
)
val endpoint = when (mediaType) {
TmdbCollectionMediaType.MOVIE -> "discover/movie"
TmdbCollectionMediaType.TV -> "discover/tv"
}
val body = fetch<TmdbDiscoverResponse>(
endpoint = endpoint,
apiKey = apiKey,
query = query,
) ?: error("TMDB discover returned no data")
val items = body.results.orEmpty()
.mapNotNull { it.toPreview(mediaType) }
.distinctBy { it.id }
return CatalogPage(
items = items,
rawItemCount = items.size,
nextSkip = if ((body.page ?: page) < (body.totalPages ?: page) && items.isNotEmpty()) page + 1 else null,
)
}
private fun buildDiscoverQuery(
source: CollectionSource,
sourceType: TmdbCollectionSourceType,
mediaType: TmdbCollectionMediaType,
language: String,
page: Int,
filters: TmdbCollectionFilters,
): Map<String, String> {
val sortBy = when (mediaType) {
TmdbCollectionMediaType.MOVIE -> movieSort(source.sortBy)
TmdbCollectionMediaType.TV -> tvSort(source.sortBy)
}
return buildMap {
put("language", language)
put("page", page.toString())
put("sort_by", sortBy)
val companyId = source.tmdbId?.toString().takeIf { sourceType == TmdbCollectionSourceType.COMPANY }
val networkId = source.tmdbId?.toString().takeIf { sourceType == TmdbCollectionSourceType.NETWORK }
putIfNotBlank("with_companies", companyId ?: filters.withCompanies)
putIfNotBlank("with_networks", networkId ?: filters.withNetworks)
putIfNotBlank("with_genres", filters.withGenres)
putIfNotBlank("vote_count.gte", filters.voteCountGte?.toString())
putIfNotBlank("vote_average.gte", filters.voteAverageGte?.toString())
putIfNotBlank("vote_average.lte", filters.voteAverageLte?.toString())
putIfNotBlank("with_original_language", filters.withOriginalLanguage)
putIfNotBlank("with_origin_country", filters.withOriginCountry)
putIfNotBlank("with_keywords", filters.withKeywords)
putIfNotBlank("year", filters.year?.takeIf { mediaType == TmdbCollectionMediaType.MOVIE }?.toString())
putIfNotBlank("first_air_date_year", filters.year?.takeIf { mediaType == TmdbCollectionMediaType.TV }?.toString())
putIfNotBlank(
if (mediaType == TmdbCollectionMediaType.MOVIE) "primary_release_date.gte" else "first_air_date.gte",
filters.releaseDateGte,
)
putIfNotBlank(
if (mediaType == TmdbCollectionMediaType.MOVIE) "primary_release_date.lte" else "first_air_date.lte",
filters.releaseDateLte,
)
}
}
private suspend inline fun <reified T> fetch(
endpoint: String,
apiKey: String,
query: Map<String, String> = emptyMap(),
): T? {
val url = buildTmdbUrl(endpoint = endpoint, apiKey = apiKey, query = query)
return runCatching {
json.decodeFromString<T>(httpGetText(url))
}.onFailure { error ->
log.w(error) { "TMDB source request failed for $endpoint" }
}.getOrNull()
}
private fun List<MetaPreview>.sortedFor(sortBy: String?): List<MetaPreview> =
when (sortBy) {
TmdbCollectionSort.ORIGINAL.value -> this
TmdbCollectionSort.VOTE_AVERAGE_DESC.value -> sortedWith(
compareByDescending<MetaPreview> { it.imdbRating?.toDoubleOrNull() ?: -1.0 }
.thenByDescending { it.rawReleaseDate ?: it.releaseInfo.orEmpty() },
)
TmdbCollectionSort.RELEASE_DATE_DESC.value,
TmdbCollectionSort.FIRST_AIR_DATE_DESC.value -> sortedByDescending { it.rawReleaseDate ?: it.releaseInfo.orEmpty() }
TmdbCollectionSort.POPULAR_DESC.value,
null,
"" -> this
else -> this
}
private fun TmdbListItem.toPreview(): MetaPreview? {
val media = mediaType?.lowercase()
val contentType = if (media == "tv") TmdbCollectionMediaType.TV else TmdbCollectionMediaType.MOVIE
return toPreview(contentType)
}
private fun TmdbListItem.toPreview(mediaType: TmdbCollectionMediaType): MetaPreview? {
val title = title?.takeIf { it.isNotBlank() }
?: name?.takeIf { it.isNotBlank() }
?: originalTitle?.takeIf { it.isNotBlank() }
?: originalName?.takeIf { it.isNotBlank() }
?: return null
return MetaPreview(
id = "tmdb:$id",
type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie",
name = title,
poster = imageUrl(posterPath, "w500") ?: imageUrl(backdropPath, "w780"),
banner = imageUrl(backdropPath, "w1280"),
posterShape = PosterShape.Poster,
description = overview?.takeIf { it.isNotBlank() },
releaseInfo = when (mediaType) {
TmdbCollectionMediaType.MOVIE -> releaseDate?.take(4)
TmdbCollectionMediaType.TV -> firstAirDate?.take(4)
},
rawReleaseDate = when (mediaType) {
TmdbCollectionMediaType.MOVIE -> releaseDate
TmdbCollectionMediaType.TV -> firstAirDate
},
popularity = popularity,
imdbRating = voteAverage?.let { ((it * 10).roundToInt() / 10.0).toString() },
)
}
private fun TmdbCollectionPart.toPreview(mediaType: TmdbCollectionMediaType): MetaPreview? {
val title = title?.takeIf { it.isNotBlank() } ?: return null
return MetaPreview(
id = "tmdb:$id",
type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie",
name = title,
poster = imageUrl(posterPath, "w500") ?: imageUrl(backdropPath, "w780"),
banner = imageUrl(backdropPath, "w1280"),
posterShape = PosterShape.Poster,
description = overview?.takeIf { it.isNotBlank() },
releaseInfo = releaseDate?.take(4),
rawReleaseDate = releaseDate,
popularity = popularity,
imdbRating = voteAverage?.let { ((it * 10).roundToInt() / 10.0).toString() },
)
}
private fun TmdbPersonCreditCast.toPreview(mediaType: TmdbCollectionMediaType): MetaPreview? {
if (!matchesMediaType(mediaType, this.mediaType)) return null
val title = title?.takeIf { it.isNotBlank() }
?: name?.takeIf { it.isNotBlank() }
?: originalTitle?.takeIf { it.isNotBlank() }
?: originalName?.takeIf { it.isNotBlank() }
?: return null
return MetaPreview(
id = "tmdb:$id",
type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie",
name = title,
poster = imageUrl(posterPath, "w500") ?: imageUrl(backdropPath, "w780"),
banner = imageUrl(backdropPath, "w1280"),
posterShape = PosterShape.Poster,
description = overview?.takeIf { it.isNotBlank() },
releaseInfo = when (mediaType) {
TmdbCollectionMediaType.MOVIE -> releaseDate?.take(4)
TmdbCollectionMediaType.TV -> firstAirDate?.take(4)
},
rawReleaseDate = when (mediaType) {
TmdbCollectionMediaType.MOVIE -> releaseDate
TmdbCollectionMediaType.TV -> firstAirDate
},
popularity = popularity,
imdbRating = voteAverage?.let { ((it * 10).roundToInt() / 10.0).toString() },
)
}
private fun TmdbPersonCreditCrew.toPreview(mediaType: TmdbCollectionMediaType): MetaPreview? {
if (!matchesMediaType(mediaType, this.mediaType)) return null
val title = title?.takeIf { it.isNotBlank() }
?: name?.takeIf { it.isNotBlank() }
?: originalTitle?.takeIf { it.isNotBlank() }
?: originalName?.takeIf { it.isNotBlank() }
?: return null
return MetaPreview(
id = "tmdb:$id",
type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie",
name = title,
poster = imageUrl(posterPath, "w500") ?: imageUrl(backdropPath, "w780"),
banner = imageUrl(backdropPath, "w1280"),
posterShape = PosterShape.Poster,
description = overview?.takeIf { it.isNotBlank() },
releaseInfo = when (mediaType) {
TmdbCollectionMediaType.MOVIE -> releaseDate?.take(4)
TmdbCollectionMediaType.TV -> firstAirDate?.take(4)
},
rawReleaseDate = when (mediaType) {
TmdbCollectionMediaType.MOVIE -> releaseDate
TmdbCollectionMediaType.TV -> firstAirDate
},
popularity = popularity,
imdbRating = voteAverage?.let { ((it * 10).roundToInt() / 10.0).toString() },
)
}
private fun CollectionSource.tmdbType(): TmdbCollectionSourceType =
tmdbSourceType
?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() }
?: TmdbCollectionSourceType.DISCOVER
private fun CollectionSource.tmdbMediaType(): TmdbCollectionMediaType =
TmdbCollectionMediaType.fromString(mediaType)
private fun matchesMediaType(expected: TmdbCollectionMediaType, actual: String?): Boolean =
when (expected) {
TmdbCollectionMediaType.MOVIE -> actual == "movie"
TmdbCollectionMediaType.TV -> actual == "tv"
}
private fun company(title: String, id: Int) = CollectionSource(
provider = "tmdb",
tmdbSourceType = TmdbCollectionSourceType.COMPANY.name,
title = title,
tmdbId = id,
mediaType = TmdbCollectionMediaType.MOVIE.name,
sortBy = TmdbCollectionSort.POPULAR_DESC.value,
)
private fun network(title: String, id: Int) = CollectionSource(
provider = "tmdb",
tmdbSourceType = TmdbCollectionSourceType.NETWORK.name,
title = title,
tmdbId = id,
mediaType = TmdbCollectionMediaType.TV.name,
sortBy = TmdbCollectionSort.POPULAR_DESC.value,
)
private fun movieSort(sortBy: String?): String =
when (sortBy) {
TmdbCollectionSort.FIRST_AIR_DATE_DESC.value -> TmdbCollectionSort.RELEASE_DATE_DESC.value
TmdbCollectionSort.ORIGINAL.value -> TmdbCollectionSort.POPULAR_DESC.value
null, "" -> TmdbCollectionSort.POPULAR_DESC.value
else -> sortBy
}
private fun tvSort(sortBy: String?): String =
when (sortBy) {
TmdbCollectionSort.RELEASE_DATE_DESC.value -> TmdbCollectionSort.FIRST_AIR_DATE_DESC.value
TmdbCollectionSort.ORIGINAL.value -> TmdbCollectionSort.POPULAR_DESC.value
null, "" -> TmdbCollectionSort.POPULAR_DESC.value
else -> sortBy
}
}
private fun MutableMap<String, String>.putIfNotBlank(key: String, value: String?) {
if (!value.isNullOrBlank()) {
put(key, value)
}
}
private fun imageUrl(path: String?, size: String): String? {
val clean = path?.takeIf { it.isNotBlank() } ?: return null
return "https://image.tmdb.org/t/p/$size$clean"
}
@Serializable
private data class TmdbListResponse(
val name: String? = null,
val page: Int? = null,
@SerialName("total_pages") val totalPages: Int? = null,
val items: List<TmdbListItem>? = null,
)
@Serializable
private data class TmdbCollectionResponse(
val name: String? = null,
@SerialName("poster_path") val posterPath: String? = null,
@SerialName("backdrop_path") val backdropPath: String? = null,
val parts: List<TmdbCollectionPart>? = null,
)
@Serializable
private data class TmdbDiscoverResponse(
val page: Int? = null,
@SerialName("total_pages") val totalPages: Int? = null,
val results: List<TmdbListItem>? = null,
)
@Serializable
private data class TmdbCompanyResponse(
val name: String? = null,
@SerialName("logo_path") val logoPath: String? = null,
)
@Serializable
private data class TmdbNetworkResponse(
val name: String? = null,
@SerialName("logo_path") val logoPath: String? = null,
)
@Serializable
private data class TmdbPersonResponse(
val name: String? = null,
@SerialName("profile_path") val profilePath: String? = null,
)
@Serializable
data class TmdbCompanySearchResult(
val id: Int,
val name: String? = null,
@SerialName("origin_country") val originCountry: String? = null,
)
@Serializable
private data class TmdbCompanySearchResponse(
val results: List<TmdbCompanySearchResult>? = null,
)
@Serializable
data class TmdbCollectionSearchResult(
val id: Int,
val name: String? = null,
@SerialName("poster_path") val posterPath: String? = null,
@SerialName("backdrop_path") val backdropPath: String? = null,
)
@Serializable
private data class TmdbCollectionSearchResponse(
val results: List<TmdbCollectionSearchResult>? = null,
)
@Serializable
private data class TmdbKeywordSearchResponse(
val results: List<TmdbKeywordSearchResult>? = null,
)
@Serializable
private data class TmdbKeywordSearchResult(
val id: Int,
val name: String? = null,
)
@Serializable
private data class TmdbGenreResponse(
val genres: List<TmdbGenreItem>? = null,
)
@Serializable
private data class TmdbGenreItem(
val id: Int,
val name: String,
)
@Serializable
private data class TmdbPersonCreditsResponse(
val cast: List<TmdbPersonCreditCast>? = null,
val crew: List<TmdbPersonCreditCrew>? = null,
)
@Serializable
private data class TmdbPersonCreditCast(
val id: Int,
@SerialName("media_type") val mediaType: String? = null,
val title: String? = null,
val name: String? = null,
@SerialName("original_title") val originalTitle: String? = null,
@SerialName("original_name") val originalName: String? = null,
val overview: String? = null,
@SerialName("poster_path") val posterPath: String? = null,
@SerialName("backdrop_path") val backdropPath: String? = null,
@SerialName("release_date") val releaseDate: String? = null,
@SerialName("first_air_date") val firstAirDate: String? = null,
@SerialName("vote_average") val voteAverage: Double? = null,
val popularity: Double? = null,
)
@Serializable
private data class TmdbPersonCreditCrew(
val id: Int,
@SerialName("media_type") val mediaType: String? = null,
val title: String? = null,
val name: String? = null,
@SerialName("original_title") val originalTitle: String? = null,
@SerialName("original_name") val originalName: String? = null,
val overview: String? = null,
@SerialName("poster_path") val posterPath: String? = null,
@SerialName("backdrop_path") val backdropPath: String? = null,
@SerialName("release_date") val releaseDate: String? = null,
@SerialName("first_air_date") val firstAirDate: String? = null,
val job: String? = null,
@SerialName("vote_average") val voteAverage: Double? = null,
val popularity: Double? = null,
)
@Serializable
private data class TmdbListItem(
val id: Int,
@SerialName("media_type") val mediaType: String? = null,
val title: String? = null,
val name: String? = null,
@SerialName("original_title") val originalTitle: String? = null,
@SerialName("original_name") val originalName: String? = null,
val overview: String? = null,
@SerialName("poster_path") val posterPath: String? = null,
@SerialName("backdrop_path") val backdropPath: String? = null,
@SerialName("release_date") val releaseDate: String? = null,
@SerialName("first_air_date") val firstAirDate: String? = null,
@SerialName("vote_average") val voteAverage: Double? = null,
val popularity: Double? = null,
)
@Serializable
private data class TmdbCollectionPart(
val id: Int,
val title: String? = null,
val overview: String? = null,
@SerialName("poster_path") val posterPath: String? = null,
@SerialName("backdrop_path") val backdropPath: String? = null,
@SerialName("release_date") val releaseDate: String? = null,
@SerialName("vote_average") val voteAverage: Double? = null,
val popularity: Double? = null,
)

View file

@ -0,0 +1,112 @@
package com.nuvio.app.features.details
import co.touchlab.kermit.Logger
import com.nuvio.app.features.library.LibraryClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
object ImdbEpisodeRatingsRepository {
private data class CacheEntry(
val ratings: Map<Pair<Int, Int>, Double>,
val expiresAtMs: Long,
)
private val log = Logger.withTag("ImdbEpisodeRatingsRepo")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val mutex = Mutex()
private val cache = mutableMapOf<String, CacheEntry>()
private val inFlight = mutableMapOf<String, Deferred<Map<Pair<Int, Int>, Double>>>()
suspend fun getEpisodeRatings(
imdbId: String?,
tmdbId: Int?,
): Map<Pair<Int, Int>, Double> {
val normalizedImdbId = normalizeImdbId(imdbId)
val normalizedTmdbId = tmdbId?.takeIf { it > 0 }
if (normalizedImdbId == null && normalizedTmdbId == null) return emptyMap()
val cacheKey = normalizedImdbId?.let { "imdb:$it" } ?: "tmdb:$normalizedTmdbId"
val now = currentTimeMs()
mutex.withLock {
cache[cacheKey]?.let { cached ->
if (cached.expiresAtMs > now) return cached.ratings
cache.remove(cacheKey)
}
}
val deferred = mutex.withLock {
inFlight[cacheKey] ?: scope.async {
try {
fetchEpisodeRatings(
imdbId = normalizedImdbId,
tmdbId = normalizedTmdbId,
).also { ratings ->
mutex.withLock {
cache[cacheKey] = CacheEntry(
ratings = ratings,
expiresAtMs = currentTimeMs() + CACHE_TTL_MS,
)
}
}
} finally {
mutex.withLock {
inFlight.remove(cacheKey)
}
}
}.also { created ->
inFlight[cacheKey] = created
}
}
return deferred.await()
}
fun clearCache() {
cache.clear()
inFlight.clear()
}
private suspend fun fetchEpisodeRatings(
imdbId: String?,
tmdbId: Int?,
): Map<Pair<Int, Int>, Double> {
if (!imdbId.isNullOrBlank()) {
val primary = toRatingsMap(ImdbTapframeApi.getSeasonRatings(imdbId))
if (primary.isNotEmpty()) return primary
log.w { "Primary episode ratings empty for imdbId=$imdbId, trying fallback" }
}
if (tmdbId != null) {
return toRatingsMap(SeriesGraphApi.getSeasonRatings(tmdbId))
}
return emptyMap()
}
private fun toRatingsMap(payload: List<SeriesGraphSeasonRatingsDto>): Map<Pair<Int, Int>, Double> =
buildMap {
payload.forEach { season ->
season.episodes.orEmpty().forEach { episode ->
val seasonNumber = episode.seasonNumber ?: return@forEach
val episodeNumber = episode.episodeNumber ?: return@forEach
val voteAverage = episode.voteAverage?.takeIf { it > 0.0 } ?: return@forEach
put(seasonNumber to episodeNumber, voteAverage)
}
}
}
private fun normalizeImdbId(value: String?): String? =
value
?.trim()
?.substringBefore(':')
?.takeIf { it.startsWith("tt", ignoreCase = true) }
private fun currentTimeMs(): Long = LibraryClock.nowEpochMs()
private const val CACHE_TTL_MS = 30L * 60L * 1000L
}

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.details
import com.nuvio.app.features.streams.StreamBehaviorHints
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamProxyHeaders
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
@ -13,6 +14,8 @@ import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.jsonPrimitive
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
internal object MetaDetailsParser {
private val json = Json { ignoreUnknownKeys = true }
@ -248,10 +251,10 @@ internal object MetaDetailsParser {
MetaTrailer(
id = trailer.string("id")?.takeIf(String::isNotBlank) ?: normalizedKey,
key = normalizedKey,
name = trailer.string("name")?.takeIf(String::isNotBlank) ?: "Trailer",
name = trailer.string("name")?.takeIf(String::isNotBlank) ?: runBlocking { getString(Res.string.generic_trailer) },
site = trailer.string("site")?.takeIf(String::isNotBlank) ?: "YouTube",
size = trailer.int("size"),
type = trailer.string("type")?.takeIf(String::isNotBlank) ?: "Trailer",
type = trailer.string("type")?.takeIf(String::isNotBlank) ?: runBlocking { getString(Res.string.generic_trailer) },
official = trailer.boolean("official") == true,
publishedAt = trailer.string("published_at") ?: trailer.string("publishedAt"),
seasonNumber = trailer.int("seasonNumber") ?: trailer.int("season_number"),
@ -273,7 +276,9 @@ internal object MetaDetailsParser {
?.objectValue("proxyHeaders")
?.toProxyHeaders()
val streamData = obj["streamData"] as? JsonObject
val addonName = streamData?.string("addon") ?: obj.string("name") ?: "Embedded"
val addonName = streamData?.string("addon")
?: obj.string("name")
?: runBlocking { getString(Res.string.source_embedded) }
StreamItem(
name = obj.string("name"),
description = obj.string("description") ?: obj.string("title"),

View file

@ -5,10 +5,14 @@ import com.nuvio.app.features.addons.AddonManifest
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.filterReleasedItems
import com.nuvio.app.features.mdblist.MdbListMetadataService
import com.nuvio.app.features.mdblist.MdbListSettingsRepository
import com.nuvio.app.features.tmdb.TmdbMetadataService
import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -19,6 +23,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
object MetaDetailsRepository {
private data class CachedMetaEntry(
@ -45,14 +51,14 @@ object MetaDetailsRepository {
cachedEntry.metaScreenMeta
?.takeIf { cachedEntry.metaScreenSettingsFingerprint == metaScreenSettingsFingerprint }
?.let { cachedMeta ->
_uiState.value = MetaDetailsUiState(meta = cachedMeta)
_uiState.value = MetaDetailsUiState(meta = cachedMeta.withUnreleasedFilter())
activeRequestKey = requestKey
return
}
val cachedBaseMeta = cachedEntry.baseMeta
if (!shouldFetchMdbListOnMetaScreen(cachedBaseMeta, id, mdbListSettings)) {
_uiState.value = MetaDetailsUiState(meta = cachedBaseMeta)
_uiState.value = MetaDetailsUiState(meta = cachedBaseMeta.withUnreleasedFilter())
activeRequestKey = requestKey
return
}
@ -78,7 +84,7 @@ object MetaDetailsRepository {
settingsFingerprint = metaScreenSettingsFingerprint,
)
}
_uiState.value = MetaDetailsUiState(meta = enrichedMeta)
_uiState.value = MetaDetailsUiState(meta = enrichedMeta.withUnreleasedFilter())
activeRequestKey = requestKey
}
return
@ -99,20 +105,25 @@ object MetaDetailsRepository {
_uiState.value = MetaDetailsUiState(isLoading = true)
scope.launch {
val manifests = AddonRepository.uiState.value.addons
.mapNotNull { it.manifest }
.filter { manifest ->
manifest.resources.any { resource ->
resource.name == "meta" &&
resource.types.contains(type) &&
(resource.idPrefixes.isEmpty() || resource.idPrefixes.any { id.startsWith(it) })
}
}
val metaLookupId = resolveMetaLookupId(itemId = id, itemType = type)
val manifests = findMetaManifests(type = type, id = metaLookupId)
if (manifests.isEmpty()) {
val tmdbMeta = tryFetchTmdbFallbackMeta(type = type, id = id)
if (tmdbMeta != null) {
publishLoadedMeta(
requestKey = requestKey,
meta = tmdbMeta,
fallbackItemId = id,
mdbListSettings = mdbListSettings,
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
)
return@launch
}
log.w { "No addon provides meta for type=$type id=$id" }
_uiState.value = MetaDetailsUiState(
errorMessage = "No addon provides meta for this content.",
errorMessage = getString(Res.string.details_no_addon_meta),
)
activeRequestKey = null
return@launch
@ -120,44 +131,34 @@ object MetaDetailsRepository {
for (manifest in manifests) {
val result = withContext(Dispatchers.Default) {
tryFetchMeta(manifest, type, id, includeMdbList = false)
tryFetchMeta(manifest, type, metaLookupId, includeMdbList = false)
}
if (result != null) {
var cachedEntry = CachedMetaEntry(baseMeta = result)
cachedMetaByRequestKey[requestKey] = cachedEntry
if (!shouldFetchMdbListOnMetaScreen(result, id, mdbListSettings)) {
_uiState.value = MetaDetailsUiState(meta = result)
activeRequestKey = requestKey
return@launch
}
_uiState.value = MetaDetailsUiState(
isLoading = true,
publishLoadedMeta(
requestKey = requestKey,
meta = result,
)
val enrichedMeta = withContext(Dispatchers.Default) {
enrichForMetaScreen(
requestKey = requestKey,
meta = result,
fallbackItemId = id,
settings = mdbListSettings,
settingsFingerprint = metaScreenSettingsFingerprint,
)
}
cachedEntry = cachedEntry.copy(
metaScreenMeta = enrichedMeta,
fallbackItemId = metaLookupId,
mdbListSettings = mdbListSettings,
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
)
cachedMetaByRequestKey[requestKey] = cachedEntry
_uiState.value = MetaDetailsUiState(meta = enrichedMeta)
activeRequestKey = requestKey
return@launch
}
}
val tmdbMeta = tryFetchTmdbFallbackMeta(type = type, id = id)
if (tmdbMeta != null) {
publishLoadedMeta(
requestKey = requestKey,
meta = tmdbMeta,
fallbackItemId = id,
mdbListSettings = mdbListSettings,
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
)
return@launch
}
_uiState.value = MetaDetailsUiState(
errorMessage = "Could not load details from any addon.",
errorMessage = getString(Res.string.details_load_failed_all_addons),
)
activeRequestKey = null
}
@ -185,19 +186,12 @@ object MetaDetailsRepository {
val requestKey = "$type:$id"
cachedMetaByRequestKey[requestKey]?.let { return it.baseMeta }
val manifests = AddonRepository.uiState.value.addons
.mapNotNull { it.manifest }
.filter { manifest ->
manifest.resources.any { resource ->
resource.name == "meta" &&
resource.types.contains(type) &&
(resource.idPrefixes.isEmpty() || resource.idPrefixes.any { id.startsWith(it) })
}
}
val metaLookupId = resolveMetaLookupId(itemId = id, itemType = type)
val manifests = findMetaManifests(type = type, id = metaLookupId)
for (manifest in manifests) {
val result = withTimeoutOrNull(FETCH_TIMEOUT_MS) {
tryFetchMeta(manifest, type, id, includeMdbList = false)
tryFetchMeta(manifest, type, metaLookupId, includeMdbList = false)
}
if (result != null) {
cachedMetaByRequestKey[requestKey] = CachedMetaEntry(baseMeta = result)
@ -205,7 +199,9 @@ object MetaDetailsRepository {
}
}
return null
return tryFetchTmdbFallbackMeta(type = type, id = id)?.also { result ->
cachedMetaByRequestKey[requestKey] = CachedMetaEntry(baseMeta = result)
}
}
private const val FETCH_TIMEOUT_MS = 5_000L
@ -263,6 +259,78 @@ object MetaDetailsRepository {
}
}
private fun findMetaManifests(type: String, id: String): List<AddonManifest> =
AddonRepository.uiState.value.addons
.mapNotNull { it.manifest }
.filter { manifest ->
manifest.resources.any { resource ->
resource.name == "meta" &&
resource.types.contains(type) &&
(resource.idPrefixes.isEmpty() || resource.idPrefixes.any { id.startsWith(it) })
}
}
private suspend fun resolveMetaLookupId(itemId: String, itemType: String): String {
val tmdbId = itemId
.takeIf { it.startsWith("tmdb:", ignoreCase = true) }
?.substringAfter(':')
?.substringBefore(':')
?.toIntOrNull()
?: return itemId
return withTimeoutOrNull(FETCH_TIMEOUT_MS) {
TmdbService.tmdbToImdb(tmdbId = tmdbId, mediaType = itemType)
}
?.takeIf { it.isNotBlank() }
?: itemId
}
private suspend fun tryFetchTmdbFallbackMeta(type: String, id: String): MetaDetails? =
withTimeoutOrNull(TMDB_ENRICH_TIMEOUT_MS) {
TmdbMetadataService.fetchStandaloneMeta(
type = type,
id = id,
settings = TmdbSettingsRepository.snapshot(),
)
}
private suspend fun publishLoadedMeta(
requestKey: String,
meta: MetaDetails,
fallbackItemId: String,
mdbListSettings: com.nuvio.app.features.mdblist.MdbListSettings,
metaScreenSettingsFingerprint: String,
) {
val cachedEntry = CachedMetaEntry(baseMeta = meta)
cachedMetaByRequestKey[requestKey] = cachedEntry
if (!shouldFetchMdbListOnMetaScreen(meta, fallbackItemId, mdbListSettings)) {
_uiState.value = MetaDetailsUiState(meta = meta.withUnreleasedFilter())
activeRequestKey = requestKey
return
}
_uiState.value = MetaDetailsUiState(
isLoading = true,
meta = meta,
)
val enrichedMeta = withContext(Dispatchers.Default) {
enrichForMetaScreen(
requestKey = requestKey,
meta = meta,
fallbackItemId = fallbackItemId,
settings = mdbListSettings,
settingsFingerprint = metaScreenSettingsFingerprint,
)
}
cachedMetaByRequestKey[requestKey] = cachedEntry.copy(
metaScreenMeta = enrichedMeta,
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
)
_uiState.value = MetaDetailsUiState(meta = enrichedMeta.withUnreleasedFilter())
activeRequestKey = requestKey
}
private suspend fun enrichForMetaScreen(
requestKey: String,
meta: MetaDetails,
@ -309,6 +377,15 @@ object MetaDetailsRepository {
return "${settings.enabled}:${settings.apiKey.trim()}:$providers"
}
private fun MetaDetails.withUnreleasedFilter(): MetaDetails {
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
val todayIsoDate = CurrentDateProvider.todayIsoDate()
return copy(
moreLikeThis = moreLikeThis.filterReleasedItems(todayIsoDate),
collectionItems = collectionItems.filterReleasedItems(todayIsoDate),
)
}
fun findEmbeddedStreams(videoId: String): List<com.nuvio.app.features.streams.StreamItem> {
val meta = _uiState.value.meta ?: return emptyList()

View file

@ -81,6 +81,7 @@ import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.library.toLibraryItem
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.streams.StreamAutoPlayPolicy
import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktCommentReview
import com.nuvio.app.features.trakt.TraktCommentsRepository
@ -100,6 +101,9 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepositor
import com.nuvio.app.features.watching.application.WatchingActions
import com.nuvio.app.features.watching.application.WatchingState
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
@Composable
@OptIn(ExperimentalSharedTransitionApi::class)
@ -164,6 +168,7 @@ fun MetaDetailsScreen(
var pickerMembership by remember(type, id) { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
var pickerPending by remember(type, id) { mutableStateOf(false) }
var pickerError by remember(type, id) { mutableStateOf<String?>(null) }
var episodeImdbRatings by remember(type, id) { mutableStateOf<Map<Pair<Int, Int>, Double>>(emptyMap()) }
val shouldShowComments = commentsEnabled &&
traktAuthUiState.mode == TraktConnectionMode.CONNECTED &&
@ -186,11 +191,35 @@ fun MetaDetailsScreen(
commentsCurrentPage = result.currentPage
commentsPageCount = result.pageCount
} catch (e: Exception) {
commentsError = e.message ?: "Failed to load comments"
commentsError = e.message ?: getString(Res.string.details_comments_load_failed)
}
isCommentsLoading = false
}
LaunchedEffect(displayedMeta?.id, displayedMeta?.videos) {
val metaForRatings = displayedMeta
if (metaForRatings == null || !metaForRatings.isSeriesLikeForEpisodeRatings()) {
episodeImdbRatings = emptyMap()
return@LaunchedEffect
}
val imdbId = extractImdbId(metaForRatings.id) ?: extractImdbId(id)
val tmdbId = extractTmdbId(metaForRatings.id)
?: extractTmdbId(id)
?: TmdbService.ensureTmdbId(metaForRatings.id, metaForRatings.type)?.toIntOrNull()
?: TmdbService.ensureTmdbId(id, type)?.toIntOrNull()
if (imdbId == null && tmdbId == null) {
episodeImdbRatings = emptyMap()
return@LaunchedEffect
}
episodeImdbRatings = ImdbEpisodeRatingsRepository.getEpisodeRatings(
imdbId = imdbId,
tmdbId = tmdbId,
)
}
LaunchedEffect(type, id, displayedMeta, uiState.isLoading, autoLoadAttempted) {
if (!autoLoadAttempted && displayedMeta == null && !uiState.isLoading) {
autoLoadAttempted = true
@ -242,14 +271,14 @@ fun MetaDetailsScreen(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Failed to load",
text = stringResource(Res.string.details_failed_to_load),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = when (networkStatusUiState.condition) {
NetworkCondition.NoInternet -> "Check your Wi-Fi or mobile data connection and try again."
NetworkCondition.ServersUnreachable -> "Your device is online, but Nuvio could not reach required servers."
NetworkCondition.NoInternet -> stringResource(Res.string.details_check_connection)
NetworkCondition.ServersUnreachable -> stringResource(Res.string.details_servers_unreachable)
else -> uiState.errorMessage.orEmpty()
},
style = MaterialTheme.typography.bodyMedium,
@ -262,7 +291,7 @@ fun MetaDetailsScreen(
MetaDetailsRepository.load(type, id)
},
) {
Text("Retry")
Text(stringResource(Res.string.action_retry))
}
}
}
@ -273,39 +302,39 @@ fun MetaDetailsScreen(
val isSaved = remember(
libraryUiState.items,
libraryUiState.sections,
traktAuthUiState.mode,
libraryUiState.sourceMode,
meta.id,
meta.type,
) {
LibraryRepository.isSaved(meta.id, meta.type)
}
val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED
val toggleSaved = remember(meta, isTraktConnected) {
val openLibraryListPicker = remember(meta) {
{
val libraryItem = meta.toLibraryItem(savedAtEpochMs = 0L)
if (!isTraktConnected) {
LibraryRepository.toggleSaved(libraryItem)
} else {
pickerTabs = LibraryRepository.traktListTabs()
pickerMembership = pickerTabs.associate { it.key to false }
pickerPending = true
pickerError = null
showLibraryListPicker = true
detailsScope.launch {
runCatching {
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
val tabs = LibraryRepository.traktListTabs()
pickerTabs = tabs
pickerMembership = tabs.associate { tab ->
tab.key to (snapshot[tab.key] == true)
}
}.onFailure { error ->
pickerError = error.message ?: "Failed to load Trakt lists"
pickerTabs = LibraryRepository.libraryListTabs()
pickerMembership = pickerTabs.associate { it.key to false }
pickerPending = true
pickerError = null
showLibraryListPicker = true
detailsScope.launch {
runCatching {
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
val tabs = LibraryRepository.libraryListTabs()
pickerTabs = tabs
pickerMembership = tabs.associate { tab ->
tab.key to (snapshot[tab.key] == true)
}
pickerPending = false
}.onFailure { error ->
pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed)
}
Unit
pickerPending = false
}
Unit
}
}
val toggleSaved = remember(meta) {
{
LibraryRepository.toggleSaved(meta.toLibraryItem(savedAtEpochMs = 0L))
}
}
val movieProgress = watchProgressUiState.byVideoId[meta.id]
@ -394,7 +423,7 @@ fun MetaDetailsScreen(
}
trailerPlaybackSource = resolvedSource
trailerErrorMessage = if (resolvedSource == null) {
"No playable trailer stream found."
getString(Res.string.trailer_no_playable_stream)
} else {
null
}
@ -403,13 +432,15 @@ fun MetaDetailsScreen(
}
}
}
val playButtonLabel = remember(movieProgress, seriesAction, meta.type, hasEpisodes) {
val playText = stringResource(Res.string.action_play)
val resumeText = stringResource(Res.string.action_resume)
val playButtonLabel = remember(movieProgress, seriesAction, meta.type, hasEpisodes, playText, resumeText) {
when {
(meta.type == "series" || hasEpisodes) && seriesAction != null ->
seriesAction.label
meta.type != "series" && !hasEpisodes && movieProgress != null ->
"Resume"
else -> "Play"
resumeText
else -> playText
}
}
val onPrimaryPlayClick: () -> Unit = {
@ -634,8 +665,10 @@ fun MetaDetailsScreen(
onPrimaryPlayClick = onPrimaryPlayClick,
onPrimaryPlayLongClick = onPrimaryPlayLongClick,
onSaveClick = toggleSaved,
onSaveLongClick = openLibraryListPicker,
showManualPlayOption = showManualPlayOption,
preferredEpisodeSeasonNumber = seriesAction?.seasonNumber,
preferredEpisodeNumber = seriesAction?.episodeNumber,
hasProductionSection = hasProductionSection,
hasTrailersSection = hasTrailersSection,
hasEpisodes = hasEpisodes,
@ -649,6 +682,7 @@ fun MetaDetailsScreen(
commentsCurrentPage = commentsCurrentPage,
commentsPageCount = commentsPageCount,
commentsError = commentsError,
episodeImdbRatings = episodeImdbRatings,
onRetryComments = {
detailsScope.launch {
isCommentsLoading = true
@ -659,7 +693,7 @@ fun MetaDetailsScreen(
commentsCurrentPage = result.currentPage
commentsPageCount = result.pageCount
} catch (e: Exception) {
commentsError = e.message ?: "Failed to load comments"
commentsError = e.message ?: getString(Res.string.details_comments_load_failed)
}
isCommentsLoading = false
}
@ -683,6 +717,7 @@ fun MetaDetailsScreen(
onTrailerClick = resolveTrailer,
progressByVideoId = watchProgressUiState.byVideoId,
watchedKeys = watchedUiState.watchedKeys,
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
onEpisodeClick = onEpisodePlayClick,
onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
onOpenMeta = onOpenMeta,
@ -779,7 +814,9 @@ fun MetaDetailsScreen(
}
EpisodeWatchedActionSheet(
episode = selectedEpisode,
seasonLabel = selectedEpisode.season?.let { "Season $it" } ?: "Specials",
seasonLabel = selectedEpisode.season?.let {
stringResource(Res.string.episodes_season, it)
} ?: stringResource(Res.string.episodes_specials),
isEpisodeWatched = isSelectedEpisodeWatched,
canMarkPreviousEpisodes = previousEpisodes.isNotEmpty(),
arePreviousEpisodesWatched = arePreviousEpisodesWatched,
@ -864,7 +901,7 @@ fun MetaDetailsScreen(
}.onSuccess {
showLibraryListPicker = false
}.onFailure { error ->
pickerError = error.message ?: "Failed to update Trakt lists"
pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed)
}
pickerPending = false
}
@ -927,6 +964,30 @@ fun MetaDetailsScreen(
}
}
private fun MetaDetails.isSeriesLikeForEpisodeRatings(): Boolean {
val normalizedType = type.trim().lowercase()
val hasNumberedEpisodes = videos.any { it.season != null && it.episode != null }
return hasNumberedEpisodes && normalizedType in setOf("series", "show", "tv", "tvshow")
}
private fun extractImdbId(value: String?): String? =
value
?.trim()
?.split(':', '/', '?', '&')
?.firstOrNull { part -> part.startsWith("tt", ignoreCase = true) }
?.takeIf { it.length > 2 }
private fun extractTmdbId(value: String?): Int? {
val trimmed = value?.trim().orEmpty()
if (trimmed.isBlank()) return null
return trimmed
.takeIf { it.startsWith("tmdb:", ignoreCase = true) }
?.substringAfter(':')
?.substringBefore(':')
?.substringBefore('/')
?.toIntOrNull()
}
@Composable
@OptIn(ExperimentalSharedTransitionApi::class)
private fun ConfiguredMetaSections(
@ -938,8 +999,10 @@ private fun ConfiguredMetaSections(
onPrimaryPlayClick: () -> Unit,
onPrimaryPlayLongClick: (() -> Unit)?,
onSaveClick: () -> Unit,
onSaveLongClick: (() -> Unit)?,
showManualPlayOption: Boolean,
preferredEpisodeSeasonNumber: Int?,
preferredEpisodeNumber: Int?,
hasProductionSection: Boolean,
hasTrailersSection: Boolean,
hasEpisodes: Boolean,
@ -953,12 +1016,14 @@ private fun ConfiguredMetaSections(
commentsCurrentPage: Int,
commentsPageCount: Int,
commentsError: String?,
episodeImdbRatings: Map<Pair<Int, Int>, Double>,
onRetryComments: () -> Unit,
onLoadMoreComments: () -> Unit,
onCommentClick: (TraktCommentReview) -> Unit,
onTrailerClick: (MetaTrailer) -> Unit,
progressByVideoId: Map<String, WatchProgressEntry>,
watchedKeys: Set<String>,
blurUnwatchedEpisodes: Boolean,
onEpisodeClick: (MetaVideo) -> Unit,
onEpisodeLongPress: (MetaVideo) -> Unit,
onOpenMeta: ((MetaPreview) -> Unit)?,
@ -991,12 +1056,17 @@ private fun ConfiguredMetaSections(
MetaScreenSectionKey.ACTIONS -> {
DetailActionButtons(
playLabel = playButtonLabel,
saveLabel = if (isSaved) "Saved" else "Save",
saveLabel = if (isSaved) {
stringResource(Res.string.action_saved)
} else {
stringResource(Res.string.action_save)
},
isSaved = isSaved,
isTablet = isTablet,
onPlayClick = onPrimaryPlayClick,
onPlayLongClick = if (showManualPlayOption) onPrimaryPlayLongClick else null,
onSaveClick = onSaveClick,
onSaveLongClick = onSaveLongClick,
)
}
MetaScreenSectionKey.OVERVIEW -> {
@ -1042,9 +1112,12 @@ private fun ConfiguredMetaSections(
meta = meta,
showHeader = showHeader,
preferredSeasonNumber = preferredEpisodeSeasonNumber,
preferredEpisodeNumber = preferredEpisodeNumber,
episodeCardStyle = settings.episodeCardStyle,
progressByVideoId = progressByVideoId,
watchedKeys = watchedKeys,
episodeRatings = episodeImdbRatings,
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
onEpisodeClick = onEpisodeClick,
onEpisodeLongPress = onEpisodeLongPress,
)
@ -1069,7 +1142,7 @@ private fun ConfiguredMetaSections(
MetaScreenSectionKey.MORE_LIKE_THIS -> {
if (hasMoreLikeThisSection) {
DetailPosterRailSection(
title = "More Like This",
title = stringResource(Res.string.details_more_like_this),
items = meta.moreLikeThis,
watchedKeys = watchedKeys,
showHeader = showHeader,

View file

@ -3,11 +3,15 @@ package com.nuvio.app.features.details
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
enum class MetaScreenSectionKey {
ACTIONS,
@ -41,6 +45,7 @@ data class MetaScreenSettingsUiState(
val cinematicBackground: Boolean = false,
val tabLayout: Boolean = false,
val episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
val blurUnwatchedEpisodes: Boolean = false,
)
enum class MetaEpisodeCardStyle {
@ -77,12 +82,14 @@ private data class StoredMetaScreenSettingsPayload(
@SerialName("tvStyleLayout")
val tabLayout: Boolean = false,
val episodeCardStyle: String = "horizontal",
@SerialName("blur_unwatched_episodes")
val blurUnwatchedEpisodes: Boolean = false,
)
private data class MetaScreenSectionDefinition(
val key: MetaScreenSectionKey,
val title: String,
val description: String,
val titleRes: StringResource,
val descriptionRes: StringResource,
)
object MetaScreenSettingsRepository {
@ -94,53 +101,53 @@ object MetaScreenSettingsRepository {
private val definitions = listOf(
MetaScreenSectionDefinition(
key = MetaScreenSectionKey.ACTIONS,
title = "Actions",
description = "Play and save controls.",
titleRes = Res.string.meta_section_actions_title,
descriptionRes = Res.string.meta_section_actions_description,
),
MetaScreenSectionDefinition(
key = MetaScreenSectionKey.OVERVIEW,
title = "Overview",
description = "Synopsis, ratings, genres, and core credits.",
titleRes = Res.string.meta_section_overview_title,
descriptionRes = Res.string.meta_section_overview_description,
),
MetaScreenSectionDefinition(
key = MetaScreenSectionKey.PRODUCTION,
title = "Production",
description = "Studios and networks.",
titleRes = Res.string.meta_section_production_title,
descriptionRes = Res.string.meta_section_production_description,
),
MetaScreenSectionDefinition(
key = MetaScreenSectionKey.CAST,
title = "Cast",
description = "Principal cast list.",
titleRes = Res.string.settings_meta_cast,
descriptionRes = Res.string.meta_section_cast_description,
),
MetaScreenSectionDefinition(
key = MetaScreenSectionKey.COMMENTS,
title = "Comments",
description = "Trakt comments section.",
titleRes = Res.string.settings_meta_comments,
descriptionRes = Res.string.meta_section_comments_description,
),
MetaScreenSectionDefinition(
key = MetaScreenSectionKey.TRAILERS,
title = "Trailers",
description = "Trailer rail and playback shortcuts.",
titleRes = Res.string.settings_meta_trailers,
descriptionRes = Res.string.meta_section_trailers_description,
),
MetaScreenSectionDefinition(
key = MetaScreenSectionKey.EPISODES,
title = "Episodes",
description = "Seasons and episode list for series.",
titleRes = Res.string.settings_meta_episodes,
descriptionRes = Res.string.meta_section_episodes_description,
),
MetaScreenSectionDefinition(
key = MetaScreenSectionKey.DETAILS,
title = "Details",
description = "Runtime, status, release, language, and related info.",
titleRes = Res.string.meta_section_details_title,
descriptionRes = Res.string.meta_section_details_description,
),
MetaScreenSectionDefinition(
key = MetaScreenSectionKey.COLLECTION,
title = "Collection",
description = "Related collection or franchise rail.",
titleRes = Res.string.meta_section_collection_title,
descriptionRes = Res.string.meta_section_collection_description,
),
MetaScreenSectionDefinition(
key = MetaScreenSectionKey.MORE_LIKE_THIS,
title = "More Like This",
description = "Recommendation rail.",
titleRes = Res.string.meta_section_more_like_this_title,
descriptionRes = Res.string.meta_section_more_like_this_description,
),
)
@ -152,6 +159,8 @@ object MetaScreenSettingsRepository {
private var cinematicBackground: Boolean = false
private var tabLayout: Boolean = false
private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal
private var blurUnwatchedEpisodes: Boolean = false
private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) }
fun ensureLoaded() {
if (hasLoaded) return
@ -167,6 +176,7 @@ object MetaScreenSettingsRepository {
tabLayout = parsed.tabLayout
episodeCardStyle = MetaEpisodeCardStyle.parse(parsed.episodeCardStyle)
?: MetaEpisodeCardStyle.Horizontal
blurUnwatchedEpisodes = parsed.blurUnwatchedEpisodes
preferences = parsed.items.mapNotNull { item ->
val key = runCatching { MetaScreenSectionKey.valueOf(item.key) }.getOrNull() ?: return@mapNotNull null
key to item
@ -185,6 +195,7 @@ object MetaScreenSettingsRepository {
cinematicBackground = false
tabLayout = false
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
blurUnwatchedEpisodes = false
_uiState.value = MetaScreenSettingsUiState()
ensureLoaded()
}
@ -210,6 +221,13 @@ object MetaScreenSettingsRepository {
persist()
}
fun setBlurUnwatchedEpisodes(enabled: Boolean) {
ensureLoaded()
blurUnwatchedEpisodes = enabled
publish()
persist()
}
fun setTabGroup(key: MetaScreenSectionKey, groupId: Int?) {
ensureLoaded()
if (!key.canBeTabbed) return
@ -228,6 +246,8 @@ object MetaScreenSettingsRepository {
preferences.clear()
cinematicBackground = false
tabLayout = false
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
blurUnwatchedEpisodes = false
_uiState.value = MetaScreenSettingsUiState()
}
@ -236,11 +256,13 @@ object MetaScreenSettingsRepository {
cinematicBackground: Boolean,
tabLayout: Boolean,
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
blurUnwatchedEpisodes: Boolean = false,
) {
ensureLoaded()
this.cinematicBackground = cinematicBackground
this.tabLayout = tabLayout
this.episodeCardStyle = episodeCardStyle
this.blurUnwatchedEpisodes = blurUnwatchedEpisodes
preferences = items.associate { item ->
item.key to StoredMetaScreenSectionPreference(
key = item.key.name,
@ -266,6 +288,7 @@ object MetaScreenSettingsRepository {
cinematicBackground = false
tabLayout = false
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
blurUnwatchedEpisodes = false
normalizePreferences()
publish()
persist()
@ -322,8 +345,8 @@ object MetaScreenSettingsRepository {
val preference = preferences[definition.key]
MetaScreenSectionItem(
key = definition.key,
title = definition.title,
description = definition.description,
title = localizedString(definition.titleRes),
description = localizedString(definition.descriptionRes),
enabled = preference?.enabled ?: true,
order = preference?.order ?: 0,
tabGroup = preference?.tabGroup,
@ -332,6 +355,7 @@ object MetaScreenSettingsRepository {
cinematicBackground = cinematicBackground,
tabLayout = tabLayout,
episodeCardStyle = episodeCardStyle,
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
)
}
@ -343,8 +367,9 @@ object MetaScreenSettingsRepository {
cinematicBackground = cinematicBackground,
tabLayout = tabLayout,
episodeCardStyle = MetaEpisodeCardStyle.persist(episodeCardStyle),
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
),
),
)
}
}
}

View file

@ -56,6 +56,7 @@ import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import com.nuvio.app.core.i18n.localizedShortMonthName
import com.nuvio.app.core.ui.landscapePosterHeightForWidth
import com.nuvio.app.core.ui.landscapePosterWidth
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
@ -63,6 +64,9 @@ import com.nuvio.app.features.details.components.DetailPosterRailSection
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.tmdb.TmdbMetadataService
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
private sealed interface PersonDetailUiState {
data object Loading : PersonDetailUiState
@ -96,7 +100,7 @@ fun PersonDetailScreen(
uiState = if (detail != null) {
PersonDetailUiState.Success(detail)
} else {
PersonDetailUiState.Error("Could not load details for $personName")
PersonDetailUiState.Error(getString(Res.string.person_load_failed, personName))
}
}
@ -141,7 +145,7 @@ fun PersonDetailScreen(
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "Back",
contentDescription = stringResource(Res.string.action_back),
tint = MaterialTheme.colorScheme.onSurface,
)
}
@ -268,7 +272,7 @@ private fun PersonDetailContent(
if (popularCredits.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
DetailPosterRailSection(
title = "Popular",
title = stringResource(Res.string.person_popular),
items = popularCredits,
watchedKeys = emptySet(),
headerHorizontalPadding = 20.dp,
@ -279,7 +283,7 @@ private fun PersonDetailContent(
if (latestCredits.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
DetailPosterRailSection(
title = "Latest",
title = stringResource(Res.string.person_latest),
items = latestCredits,
watchedKeys = emptySet(),
headerHorizontalPadding = 20.dp,
@ -290,7 +294,7 @@ private fun PersonDetailContent(
if (upcomingCredits.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
DetailPosterRailSection(
title = "Upcoming",
title = stringResource(Res.string.person_upcoming),
items = upcomingCredits,
watchedKeys = emptySet(),
headerHorizontalPadding = 20.dp,
@ -405,18 +409,23 @@ private fun HeroSection(
val infoItems = buildList {
person.birthday?.let { bday ->
val age = calculateAge(bday, person.deathday)
val ageStr = if (age != null) " (age $age)" else ""
val ageStr = if (age != null) stringResource(Res.string.person_age, age) else ""
val bdayDisplay = formatDateForDisplay(bday) ?: bday
val deathDisplay = person.deathday?.let { formatDateForDisplay(it) ?: it }
val line = if (deathDisplay != null) {
"Born $bdayDisplay — Died $deathDisplay$ageStr"
buildString {
append(stringResource(Res.string.person_born, bdayDisplay, ""))
append("")
append(stringResource(Res.string.person_died, deathDisplay))
append(ageStr)
}
} else {
"Born $bdayDisplay$ageStr"
stringResource(Res.string.person_born, bdayDisplay, ageStr)
}
add(line)
}
person.placeOfBirth?.let { add(it) }
person.knownFor?.let { add("Known for: $it") }
person.knownFor?.let { add(stringResource(Res.string.person_known_for, it)) }
}
if (infoItems.isNotEmpty()) {
infoItems.forEach { info ->
@ -682,7 +691,7 @@ private fun PersonDetailError(
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Something went wrong",
text = stringResource(Res.string.person_something_wrong),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
@ -700,7 +709,7 @@ private fun PersonDetailError(
contentColor = MaterialTheme.colorScheme.onPrimary,
),
) {
Text("Retry")
Text(stringResource(Res.string.action_retry))
}
}
}
@ -741,15 +750,11 @@ private fun calculateAge(birthday: String, deathday: String?): Int? {
private fun formatDateForDisplay(date: String): String? {
val parts = date.split("-").mapNotNull { it.toIntOrNull() }
if (parts.size < 3) return null
val months = arrayOf(
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
)
val month = parts[1]
val day = parts[2]
val year = parts[0]
return if (month in 1..12) {
"${months[month - 1]} $day, $year"
"${localizedShortMonthName(month)} $day, $year"
} else {
null
}

View file

@ -0,0 +1,65 @@
package com.nuvio.app.features.details
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.httpRequestRaw
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
internal object SeriesGraphApi {
suspend fun getSeasonRatings(tmdbId: Int): List<SeriesGraphSeasonRatingsDto> =
requestSeasonRatings(
baseUrl = ImdbEpisodeRatingsConfig.IMDB_RATINGS_API_BASE_URL,
showId = tmdbId.toString(),
)
}
internal object ImdbTapframeApi {
suspend fun getSeasonRatings(imdbId: String): List<SeriesGraphSeasonRatingsDto> =
requestSeasonRatings(
baseUrl = ImdbEpisodeRatingsConfig.IMDB_TAPFRAME_API_BASE_URL,
showId = imdbId,
)
}
@Serializable
internal data class SeriesGraphEpisodeRatingDto(
@SerialName("season_number") val seasonNumber: Int? = null,
@SerialName("episode_number") val episodeNumber: Int? = null,
@SerialName("vote_average") val voteAverage: Double? = null,
val name: String? = null,
val tconst: String? = null,
)
@Serializable
internal data class SeriesGraphSeasonRatingsDto(
val episodes: List<SeriesGraphEpisodeRatingDto>? = null,
)
private val seriesGraphLog = Logger.withTag("SeriesGraphApi")
private val seriesGraphJson = Json { ignoreUnknownKeys = true }
private suspend fun requestSeasonRatings(
baseUrl: String,
showId: String,
): List<SeriesGraphSeasonRatingsDto> {
val resolvedBaseUrl = baseUrl.trim().trimEnd('/')
if (resolvedBaseUrl.isBlank()) return emptyList()
return runCatching {
val response = httpRequestRaw(
method = "GET",
url = "$resolvedBaseUrl/api/shows/$showId/season-ratings",
headers = mapOf("Accept" to "application/json"),
body = "",
)
if (response.status !in 200..299 || response.body.isBlank()) {
seriesGraphLog.w { "Season ratings request failed for $showId (${response.status})" }
return emptyList()
}
seriesGraphJson.decodeFromString<List<SeriesGraphSeasonRatingsDto>>(response.body)
}.onFailure { error ->
seriesGraphLog.w(error) { "Season ratings request failed for $showId" }
}.getOrDefault(emptyList())
}

View file

@ -1,6 +1,7 @@
package com.nuvio.app.features.details
import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode
import com.nuvio.app.features.watching.domain.WatchingContentRef
@ -85,19 +86,35 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
)
val candidates = sortedEpisodes
.dropWhile { episode ->
buildPlaybackVideoId(
content = WatchingContentRef(type = type, id = id),
seasonNumber = episode.season,
episodeNumber = episode.episode,
fallbackVideoId = episode.id,
) != watchedVideoId
var watchedIndex = sortedEpisodes.indexOfFirst { episode ->
buildPlaybackVideoId(
content = WatchingContentRef(type = type, id = id),
seasonNumber = episode.season,
episodeNumber = episode.episode,
fallbackVideoId = episode.id,
) == watchedVideoId
}
// 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 }
if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) {
val globalIndex = episodeNumber - 1
if (globalIndex in sortedEpisodes.indices) {
watchedIndex = globalIndex
}
}
.drop(1)
}
if (watchedIndex < 0) return null
val watchedEpisodeSeason = sortedEpisodes[watchedIndex].season
val candidates = sortedEpisodes
.drop(watchedIndex + 1)
.filter { episode ->
shouldSurfaceNextEpisode(
watchedSeasonNumber = seasonNumber,
watchedSeasonNumber = watchedEpisodeSeason,
candidateSeasonNumber = episode.season,
todayIsoDate = todayIsoDate,
releasedDate = episode.released,
@ -190,7 +207,7 @@ private fun WatchedItem.toDomainWatchedRecord(): WatchingWatchedRecord =
content = WatchingContentRef(type = type, id = id),
seasonNumber = season,
episodeNumber = episode,
markedAtEpochMs = markedAtEpochMs,
markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs),
)
private fun WatchingSeriesPrimaryAction.toLegacySeriesPrimaryAction(): SeriesPrimaryAction =

View file

@ -43,6 +43,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
import com.nuvio.app.core.ui.landscapePosterHeightForWidth
import com.nuvio.app.core.ui.landscapePosterWidth
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
@ -73,6 +75,7 @@ fun TmdbEntityBrowseScreen(
var uiState by remember(entityKind, entityId) {
mutableStateOf<EntityBrowseUiState>(EntityBrowseUiState.Loading)
}
val loadFailedMessage = stringResource(Res.string.details_browse_load_failed, entityName)
LaunchedEffect(entityKind, entityId) {
uiState = EntityBrowseUiState.Loading
@ -85,7 +88,7 @@ fun TmdbEntityBrowseScreen(
uiState = if (data != null) {
EntityBrowseUiState.Success(data)
} else {
EntityBrowseUiState.Error("Could not load $entityName")
EntityBrowseUiState.Error(loadFailedMessage)
}
}
@ -117,7 +120,7 @@ fun TmdbEntityBrowseScreen(
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "Back",
contentDescription = stringResource(Res.string.action_back),
tint = MaterialTheme.colorScheme.onSurface,
)
}
@ -170,7 +173,7 @@ private fun EntityBrowseContent(
contentAlignment = Alignment.Center,
) {
Text(
text = "No titles found",
text = stringResource(Res.string.catalog_empty_title),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -191,18 +194,16 @@ private fun EntityBrowseContent(
)
data.rails.forEach { rail ->
val railTitle = remember(rail.mediaType, rail.railType) {
val mediaLabel = when (rail.mediaType) {
TmdbEntityMediaType.MOVIE -> "Movies"
TmdbEntityMediaType.TV -> "Series"
}
val railLabel = when (rail.railType) {
TmdbEntityRailType.POPULAR -> "Popular"
TmdbEntityRailType.TOP_RATED -> "Top Rated"
TmdbEntityRailType.RECENT -> "Recent"
}
"$mediaLabel$railLabel"
val mediaLabel = when (rail.mediaType) {
TmdbEntityMediaType.MOVIE -> stringResource(Res.string.media_movies)
TmdbEntityMediaType.TV -> stringResource(Res.string.media_series)
}
val railLabel = when (rail.railType) {
TmdbEntityRailType.POPULAR -> stringResource(Res.string.details_browse_rail_popular)
TmdbEntityRailType.TOP_RATED -> stringResource(Res.string.details_browse_rail_top_rated)
TmdbEntityRailType.RECENT -> stringResource(Res.string.details_browse_rail_recent)
}
val railTitle = stringResource(Res.string.details_browse_rail_title, mediaLabel, railLabel)
DetailPosterRailSection(
title = railTitle,
@ -230,8 +231,8 @@ private fun EntityHeroSection(
Column(modifier = modifier.padding(horizontal = 20.dp)) {
Text(
text = when (header.kind) {
TmdbEntityKind.COMPANY -> "Production Company"
TmdbEntityKind.NETWORK -> "Network"
TmdbEntityKind.COMPANY -> stringResource(Res.string.details_browse_kind_company)
TmdbEntityKind.NETWORK -> stringResource(Res.string.details_browse_kind_network)
},
style = MaterialTheme.typography.labelLarge.copy(
fontWeight = FontWeight.Medium,
@ -405,7 +406,7 @@ private fun EntityBrowseError(
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
) {
Text("Retry")
Text(stringResource(Res.string.action_retry))
}
}
}

View file

@ -36,6 +36,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.nuvio.app.core.ui.NuvioModalBottomSheet
import com.nuvio.app.features.trakt.TraktCommentReview
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -114,7 +116,7 @@ fun CommentDetailSheet(
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowLeft,
contentDescription = "Previous",
contentDescription = stringResource(Res.string.action_previous),
tint = if (canGoBack) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
modifier = Modifier.size(20.dp),
@ -140,7 +142,7 @@ fun CommentDetailSheet(
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
contentDescription = "Next",
contentDescription = stringResource(Res.string.action_next),
tint = if (canGoForward) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
modifier = Modifier.size(20.dp),
@ -153,13 +155,13 @@ fun CommentDetailSheet(
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (comment.review) {
CommentDetailChip(text = "Review")
CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_review))
}
if (comment.hasSpoilerContent) {
CommentDetailChip(text = "Spoiler")
CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_spoiler))
}
comment.rating?.let { rating ->
CommentDetailChip(text = "Rating $rating/10")
CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_rating, rating))
}
}
@ -173,7 +175,7 @@ fun CommentDetailSheet(
) {
Text(
text = if (comment.hasSpoilerContent) {
"This comment contains spoilers and has been hidden."
stringResource(Res.string.detail_comments_spoiler_hidden_sheet)
} else {
comment.comment
},
@ -189,7 +191,7 @@ fun CommentDetailSheet(
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "${comment.likes} likes",
text = stringResource(Res.string.detail_comments_likes, comment.likes),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)

View file

@ -13,11 +13,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -28,18 +25,23 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.nuvio.app.core.ui.AppIconResource
import com.nuvio.app.core.ui.appIconPainter
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_play
import nuvio.composeapp.generated.resources.action_save
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DetailActionButtons(
modifier: Modifier = Modifier,
playLabel: String = "Play",
saveLabel: String = "Save",
playLabel: String = stringResource(Res.string.action_play),
saveLabel: String = stringResource(Res.string.action_save),
isSaved: Boolean = false,
isTablet: Boolean = false,
onPlayClick: () -> Unit = {},
onPlayLongClick: (() -> Unit)? = null,
onSaveClick: () -> Unit = {},
onSaveLongClick: (() -> Unit)? = null,
) {
val playPainter = appIconPainter(AppIconResource.PlayerPlay)
val libraryAddPainter = appIconPainter(AppIconResource.LibraryAddPlus)
@ -92,35 +94,49 @@ fun DetailActionButtons(
}
}
OutlinedButton(
onClick = onSaveClick,
Surface(
modifier = rowButtonModifier.height(50.dp),
shape = RoundedCornerShape(40.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0f),
contentColor = MaterialTheme.colorScheme.onSurface,
) {
if (isSaved) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
} else {
Icon(
painter = libraryAddPainter,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurface,
Row(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = onSaveClick,
onLongClick = onSaveLongClick,
role = Role.Button,
)
.height(50.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
if (isSaved) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
} else {
Icon(
painter = libraryAddPainter,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
}
Spacer(modifier = Modifier.width(6.dp))
Text(
text = saveLabel,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.width(6.dp))
Text(
text = saveLabel,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}

View file

@ -19,6 +19,8 @@ import androidx.compose.ui.unit.dp
import com.nuvio.app.core.format.formatReleaseDateForDisplay
import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.formatRuntimeForDisplay
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun DetailAdditionalInfoSection(
@ -27,14 +29,24 @@ fun DetailAdditionalInfoSection(
showHeader: Boolean = true,
) {
val isSeriesLike = meta.type == "series" || meta.videos.any { it.season != null || it.episode != null }
val title = if (isSeriesLike) "Show Details" else "Movie Details"
val title = if (isSeriesLike) {
stringResource(Res.string.details_show_details)
} else {
stringResource(Res.string.details_movie_details)
}
val rows = buildList {
meta.status?.let { add("Status" to it) }
meta.releaseInfo?.let { add("Release Info" to formatReleaseDateForDisplay(it)) }
formatRuntimeForDisplay(meta.runtime)?.let { add("Runtime" to it) }
meta.ageRating?.let { add("Certification" to it) }
meta.country?.let { add("Origin Country" to it) }
meta.language?.let { add("Original Language" to it.uppercase()) }
meta.status?.let { add(stringResource(Res.string.details_status) to it) }
meta.releaseInfo?.let {
add(stringResource(Res.string.details_release_info) to formatReleaseDateForDisplay(it))
}
formatRuntimeForDisplay(meta.runtime)?.let {
add(stringResource(Res.string.details_runtime) to it)
}
meta.ageRating?.let { add(stringResource(Res.string.details_certification) to it) }
meta.country?.let { add(stringResource(Res.string.details_origin_country) to it) }
meta.language?.let {
add(stringResource(Res.string.details_original_language) to it.uppercase())
}
}
if (rows.isEmpty()) return

View file

@ -34,6 +34,8 @@ import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import com.nuvio.app.features.details.MetaPerson
import com.nuvio.app.features.details.castAvatarSharedTransitionKey
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
@OptIn(ExperimentalSharedTransitionApi::class)
@ -48,7 +50,7 @@ fun DetailCastSection(
if (cast.isEmpty()) return
DetailSection(
title = "Cast",
title = stringResource(Res.string.settings_meta_cast),
modifier = modifier,
showHeader = showHeader,
) {

View file

@ -38,8 +38,11 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
import com.nuvio.app.features.trakt.TraktCommentReview
import kotlinx.coroutines.flow.distinctUntilChanged
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun DetailCommentsSection(
@ -101,14 +104,14 @@ fun DetailCommentsSection(
contentColor = MaterialTheme.colorScheme.onSurface,
),
) {
Text("Retry")
Text(stringResource(Res.string.action_retry))
}
}
}
comments.isEmpty() -> {
Text(
text = "No comments yet.",
text = stringResource(Res.string.detail_comments_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -120,7 +123,11 @@ fun DetailCommentsSection(
state = listState,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(comments, key = { it.id }) { review ->
items(
items = comments.withDuplicateSafeLazyKeys { it.id },
key = { it.lazyKey },
) { keyedReview ->
val review = keyedReview.value
CommentCard(
review = review,
onClick = { onCommentClick(review) },
@ -144,7 +151,7 @@ private fun CommentsHeader() {
val titleSize = if (isTablet) 22.sp else 20.sp
Text(
text = "Trakt Comments",
text = stringResource(Res.string.detail_comments_title),
style = MaterialTheme.typography.titleLarge.copy(
fontSize = titleSize,
fontWeight = FontWeight.SemiBold,
@ -163,7 +170,7 @@ private fun CommentCard(
val colorScheme = MaterialTheme.colorScheme
val isAmoled = colorScheme.background == Color.Black && colorScheme.surface == Color(0xFF050505)
val bodyText = if (review.hasSpoilerContent) {
"This comment contains spoilers."
stringResource(Res.string.detail_comments_spoiler_card)
} else {
review.comment
}
@ -199,13 +206,13 @@ private fun CommentCard(
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (review.review) {
CommentChip(text = "Review")
CommentChip(text = stringResource(Res.string.detail_comments_badge_review))
}
if (review.hasSpoilerContent) {
CommentChip(text = "Spoiler")
CommentChip(text = stringResource(Res.string.detail_comments_badge_spoiler))
}
review.rating?.let { rating ->
CommentChip(text = "Rating $rating/10")
CommentChip(text = stringResource(Res.string.detail_comments_badge_rating, rating))
}
}
@ -219,7 +226,7 @@ private fun CommentCard(
)
Text(
text = "${review.likes} likes",
text = stringResource(Res.string.detail_comments_likes, review.likes),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
maxLines = 1,

View file

@ -41,6 +41,8 @@ import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.NuvioBackButton
import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.isIos
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun DetailFloatingHeader(
@ -112,7 +114,7 @@ fun DetailFloatingHeader(
if (meta.logo != null && !logoLoadError) {
AsyncImage(
model = meta.logo,
contentDescription = "${meta.name} logo",
contentDescription = stringResource(Res.string.detail_logo_content_description, meta.name),
modifier = Modifier
.width(logoWidth)
.widthIn(max = 240.dp)
@ -166,7 +168,11 @@ private fun DetailFloatingHeaderAction(
) {
Icon(
imageVector = if (isSaved) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
contentDescription = if (isSaved) "Remove from Library" else "Add to Library",
contentDescription = if (isSaved) {
stringResource(Res.string.hero_remove_from_library)
} else {
stringResource(Res.string.hero_add_to_library)
},
tint = MaterialTheme.colorScheme.onBackground,
)
}

View file

@ -25,6 +25,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.graphicsLayer
import coil3.compose.AsyncImage
import com.nuvio.app.features.details.MetaDetails
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun DetailHero(
@ -103,7 +105,7 @@ fun DetailHero(
if (meta.logo != null) {
AsyncImage(
model = meta.logo,
contentDescription = "${meta.name} logo",
contentDescription = stringResource(Res.string.detail_logo_content_description, meta.name),
modifier = Modifier
.fillMaxWidth(if (isTablet) 0.56f else 0.6f)
.widthIn(max = contentMaxWidth)

View file

@ -49,7 +49,7 @@ import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_METACRITIC
import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TMDB
import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TOMATOES
import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TRAKT
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.*
import nuvio.composeapp.generated.resources.rating_audience_score
import nuvio.composeapp.generated.resources.rating_imdb
import nuvio.composeapp.generated.resources.rating_letterboxd
@ -58,7 +58,10 @@ import nuvio.composeapp.generated.resources.rating_rotten_tomatoes
import nuvio.composeapp.generated.resources.rating_tmdb
import nuvio.composeapp.generated.resources.rating_trakt
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import kotlinx.coroutines.runBlocking
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
@ -114,7 +117,7 @@ fun DetailMetaInfo(
color = ImdbYellow,
) {
Text(
text = "IMDb",
text = stringResource(Res.string.source_imdb),
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelMedium.copy(
fontSize = 10.sp,
@ -148,14 +151,14 @@ fun DetailMetaInfo(
if (meta.director.isNotEmpty()) {
MetaLabelValueRow(
label = "Director",
label = stringResource(Res.string.details_director),
value = meta.director.joinToString(", "),
)
}
if (meta.writer.isNotEmpty()) {
MetaLabelValueRow(
label = "Writer",
label = stringResource(Res.string.details_writer),
value = meta.writer.joinToString(", "),
)
}
@ -182,7 +185,11 @@ fun DetailMetaInfo(
if (canExpand) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = if (expanded) "Show Less" else "Show More ▾",
text = if (expanded) {
stringResource(Res.string.details_show_less)
} else {
stringResource(Res.string.details_show_more)
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.clickable { expanded = !expanded },
@ -341,7 +348,7 @@ private val ratingVisuals = listOf(
),
RatingVisuals(
source = PROVIDER_AUDIENCE,
displayName = "Audience Score",
displayName = runBlocking { getString(Res.string.rating_audience_score) },
logo = Res.drawable.rating_audience_score,
logoWidth = 16.dp,
valueColor = Color(0xFFFA320A),

View file

@ -25,6 +25,8 @@ import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.nuvio.app.features.details.MetaCompany
import com.nuvio.app.features.details.MetaDetails
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalLayoutApi::class)
@Composable
@ -54,7 +56,11 @@ fun DetailProductionSection(
if (displayItems.isEmpty()) return
DetailSection(
title = if (isSeriesLike) "Network" else "Production",
title = if (isSeriesLike) {
stringResource(Res.string.details_networks)
} else {
stringResource(Res.string.meta_section_production_title)
},
modifier = modifier,
showHeader = showHeader,
) {

View file

@ -15,12 +15,14 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@ -30,6 +32,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@ -44,6 +47,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
@ -57,6 +61,7 @@ import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import co.touchlab.kermit.Logger
import com.nuvio.app.core.format.formatReleaseDateForDisplay
import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode
import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge
import com.nuvio.app.core.ui.NuvioProgressBar
import com.nuvio.app.features.details.MetaDetails
@ -71,6 +76,13 @@ import com.nuvio.app.features.details.seasonSortKey
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import com.nuvio.app.features.watching.application.WatchingState
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
private val log = Logger.withTag("SeriesContent")
@ -80,9 +92,12 @@ fun DetailSeriesContent(
modifier: Modifier = Modifier,
showHeader: Boolean = true,
preferredSeasonNumber: Int? = null,
preferredEpisodeNumber: Int? = null,
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
watchedKeys: Set<String> = emptySet(),
episodeRatings: Map<Pair<Int, Int>, Double> = emptyMap(),
blurUnwatchedEpisodes: Boolean = false,
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
onEpisodeLongPress: ((MetaVideo) -> Unit)? = null,
) {
@ -91,16 +106,16 @@ fun DetailSeriesContent(
if (meta.videos.isEmpty()) {
DetailSection(
title = "Episodes",
title = stringResource(Res.string.settings_meta_episodes),
modifier = modifier,
showHeader = showHeader,
) {
Text(
text = when {
meta.status.equals("Not yet aired", ignoreCase = true) || meta.hasScheduledVideos ->
"Episodes have not been published by this addon yet."
stringResource(Res.string.details_series_unpublished)
else ->
"This addon did not provide episode metadata for this series."
stringResource(Res.string.details_series_no_metadata)
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@ -131,12 +146,12 @@ fun DetailSeriesContent(
if (groupedEpisodes.isEmpty()) {
if (meta.type == "series") {
DetailSection(
title = "Episodes",
title = stringResource(Res.string.settings_meta_episodes),
modifier = modifier,
showHeader = showHeader,
) {
Text(
text = "This addon returned videos for the series, but none included season or episode numbers.",
text = stringResource(Res.string.details_series_missing_numbers),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -181,7 +196,7 @@ fun DetailSeriesContent(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Seasons",
text = stringResource(Res.string.details_seasons),
style = MaterialTheme.typography.titleLarge.copy(
fontSize = sizing.seasonHeaderSize,
fontWeight = FontWeight.SemiBold,
@ -249,7 +264,7 @@ fun DetailSeriesContent(
label = "season_episodes",
) { seasonForContent ->
val sectionTitle = if (meta.type != "series" && seasons.size == 1 && seasonForContent <= 0) {
"Videos"
stringResource(Res.string.details_videos)
} else {
seasonForContent.label()
}
@ -269,6 +284,9 @@ fun DetailSeriesContent(
watchedKeys = watchedKeys,
fallbackImage = meta.background ?: meta.poster,
progressByVideoId = progressByVideoId,
episodeRatings = episodeRatings,
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
preferredEpisodeNumber = preferredEpisodeNumber,
onEpisodeClick = onEpisodeClick,
onEpisodeLongPress = onEpisodeLongPress,
)
@ -287,13 +305,15 @@ fun DetailSeriesContent(
video = episode,
fallbackImage = meta.background ?: meta.poster,
progressEntry = progressByVideoId[episodeVideoId],
isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true ||
imdbRating = episode.seasonEpisodeKey()?.let { episodeRatings[it] },
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
WatchingState.isEpisodeWatched(
watchedKeys = watchedKeys,
metaType = meta.type,
metaId = meta.id,
episode = episode,
),
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
sizing = sizing,
onClick = { onEpisodeClick?.invoke(episode) },
onLongPress = { onEpisodeLongPress?.invoke(episode) },
@ -334,7 +354,11 @@ private fun SeasonViewModeToggle(
contentAlignment = Alignment.Center,
) {
Text(
text = if (isPosters) "Posters" else "Text",
text = if (isPosters) {
stringResource(Res.string.details_season_view_posters)
} else {
stringResource(Res.string.details_season_view_text)
},
style = MaterialTheme.typography.labelLarge.copy(
fontSize = sizing.seasonToggleTextSize,
fontWeight = FontWeight.SemiBold,
@ -541,17 +565,42 @@ private fun EpisodeHorizontalRow(
watchedKeys: Set<String>,
fallbackImage: String?,
progressByVideoId: Map<String, WatchProgressEntry>,
episodeRatings: Map<Pair<Int, Int>, Double>,
blurUnwatchedEpisodes: Boolean,
preferredEpisodeNumber: Int? = null,
onEpisodeClick: ((MetaVideo) -> Unit)?,
onEpisodeLongPress: ((MetaVideo) -> Unit)?,
) {
val rowMetrics = rememberEpisodeHorizontalCardMetrics(maxWidthDp)
val listState = rememberLazyListState()
var hasPositioned by remember(episodes) { mutableStateOf(false) }
LaunchedEffect(episodes, preferredEpisodeNumber) {
val targetIndex = if (preferredEpisodeNumber != null) {
episodes.indexOfFirst { it.episode == preferredEpisodeNumber }
} else {
-1
}
if (targetIndex >= 0) {
if (hasPositioned) {
listState.animateScrollToItem(targetIndex)
} else {
listState.scrollToItem(targetIndex)
hasPositioned = true
}
}
}
LazyRow(
state = listState,
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = rowMetrics.rowHorizontalPadding, vertical = rowMetrics.rowVerticalPadding),
horizontalArrangement = Arrangement.spacedBy(rowMetrics.itemSpacing),
) {
items(episodes, key = { it.id }) { episode ->
itemsIndexed(
items = episodes,
key = { index, episode -> "${episode.season}:${episode.episode}:${episode.id}#$index" },
) { _, episode ->
val episodeVideoId = buildPlaybackVideoId(
parentMetaId = parentMetaId,
seasonNumber = episode.season,
@ -562,13 +611,15 @@ private fun EpisodeHorizontalRow(
video = episode,
fallbackImage = fallbackImage,
progressEntry = progressByVideoId[episodeVideoId],
isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true ||
imdbRating = episode.seasonEpisodeKey()?.let { episodeRatings[it] },
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
WatchingState.isEpisodeWatched(
watchedKeys = watchedKeys,
metaType = metaType,
metaId = parentMetaId,
episode = episode,
),
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
metrics = rowMetrics,
onClick = { onEpisodeClick?.invoke(episode) },
onLongPress = { onEpisodeLongPress?.invoke(episode) },
@ -583,12 +634,17 @@ private fun EpisodeHorizontalCard(
video: MetaVideo,
fallbackImage: String?,
progressEntry: WatchProgressEntry?,
imdbRating: Double?,
isWatched: Boolean,
blurUnwatchedEpisodes: Boolean,
metrics: EpisodeHorizontalCardMetrics,
onClick: (() -> Unit)? = null,
onLongPress: (() -> Unit)? = null,
) {
val cardShape = RoundedCornerShape(metrics.cornerRadius)
val ratingLabel = remember(imdbRating) { imdbRating?.takeIf { it > 0.0 }?.let(::formatEpisodeRating) }
val formattedDate = remember(video.released) { video.released?.let { formatReleaseDateForDisplay(it) } }
val runtimeLabel = remember(video.runtime) { video.runtime?.takeIf { it > 0 }?.let(::formatEpisodeRuntime) }
Box(
modifier = Modifier
.width(metrics.cardWidth)
@ -607,11 +663,14 @@ private fun EpisodeHorizontalCard(
),
) {
val imageUrl = video.thumbnail ?: fallbackImage
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = video.title,
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
contentScale = ContentScale.Crop,
)
}
@ -631,30 +690,6 @@ private fun EpisodeHorizontalCard(
),
)
Box(
modifier = Modifier
.align(Alignment.TopStart)
.padding(start = metrics.contentPadding, top = metrics.contentPadding)
.clip(RoundedCornerShape(metrics.badgeRadius))
.background(Color.Black.copy(alpha = 0.75f))
.border(
width = 1.dp,
color = Color.White.copy(alpha = 0.18f),
shape = RoundedCornerShape(metrics.badgeRadius),
)
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text(
text = video.episodeBadge(),
style = MaterialTheme.typography.labelMedium.copy(
fontSize = metrics.badgeTextSize,
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.5.sp,
),
color = Color.White,
)
}
NuvioAnimatedWatchedBadge(
isVisible = isWatched,
modifier = Modifier
@ -674,6 +709,15 @@ private fun EpisodeHorizontalCard(
),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
EpisodeCodeBadge(
text = video.episodeBadge(),
textSize = metrics.badgeTextSize,
radius = metrics.badgeRadius,
horizontalPadding = metrics.badgeHorizontalPadding,
verticalPadding = metrics.badgeVerticalPadding,
backgroundAlpha = 0.42f,
)
Text(
text = video.title,
style = MaterialTheme.typography.titleMedium.copy(
@ -699,27 +743,39 @@ private fun EpisodeHorizontalCard(
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
video.runtime?.takeIf { it > 0 }?.let { runtimeMinutes ->
Text(
text = formatEpisodeRuntime(runtimeMinutes),
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
color = Color.White.copy(alpha = 0.78f),
maxLines = 1,
)
}
video.released?.let { formatReleaseDateForDisplay(it) }?.let { formattedDate ->
Text(
text = formattedDate,
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
color = Color.White.copy(alpha = 0.78f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (runtimeLabel != null || ratingLabel != null || formattedDate != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
runtimeLabel?.let { runtime ->
Text(
text = runtime,
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
color = Color.White.copy(alpha = 0.78f),
maxLines = 1,
)
}
ratingLabel?.let { rating ->
ImdbEpisodeRatingBadge(
rating = rating,
logoWidth = metrics.imdbLogoWidth,
logoHeight = metrics.imdbLogoHeight,
textSize = metrics.metaTextSize,
)
}
Spacer(modifier = Modifier.weight(1f))
formattedDate?.let { date ->
Text(
text = date,
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
color = Color.White.copy(alpha = 0.78f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.End,
)
}
}
}
}
@ -758,6 +814,10 @@ private data class EpisodeHorizontalCardMetrics(
val metaTextSize: androidx.compose.ui.unit.TextUnit,
val badgeTextSize: androidx.compose.ui.unit.TextUnit,
val badgeRadius: Dp,
val badgeHorizontalPadding: Dp,
val badgeVerticalPadding: Dp,
val imdbLogoWidth: Dp,
val imdbLogoHeight: Dp,
)
@Composable
@ -780,7 +840,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
overviewMaxLines = 3,
metaTextSize = 12.sp,
badgeTextSize = 11.sp,
badgeRadius = 6.dp,
badgeRadius = 8.dp,
badgeHorizontalPadding = 10.dp,
badgeVerticalPadding = 5.dp,
imdbLogoWidth = 28.dp,
imdbLogoHeight = 14.dp,
)
maxWidthDp >= 1000f -> EpisodeHorizontalCardMetrics(
@ -799,7 +863,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
overviewMaxLines = 3,
metaTextSize = 12.sp,
badgeTextSize = 10.sp,
badgeRadius = 6.dp,
badgeRadius = 7.dp,
badgeHorizontalPadding = 9.dp,
badgeVerticalPadding = 4.dp,
imdbLogoWidth = 26.dp,
imdbLogoHeight = 13.dp,
)
maxWidthDp >= 760f -> EpisodeHorizontalCardMetrics(
@ -818,7 +886,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
overviewMaxLines = 2,
metaTextSize = 11.sp,
badgeTextSize = 10.sp,
badgeRadius = 5.dp,
badgeRadius = 6.dp,
badgeHorizontalPadding = 8.dp,
badgeVerticalPadding = 4.dp,
imdbLogoWidth = 24.dp,
imdbLogoHeight = 12.dp,
)
else -> EpisodeHorizontalCardMetrics(
@ -838,6 +910,10 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
metaTextSize = 10.sp,
badgeTextSize = 9.sp,
badgeRadius = 5.dp,
badgeHorizontalPadding = 7.dp,
badgeVerticalPadding = 3.dp,
imdbLogoWidth = 22.dp,
imdbLogoHeight = 11.dp,
)
}
}
@ -847,19 +923,83 @@ private fun formatEpisodeRuntime(runtimeMinutes: Int): String {
return formatRuntimeFromMinutes(runtimeMinutes)
}
@Composable
private fun EpisodeCodeBadge(
text: String,
textSize: androidx.compose.ui.unit.TextUnit,
radius: Dp,
horizontalPadding: Dp,
verticalPadding: Dp,
backgroundAlpha: Float,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.clip(RoundedCornerShape(radius))
.background(Color.Black.copy(alpha = backgroundAlpha))
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
) {
Text(
text = text,
style = MaterialTheme.typography.labelMedium.copy(
fontSize = textSize,
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.sp,
),
color = Color.White.copy(alpha = 0.9f),
maxLines = 1,
)
}
}
@Composable
private fun ImdbEpisodeRatingBadge(
rating: String,
logoWidth: Dp,
logoHeight: Dp,
textSize: androidx.compose.ui.unit.TextUnit,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(Res.drawable.rating_imdb),
contentDescription = stringResource(Res.string.source_imdb),
modifier = Modifier
.width(logoWidth)
.height(logoHeight),
contentScale = ContentScale.Fit,
)
Text(
text = rating,
style = MaterialTheme.typography.labelSmall.copy(
fontSize = textSize,
fontWeight = FontWeight.SemiBold,
),
color = Color(0xFFF5C518),
maxLines = 1,
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun EpisodeListCard(
video: MetaVideo,
fallbackImage: String?,
progressEntry: WatchProgressEntry?,
imdbRating: Double?,
isWatched: Boolean,
blurUnwatchedEpisodes: Boolean,
sizing: SeriesContentSizing,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
onLongPress: (() -> Unit)? = null,
) {
val cardShape = RoundedCornerShape(sizing.cardRadius)
val ratingLabel = remember(imdbRating) { imdbRating?.takeIf { it > 0.0 }?.let(::formatEpisodeRating) }
val formattedDate = remember(video.released) { video.released?.let { formatReleaseDateForDisplay(it) } }
Box(
modifier = modifier
.fillMaxWidth()
@ -888,11 +1028,14 @@ private fun EpisodeListCard(
.clip(RoundedCornerShape(topStart = sizing.cardRadius, bottomStart = sizing.cardRadius)),
) {
val imageUrl = video.thumbnail ?: fallbackImage
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = video.title,
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
contentScale = ContentScale.Crop,
)
} else {
@ -903,32 +1046,17 @@ private fun EpisodeListCard(
)
}
Box(
EpisodeCodeBadge(
text = video.episodeBadge(),
textSize = sizing.badgeTextSize,
radius = sizing.badgeRadius,
horizontalPadding = sizing.badgeHorizontalPadding,
verticalPadding = sizing.badgeVerticalPadding,
backgroundAlpha = 0.85f,
modifier = Modifier
.align(Alignment.TopStart)
.padding(start = 8.dp, top = 8.dp)
.clip(RoundedCornerShape(sizing.badgeRadius))
.background(Color.Black.copy(alpha = 0.85f))
.border(
width = 1.dp,
color = Color.White.copy(alpha = 0.2f),
shape = RoundedCornerShape(sizing.badgeRadius),
)
.padding(
horizontal = sizing.badgeHorizontalPadding,
vertical = sizing.badgeVerticalPadding,
),
) {
Text(
text = video.episodeBadge(),
style = MaterialTheme.typography.labelMedium.copy(
fontSize = sizing.badgeTextSize,
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.3.sp,
),
color = Color.White,
)
}
.padding(start = 8.dp, top = 8.dp),
)
NuvioAnimatedWatchedBadge(
isVisible = isWatched,
@ -956,24 +1084,39 @@ private fun EpisodeListCard(
fontSize = sizing.titleTextSize,
fontWeight = FontWeight.Bold,
lineHeight = sizing.titleLineHeight,
letterSpacing = 0.3.sp,
letterSpacing = 0.sp,
),
color = MaterialTheme.colorScheme.onSurface,
maxLines = sizing.titleMaxLines,
overflow = TextOverflow.Ellipsis,
)
video.released?.let { formatReleaseDateForDisplay(it) }?.let { formattedDate ->
Text(
text = formattedDate,
style = MaterialTheme.typography.labelMedium.copy(
fontSize = sizing.metaTextSize,
fontWeight = FontWeight.Medium,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (formattedDate != null || ratingLabel != null) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
formattedDate?.let { date ->
Text(
text = date,
style = MaterialTheme.typography.labelMedium.copy(
fontSize = sizing.metaTextSize,
fontWeight = FontWeight.Medium,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
ratingLabel?.let { rating ->
ImdbEpisodeRatingBadge(
rating = rating,
logoWidth = 24.dp,
logoHeight = 12.dp,
textSize = sizing.metaTextSize,
)
}
}
}
if (!video.overview.isNullOrBlank()) {
@ -1165,14 +1308,27 @@ private fun seriesContentSizing(maxWidthDp: Float): SeriesContentSizing =
private fun Int.label(): String =
if (this <= 0) {
"Specials"
runBlocking { getString(Res.string.episodes_specials) }
} else {
"Season $this"
runBlocking { getString(Res.string.episodes_season, this@label) }
}
private fun MetaVideo.episodeBadge(): String =
when {
episode != null -> "E${episode.toString().padStart(2, '0')}"
season != null -> "S${season.toString().padStart(2, '0')}"
else -> "FILE"
episode != null || season != null ->
localizedSeasonEpisodeCode(seasonNumber = season, episodeNumber = episode).orEmpty()
else -> runBlocking { getString(Res.string.details_episode_badge_file) }
}
private fun MetaVideo.seasonEpisodeKey(): Pair<Int, Int>? {
val seasonNumber = season ?: return null
val episodeNumber = episode ?: return null
return seasonNumber to episodeNumber
}
private fun formatEpisodeRating(rating: Double): String {
val roundedTenths = (rating * 10.0).roundToInt()
val whole = roundedTenths / 10
val tenth = (roundedTenths % 10).absoluteValue
return "$whole.$tenth"
}

View file

@ -13,7 +13,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore
@ -38,6 +38,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.nuvio.app.features.details.MetaTrailer
import nuvio.composeapp.generated.resources.*
import nuvio.composeapp.generated.resources.detail_tab_trailer
import nuvio.composeapp.generated.resources.detail_trailer_category_count
import nuvio.composeapp.generated.resources.detail_trailers_title
import org.jetbrains.compose.resources.stringResource
@Composable
fun DetailTrailersSection(
@ -48,10 +53,11 @@ fun DetailTrailersSection(
) {
if (trailers.isEmpty()) return
val trailerLabel = stringResource(Res.string.detail_tab_trailer)
val grouped = remember(trailers) {
linkedMapOf<String, MutableList<MetaTrailer>>().apply {
trailers.forEach { trailer ->
val category = trailer.type.ifBlank { "Trailer" }
val category = trailer.type.ifBlank { trailerLabel }
getOrPut(category) { mutableListOf() }.add(trailer)
}
}
@ -60,7 +66,7 @@ fun DetailTrailersSection(
if (grouped.isEmpty()) return
val initialCategory = remember(grouped) {
grouped.keys.firstOrNull { it.equals("Trailer", ignoreCase = true) }
grouped.keys.firstOrNull { it.equals(trailerLabel, ignoreCase = true) }
?: grouped.keys.first()
}
var selectedCategory by remember(grouped) { mutableStateOf(initialCategory) }
@ -82,7 +88,7 @@ fun DetailTrailersSection(
) {
if (showHeader) {
DetailSectionTitle(
title = "Trailers",
title = stringResource(Res.string.detail_trailers_title),
fullWidth = false,
)
}
@ -131,7 +137,7 @@ fun DetailTrailersSection(
DropdownMenuItem(
text = {
Text(
text = "$category ($count)",
text = stringResource(Res.string.detail_trailer_category_count, category, count),
style = MaterialTheme.typography.bodyMedium,
)
},
@ -152,10 +158,10 @@ fun DetailTrailersSection(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(sizing.cardSpacing),
) {
items(
itemsIndexed(
items = selectedTrailers,
key = { trailer -> "${trailer.type}-${trailer.id}-${trailer.seasonNumber ?: 0}" },
) { trailer ->
key = { index, trailer -> "${trailer.type}-${trailer.id}-${trailer.seasonNumber ?: 0}#$index" },
) { _, trailer ->
TrailerCard(
trailer = trailer,
cardWidth = sizing.cardWidth,

View file

@ -29,8 +29,11 @@ import com.nuvio.app.core.ui.NuvioBottomSheetDivider
import com.nuvio.app.core.ui.NuvioModalBottomSheet
import com.nuvio.app.core.ui.dismissNuvioBottomSheet
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode
import com.nuvio.app.features.details.MetaVideo
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -71,7 +74,11 @@ fun EpisodeWatchedActionSheet(
NuvioBottomSheetDivider()
NuvioBottomSheetActionRow(
icon = Icons.Default.CheckCircle,
title = if (isEpisodeWatched) "Mark as unwatched" else "Mark as watched",
title = if (isEpisodeWatched) {
stringResource(Res.string.episode_mark_unwatched)
} else {
stringResource(Res.string.episode_mark_watched)
},
onClick = {
onToggleWatched()
coroutineScope.launch {
@ -84,9 +91,9 @@ fun EpisodeWatchedActionSheet(
NuvioBottomSheetActionRow(
icon = Icons.Default.DoneAll,
title = if (arePreviousEpisodesWatched) {
"Mark previous as unwatched"
stringResource(Res.string.episode_mark_previous_unwatched)
} else {
"Mark previous as watched"
stringResource(Res.string.episode_mark_previous_watched)
},
onClick = {
onTogglePreviousWatched()
@ -100,9 +107,9 @@ fun EpisodeWatchedActionSheet(
NuvioBottomSheetActionRow(
icon = Icons.Default.PlaylistAddCheckCircle,
title = if (isSeasonWatched) {
"Mark $seasonLabel as unwatched"
stringResource(Res.string.episode_mark_season_unwatched, seasonLabel)
} else {
"Mark $seasonLabel as watched"
stringResource(Res.string.episode_mark_season_watched, seasonLabel)
},
onClick = {
onToggleSeasonWatched()
@ -115,7 +122,7 @@ fun EpisodeWatchedActionSheet(
NuvioBottomSheetDivider()
NuvioBottomSheetActionRow(
icon = Icons.Default.PlayArrow,
title = "Play manually",
title = stringResource(Res.string.play_manually),
onClick = {
onPlayManually()
coroutineScope.launch {
@ -149,8 +156,11 @@ private fun EpisodeActionSheetHeader(
)
Text(
text = buildString {
if (episode.season != null && episode.episode != null) {
append("S${episode.season}E${episode.episode}")
localizedSeasonEpisodeCode(
seasonNumber = episode.season,
episodeNumber = episode.episode,
)?.let {
append(it)
append("")
}
append(seasonLabel)
@ -162,4 +172,3 @@ private fun EpisodeActionSheetHeader(
)
}
}

View file

@ -39,6 +39,8 @@ import com.nuvio.app.features.player.PlatformPlayerSurface
import com.nuvio.app.features.player.PlayerResizeMode
import com.nuvio.app.features.trailer.TrailerPlaybackSource
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -55,7 +57,7 @@ fun TrailerPlayerPopup(
) {
if (!visible) return
val headerType = trailerType.trim().ifBlank { "Trailer" }
val headerType = trailerType.trim().ifBlank { stringResource(Res.string.detail_tab_trailer) }
val headerSubtitle = buildList {
if (trailerTitle.isNotBlank() && !trailerTitle.equals(headerType, ignoreCase = true)) {
add(trailerTitle)
@ -119,7 +121,7 @@ fun TrailerPlayerPopup(
IconButton(onClick = dismissSheet) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = "Close trailer",
contentDescription = stringResource(Res.string.trailer_close),
tint = MaterialTheme.colorScheme.onSurface,
)
}
@ -147,7 +149,7 @@ fun TrailerPlayerPopup(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Unable to play trailer",
text = stringResource(Res.string.trailer_unable_to_play),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
@ -160,7 +162,7 @@ fun TrailerPlayerPopup(
)
if (onRetry != null) {
TextButton(onClick = onRetry) {
Text("Retry")
Text(stringResource(Res.string.action_retry))
}
}
}

View file

@ -1,6 +1,13 @@
package com.nuvio.app.features.downloads
import kotlinx.serialization.Serializable
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.downloads_enqueue_missing_url
import nuvio.composeapp.generated.resources.downloads_enqueue_replaced
import nuvio.composeapp.generated.resources.downloads_enqueue_started
import nuvio.composeapp.generated.resources.downloads_enqueue_unsupported_format
import org.jetbrains.compose.resources.getString
@Serializable
enum class DownloadStatus {
@ -48,22 +55,7 @@ data class DownloadItem(
get() = status == DownloadStatus.Completed && !localFileUri.isNullOrBlank()
val displaySubtitle: String
get() = if (isEpisode) {
buildString {
append("S")
append(seasonNumber)
append("E")
append(episodeNumber)
episodeTitle
?.takeIf { it.isNotBlank() }
?.let {
append("")
append(it)
}
}
} else {
"Movie"
}
get() = episodeTitle.orEmpty()
val progressFraction: Float
get() {
@ -91,11 +83,28 @@ data class DownloadsUiState(
get() = items.filter { it.status == DownloadStatus.Completed }
}
enum class DownloadEnqueueResult(
val toastMessage: String,
) {
Started("Download started"),
Replaced("Replaced previous download"),
MissingUrl("No direct stream link available"),
UnsupportedFormat("Unsupported stream format for downloads"),
enum class DownloadEnqueueResult {
Started,
Replaced,
MissingUrl,
UnsupportedFormat;
fun toastMessage(): String = runBlocking {
when (this@DownloadEnqueueResult) {
Started -> getString(Res.string.downloads_enqueue_started)
Replaced -> getString(Res.string.downloads_enqueue_replaced)
MissingUrl -> getString(Res.string.downloads_enqueue_missing_url)
UnsupportedFormat -> getString(Res.string.downloads_enqueue_unsupported_format)
}
}
}
internal fun List<DownloadItem>.sortedForSeriesDownloads(): List<DownloadItem> =
sortedWith(downloadSeriesEpisodeComparator)
internal val downloadSeriesEpisodeComparator: Comparator<DownloadItem> =
compareBy<DownloadItem> { it.seasonNumber ?: Int.MAX_VALUE }
.thenBy { it.episodeNumber ?: Int.MAX_VALUE }
.thenBy { it.episodeTitle?.trim().orEmpty().lowercase() }
.thenBy { it.title.trim().lowercase() }
.thenBy { it.id }

View file

@ -21,4 +21,6 @@ internal expect object DownloadsPlatformDownloader {
fun removeFile(localFileUri: String?): Boolean
fun removePartialFile(destinationFileName: String): Boolean
fun resolveLocalFileUri(localFileUri: String?, destinationFileName: String): String?
}

View file

@ -1,6 +1,7 @@
package com.nuvio.app.features.downloads
import com.nuvio.app.features.streams.StreamItem
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -9,6 +10,8 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
object DownloadsRepository {
private val _uiState = MutableStateFlow(DownloadsUiState())
@ -40,7 +43,7 @@ object DownloadsRepository {
val normalizedVideoId = videoId?.trim().orEmpty()
if (normalizedVideoId.isBlank()) return null
return _uiState.value.items.firstOrNull { item ->
item.videoId == normalizedVideoId && item.isPlayable && !item.localFileUri.isNullOrBlank()
item.videoId == normalizedVideoId && item.hasPlayableLocalFile()
}
}
@ -61,20 +64,42 @@ object DownloadsRepository {
item.parentMetaId == normalizedParentMetaId &&
item.seasonNumber == seasonNumber &&
item.episodeNumber == episodeNumber &&
item.isPlayable &&
!item.localFileUri.isNullOrBlank()
item.hasPlayableLocalFile()
}
} else {
items.firstOrNull { item ->
item.parentMetaId == normalizedParentMetaId &&
item.seasonNumber == null &&
item.episodeNumber == null &&
item.isPlayable &&
!item.localFileUri.isNullOrBlank()
item.hasPlayableLocalFile()
}
}
}
fun playableLocalFileUri(item: DownloadItem): String? {
ensureLoaded()
if (item.status != DownloadStatus.Completed) return null
val resolvedUri = DownloadsPlatformDownloader.resolveLocalFileUri(
localFileUri = item.localFileUri,
destinationFileName = item.fileName,
) ?: return null
if (resolvedUri != item.localFileUri) {
mutateItem(item.id) { current ->
if (current.fileName == item.fileName) {
current.copy(
localFileUri = resolvedUri,
updatedAtEpochMs = DownloadsClock.nowEpochMs(),
)
} else {
current
}
}
}
return resolvedUri
}
fun enqueueFromStream(
contentType: String,
videoId: String,
@ -114,7 +139,7 @@ object DownloadsRepository {
if (existing != null) {
replacedExisting = true
activeHandles.remove(existing.id)?.cancel()
DownloadsPlatformDownloader.removeFile(existing.localFileUri)
DownloadsPlatformDownloader.removeFile(playableLocalFileUri(existing) ?: existing.localFileUri)
DownloadsPlatformDownloader.removePartialFile(existing.fileName)
currentItems.removeAll { it.id == existing.id }
}
@ -188,6 +213,14 @@ object DownloadsRepository {
}
}
fun pauseActiveDownloads() {
ensureLoaded()
_uiState.value.items
.filter { it.status == DownloadStatus.Downloading }
.map { it.id }
.forEach(::pauseDownload)
}
fun resumeDownload(downloadId: String) {
ensureLoaded()
val item = _uiState.value.items.firstOrNull { it.id == downloadId } ?: return
@ -214,7 +247,7 @@ object DownloadsRepository {
val item = _uiState.value.items.firstOrNull { it.id == downloadId } ?: return
activeHandles.remove(downloadId)?.cancel()
DownloadsPlatformDownloader.removeFile(item.localFileUri)
DownloadsPlatformDownloader.removeFile(playableLocalFileUri(item) ?: item.localFileUri)
DownloadsPlatformDownloader.removePartialFile(item.fileName)
publish(_uiState.value.items.filterNot { it.id == downloadId })
@ -230,9 +263,10 @@ object DownloadsRepository {
return
}
var shouldPersistNormalized = false
val normalized = DownloadsCodec.decodeItems(payload)
.map { item ->
if (item.status == DownloadStatus.Downloading) {
val statusNormalized = if (item.status == DownloadStatus.Downloading) {
item.copy(
status = DownloadStatus.Paused,
errorMessage = null,
@ -240,10 +274,19 @@ object DownloadsRepository {
} else {
item
}
val localUriNormalized = normalizeCompletedLocalFileUri(statusNormalized)
if (localUriNormalized != item) {
shouldPersistNormalized = true
}
localUriNormalized
}
_uiState.value = DownloadsUiState(normalized)
notifyLiveStatusPlatform()
if (shouldPersistNormalized) {
persist()
}
}
private fun startDownload(item: DownloadItem) {
@ -294,7 +337,7 @@ object DownloadsRepository {
} else {
current.copy(
status = DownloadStatus.Failed,
errorMessage = message.ifBlank { "Download failed" },
errorMessage = message.ifBlank { runBlocking { getString(Res.string.download_failed) } },
updatedAtEpochMs = DownloadsClock.nowEpochMs(),
)
}
@ -356,6 +399,26 @@ object DownloadsRepository {
append(nextDownloadOrdinal.toString(36))
}
}
private fun normalizeCompletedLocalFileUri(item: DownloadItem): DownloadItem {
if (item.status != DownloadStatus.Completed) return item
val resolvedUri = DownloadsPlatformDownloader.resolveLocalFileUri(
localFileUri = item.localFileUri,
destinationFileName = item.fileName,
) ?: return item
return if (resolvedUri != item.localFileUri) {
item.copy(localFileUri = resolvedUri)
} else {
item
}
}
private fun DownloadItem.hasPlayableLocalFile(): Boolean =
status == DownloadStatus.Completed &&
DownloadsPlatformDownloader.resolveLocalFileUri(
localFileUri = localFileUri,
destinationFileName = fileName,
) != null
}
@Serializable

View file

@ -35,8 +35,11 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.i18n.localizedByteUnit
import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioScreenHeader
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun DownloadsScreen(
@ -53,7 +56,7 @@ fun DownloadsScreen(
val completedEpisodes = remember(uiState.items) {
uiState.completedItems
.filter { it.isEpisode }
.sortedByDescending { it.updatedAtEpochMs }
.sortedForSeriesDownloads()
}
val selectedShowTitle = remember(selectedShowId, completedEpisodes) {
@ -66,9 +69,9 @@ fun DownloadsScreen(
stickyHeader {
NuvioScreenHeader(
title = if (selectedShowId == null) {
"Downloads"
stringResource(Res.string.compose_settings_root_downloads_title)
} else {
selectedShowTitle ?: "Show Downloads"
selectedShowTitle ?: stringResource(Res.string.downloads_show_downloads)
},
onBack = {
if (selectedShowId != null) {
@ -115,7 +118,7 @@ private fun LazyListScope.downloadsRootContent(
if (activeItems.isNotEmpty()) {
item {
SectionTitle("ACTIVE")
SectionTitle(stringResource(Res.string.downloads_section_active))
}
items(
items = activeItems,
@ -134,7 +137,7 @@ private fun LazyListScope.downloadsRootContent(
if (completedMovies.isNotEmpty()) {
item {
SectionTitle("MOVIES")
SectionTitle(stringResource(Res.string.downloads_section_movies))
}
items(
items = completedMovies,
@ -153,7 +156,7 @@ private fun LazyListScope.downloadsRootContent(
if (completedShows.isNotEmpty()) {
item {
SectionTitle("SHOWS")
SectionTitle(stringResource(Res.string.downloads_section_shows))
}
items(
items = completedShows,
@ -186,7 +189,7 @@ private fun LazyListScope.downloadsRootContent(
overflow = TextOverflow.Ellipsis,
)
Text(
text = "${episodes.size} downloaded episode${if (episodes.size == 1) "" else "s"}",
text = stringResource(Res.string.downloads_episode_count, episodes.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -210,7 +213,7 @@ private fun LazyListScope.downloadsRootContent(
contentAlignment = Alignment.Center,
) {
Text(
text = "No downloads yet",
text = stringResource(Res.string.downloads_empty_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -226,6 +229,7 @@ private fun LazyListScope.downloadsShowContent(
) {
val showEpisodes = episodes
.filter { it.parentMetaId == showId }
.sortedForSeriesDownloads()
val seasons = showEpisodes
.groupBy { it.seasonNumber ?: 0 }
@ -245,7 +249,7 @@ private fun LazyListScope.downloadsShowContent(
contentAlignment = Alignment.Center,
) {
Text(
text = "No completed episodes",
text = stringResource(Res.string.downloads_empty_episodes),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -255,19 +259,17 @@ private fun LazyListScope.downloadsShowContent(
}
seasons.forEach { (seasonNumber, entries) ->
val seasonTitle = if (seasonNumber == 0) {
"Specials"
} else {
"Season $seasonNumber"
}
item {
SectionTitle(seasonTitle)
SectionTitle(
if (seasonNumber == 0) {
stringResource(Res.string.episodes_specials)
} else {
stringResource(Res.string.episodes_season, seasonNumber)
},
)
}
val sortedEpisodes = entries.sortedWith(
compareBy<DownloadItem> { it.episodeNumber ?: Int.MAX_VALUE }
.thenByDescending { it.updatedAtEpochMs },
)
val sortedEpisodes = entries.sortedForSeriesDownloads()
items(
items = sortedEpisodes,
@ -294,6 +296,12 @@ private fun DownloadRow(
onRetry: () -> Unit,
onDelete: () -> Unit,
) {
val displayTitle = item.displayTitle()
val displaySubtitle = downloadDisplaySubtitle(
item = item,
displayTitle = displayTitle,
)
Surface(
modifier = Modifier
.fillMaxWidth()
@ -318,7 +326,7 @@ private fun DownloadRow(
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = item.title,
text = displayTitle,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
@ -326,7 +334,7 @@ private fun DownloadRow(
overflow = TextOverflow.Ellipsis,
)
Text(
text = item.displaySubtitle,
text = displaySubtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@ -345,7 +353,7 @@ private fun DownloadRow(
IconButton(onClick = onPause) {
Icon(
imageVector = Icons.Rounded.Pause,
contentDescription = "Pause",
contentDescription = stringResource(Res.string.compose_action_pause),
)
}
}
@ -353,7 +361,7 @@ private fun DownloadRow(
IconButton(onClick = onResume) {
Icon(
imageVector = Icons.Rounded.PlayArrow,
contentDescription = "Resume",
contentDescription = stringResource(Res.string.action_resume),
)
}
}
@ -361,7 +369,7 @@ private fun DownloadRow(
IconButton(onClick = onRetry) {
Icon(
imageVector = Icons.Rounded.Refresh,
contentDescription = "Retry",
contentDescription = stringResource(Res.string.action_retry),
)
}
}
@ -369,7 +377,7 @@ private fun DownloadRow(
IconButton(onClick = onOpen) {
Icon(
imageVector = Icons.Rounded.PlayArrow,
contentDescription = "Play",
contentDescription = stringResource(Res.string.action_play),
)
}
}
@ -377,7 +385,7 @@ private fun DownloadRow(
IconButton(onClick = onDelete) {
Icon(
imageVector = Icons.Rounded.Delete,
contentDescription = "Delete",
contentDescription = stringResource(Res.string.action_delete),
)
}
}
@ -399,6 +407,36 @@ private fun DownloadRow(
}
}
private fun DownloadItem.displayTitle(): String =
if (isEpisode) {
episodeTitle?.trim()?.takeIf { it.isNotBlank() } ?: title
} else {
title
}
@Composable
private fun downloadDisplaySubtitle(
item: DownloadItem,
displayTitle: String,
): String {
val seasonNumber = item.seasonNumber
val episodeNumber = item.episodeNumber
if (seasonNumber == null || episodeNumber == null) {
return item.displaySubtitle
}
val episodeCode = stringResource(
Res.string.compose_player_episode_code_full,
seasonNumber,
episodeNumber,
)
return listOf(
episodeCode,
item.episodeTitle?.trim().orEmpty().takeIf { it.isNotBlank() && it != displayTitle },
item.title.trim().takeIf { it.isNotBlank() && it != displayTitle },
).filterNotNull().joinToString("")
}
@Composable
private fun SectionTitle(title: String) {
Text(
@ -410,6 +448,7 @@ private fun SectionTitle(title: String) {
)
}
@Composable
private fun statusText(item: DownloadItem): String {
val size = if (item.totalBytes != null && item.totalBytes > 0L) {
"${formatBytes(item.downloadedBytes)} / ${formatBytes(item.totalBytes)}"
@ -418,23 +457,26 @@ private fun statusText(item: DownloadItem): String {
}
return when (item.status) {
DownloadStatus.Downloading -> "Downloading • $size"
DownloadStatus.Paused -> "Paused • $size"
DownloadStatus.Completed -> "Completed • ${formatBytes(item.totalBytes ?: item.downloadedBytes)}"
DownloadStatus.Failed -> item.errorMessage ?: "Failed"
DownloadStatus.Downloading -> stringResource(Res.string.downloads_status_downloading, size)
DownloadStatus.Paused -> stringResource(Res.string.downloads_status_paused, size)
DownloadStatus.Completed -> stringResource(
Res.string.downloads_status_completed,
formatBytes(item.totalBytes ?: item.downloadedBytes),
)
DownloadStatus.Failed -> item.errorMessage ?: stringResource(Res.string.downloads_status_failed)
}
}
private fun formatBytes(bytes: Long): String {
if (bytes <= 0L) return "0 B"
if (bytes <= 0L) return "0 ${localizedByteUnit("B")}"
val kib = 1024.0
val mib = kib * 1024.0
val gib = mib * 1024.0
val value = bytes.toDouble()
return when {
value >= gib -> "${((value / gib) * 10.0).toInt() / 10.0} GB"
value >= mib -> "${((value / mib) * 10.0).toInt() / 10.0} MB"
value >= kib -> "${((value / kib) * 10.0).toInt() / 10.0} KB"
else -> "$bytes B"
value >= gib -> "${((value / gib) * 10.0).toInt() / 10.0} ${localizedByteUnit("GB")}"
value >= mib -> "${((value / mib) * 10.0).toInt() / 10.0} ${localizedByteUnit("MB")}"
value >= kib -> "${((value / kib) * 10.0).toInt() / 10.0} ${localizedByteUnit("KB")}"
else -> "$bytes ${localizedByteUnit("B")}"
}
}

View file

@ -1,7 +1,12 @@
package com.nuvio.app.features.home
import com.nuvio.app.core.i18n.localizedMediaTypeLabel
import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.catalog.supportsPagination
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.home_catalog_default_title
import org.jetbrains.compose.resources.getString
data class HomeCatalogDefinition(
val key: String,
@ -23,7 +28,13 @@ fun buildHomeCatalogDefinitions(addons: List<ManagedAddon>): List<HomeCatalogDef
.map { catalog ->
HomeCatalogDefinition(
key = "${manifest.id}:${catalog.type}:${catalog.id}",
defaultTitle = "${catalog.name} - ${catalog.type.displayLabel()}",
defaultTitle = runBlocking {
getString(
Res.string.home_catalog_default_title,
catalog.name,
localizedMediaTypeLabel(catalog.type),
)
},
addonName = addon.displayTitle,
manifestUrl = addon.manifestUrl,
type = catalog.type,
@ -33,7 +44,4 @@ fun buildHomeCatalogDefinitions(addons: List<ManagedAddon>): List<HomeCatalogDef
}
}.distinctBy(HomeCatalogDefinition::key)
internal fun String.displayLabel(): String =
replaceFirstChar { char ->
if (char.isLowerCase()) char.titlecase() else char.toString()
}
internal fun String.displayLabel(): String = localizedMediaTypeLabel(this)

View file

@ -52,6 +52,7 @@ internal object HomeCatalogParser {
posterShape = meta.string("posterShape").toPosterShape(),
description = meta.string("description"),
releaseInfo = meta.string("releaseInfo"),
rawReleaseDate = meta.string("released"),
imdbRating = meta.string("imdbRating"),
genres = meta.array("genres").mapNotNull { genre ->
genre.jsonPrimitive.contentOrNull?.takeIf { it.isNotBlank() }

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.home
import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.collection.Collection
import com.nuvio.app.features.collection.CollectionRepository
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -10,6 +11,8 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
data class HomeCatalogSettingsItem(
val key: String,
@ -29,12 +32,15 @@ data class HomeCatalogSettingsItem(
data class HomeCatalogSettingsUiState(
val heroEnabled: Boolean = true,
val hideUnreleasedContent: Boolean = false,
val items: List<HomeCatalogSettingsItem> = emptyList(),
) {
val signature: String
get() = buildString {
append(heroEnabled)
append('|')
append(hideUnreleasedContent)
append('|')
append(
items.joinToString(separator = "|") { item ->
"${item.key}:${item.order}:${item.enabled}:${item.heroSourceEnabled}:${item.customTitle}"
@ -52,6 +58,7 @@ internal data class HomeCatalogPreference(
internal data class HomeCatalogSettingsSnapshot(
val heroEnabled: Boolean,
val hideUnreleasedContent: Boolean,
val preferences: Map<String, HomeCatalogPreference>,
)
@ -67,6 +74,7 @@ private data class StoredHomeCatalogPreference(
@Serializable
private data class StoredHomeCatalogSettingsPayload(
val heroEnabled: Boolean = true,
val hideUnreleasedContent: Boolean = false,
val items: List<StoredHomeCatalogPreference> = emptyList(),
)
@ -86,11 +94,13 @@ object HomeCatalogSettingsRepository {
private var collectionDefinitions: List<CollectionCatalogDefinition> = emptyList()
private var preferences: MutableMap<String, StoredHomeCatalogPreference> = mutableMapOf()
private var heroEnabled = true
private var hideUnreleasedContent = false
fun onProfileChanged() {
hasLoaded = false
preferences.clear()
heroEnabled = true
hideUnreleasedContent = false
definitions = emptyList()
collectionDefinitions = emptyList()
_uiState.value = HomeCatalogSettingsUiState()
@ -102,6 +112,7 @@ object HomeCatalogSettingsRepository {
collectionDefinitions = emptyList()
preferences.clear()
heroEnabled = true
hideUnreleasedContent = false
_uiState.value = HomeCatalogSettingsUiState()
}
@ -132,6 +143,7 @@ object HomeCatalogSettingsRepository {
ensureLoaded()
return HomeCatalogSettingsSnapshot(
heroEnabled = heroEnabled,
hideUnreleasedContent = hideUnreleasedContent,
preferences = preferences.mapValues { (_, value) ->
HomeCatalogPreference(
customTitle = value.customTitle,
@ -151,6 +163,15 @@ object HomeCatalogSettingsRepository {
HomeRepository.applyCurrentSettings()
}
fun setHideUnreleasedContent(enabled: Boolean) {
ensureLoaded()
if (hideUnreleasedContent == enabled) return
hideUnreleasedContent = enabled
publish()
persist()
HomeRepository.applyCurrentSettings()
}
fun setHeroSourceEnabled(key: String, enabled: Boolean) {
updatePreference(key) { preference ->
if (!enabled) {
@ -178,6 +199,7 @@ object HomeCatalogSettingsRepository {
fun resetToDefaults() {
ensureLoaded()
heroEnabled = true
hideUnreleasedContent = false
preferences.clear()
normalizePreferences()
publish()
@ -223,7 +245,9 @@ object HomeCatalogSettingsRepository {
if (parsedPayload != null) {
heroEnabled = parsedPayload.heroEnabled
hideUnreleasedContent = parsedPayload.hideUnreleasedContent
preferences = parsedPayload.items.associateBy { it.key }.toMutableMap()
publish()
return
}
@ -232,6 +256,7 @@ object HomeCatalogSettingsRepository {
}.getOrDefault(emptyList())
preferences = legacyItems.associateBy { it.key }.toMutableMap()
publish()
}
private fun normalizePreferences() {
@ -319,6 +344,7 @@ object HomeCatalogSettingsRepository {
_uiState.value = HomeCatalogSettingsUiState(
heroEnabled = heroEnabled,
hideUnreleasedContent = hideUnreleasedContent,
items = items,
)
}
@ -328,6 +354,7 @@ object HomeCatalogSettingsRepository {
json.encodeToString(
StoredHomeCatalogSettingsPayload(
heroEnabled = heroEnabled,
hideUnreleasedContent = hideUnreleasedContent,
items = preferences.values.sortedBy { it.order },
),
),
@ -346,10 +373,12 @@ object HomeCatalogSettingsRepository {
HomeRepository.applyCurrentSettings()
}
private fun selectedHeroSourceCount(excludingKey: String? = null): Int =
preferences.count { (itemKey, preference) ->
itemKey != excludingKey && preference.heroSourceEnabled
private fun selectedHeroSourceCount(excludingKey: String? = null): Int {
val catalogKeys = definitions.mapTo(mutableSetOf()) { it.key }
return preferences.count { (itemKey, preference) ->
itemKey != excludingKey && itemKey in catalogKeys && preference.heroSourceEnabled
}
}
private fun move(
key: String,
@ -406,26 +435,32 @@ object HomeCatalogSettingsRepository {
)
}
}
return SyncHomeCatalogPayload(items = items)
return SyncHomeCatalogPayload(
hideUnreleasedContent = hideUnreleasedContent,
items = items,
)
}
fun applyFromRemote(payload: SyncHomeCatalogPayload) {
ensureLoaded()
val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled }
preferences = payload.items.associate { item ->
val key = if (item.isCollection) {
"collection_${item.collectionId}"
} else {
"${item.addonId}:${item.type}:${item.catalogId}"
}
key to StoredHomeCatalogPreference(
key = key,
customTitle = item.customTitle,
enabled = item.enabled,
heroSourceEnabled = existingHeroState[key] ?: true,
order = item.order,
)
}.toMutableMap()
hideUnreleasedContent = payload.hideUnreleasedContent
if (payload.items.isNotEmpty()) {
val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled }
preferences = payload.items.associate { item ->
val key = if (item.isCollection) {
"collection_${item.collectionId}"
} else {
"${item.addonId}:${item.type}:${item.catalogId}"
}
key to StoredHomeCatalogPreference(
key = key,
customTitle = item.customTitle,
enabled = item.enabled,
heroSourceEnabled = existingHeroState[key] ?: true,
order = item.order,
)
}.toMutableMap()
}
hasLoaded = true
publish()
persist()
@ -478,7 +513,7 @@ internal fun buildCollectionDefinitions(collections: List<Collection>): List<Col
key = "collection_${collection.id}",
collectionId = collection.id,
title = collection.title,
subtitle = "${collection.folders.size} folder${if (collection.folders.size != 1) "s" else ""}",
subtitle = runBlocking { getString(Res.string.collections_folder_count, collection.folders.size) },
isPinnedToTop = collection.pinToTop,
)
}

View file

@ -41,6 +41,7 @@ data class SyncCatalogItem(
@Serializable
data class SyncHomeCatalogPayload(
@SerialName("hide_unreleased_content") val hideUnreleasedContent: Boolean = false,
val items: List<SyncCatalogItem> = emptyList(),
)
@ -101,7 +102,10 @@ object HomeCatalogSettingsSyncService {
}
if (remotePayload.items.isEmpty()) {
log.i { "pullFromServer — remote has empty items, preserving local" }
log.i { "pullFromServer — remote has empty items, preserving local catalog order" }
isSyncingFromRemote = true
HomeCatalogSettingsRepository.applyFromRemote(remotePayload)
isSyncingFromRemote = false
val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload()
if (localPayload.items.isNotEmpty()) {
pushToRemote(profileId)

View file

@ -2,6 +2,7 @@ package com.nuvio.app.features.home
import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.catalog.fetchCatalogPage
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -145,13 +146,17 @@ object HomeRepository {
) {
val snapshot = HomeCatalogSettingsRepository.snapshot()
val preferences = snapshot.preferences
val todayIsoDate = if (snapshot.hideUnreleasedContent) CurrentDateProvider.todayIsoDate() else null
fun HomeCatalogSection.withReleaseFilter(): HomeCatalogSection =
if (todayIsoDate == null) this else filterReleasedItems(todayIsoDate)
val sections = currentDefinitions
.sortedBy { definition -> preferences[definition.key]?.order ?: Int.MAX_VALUE }
.mapNotNull { definition ->
val preference = preferences[definition.key]
if (preference?.enabled == false) return@mapNotNull null
val section = cachedSections[definition.key] ?: return@mapNotNull null
val section = cachedSections[definition.key]?.withReleaseFilter() ?: return@mapNotNull null
if (section.items.isEmpty()) return@mapNotNull null
val customTitle = preference?.customTitle.orEmpty()
section.copy(
@ -164,6 +169,7 @@ object HomeRepository {
currentDefinitions
.filter { definition -> preferences[definition.key]?.heroSourceEnabled != false }
.mapNotNull { definition -> cachedSections[definition.key] }
.map { section -> section.withReleaseFilter() }
.flatMap { section -> section.items }
.distinctBy { item -> "${item.type}:${item.id}" }
.shuffled(heroRandom)

View file

@ -16,8 +16,10 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.network.NetworkStatusRepository
import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding
import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.nextReleasedEpisodeAfter
@ -29,6 +31,10 @@ import com.nuvio.app.features.home.components.HomeHeroSection
import com.nuvio.app.features.home.components.HomeSkeletonHero
import com.nuvio.app.features.home.components.HomeSkeletonRow
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap
import com.nuvio.app.features.trakt.shouldUseTraktProgress
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.CachedInProgressItem
import com.nuvio.app.features.watchprogress.CachedNextUpItem
@ -36,15 +42,19 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching
import com.nuvio.app.features.watchprogress.nextUpDismissKey
import com.nuvio.app.features.watchprogress.WatchProgressClock
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.buildContinueWatchingEpisodeSubtitle
import com.nuvio.app.features.watchprogress.toContinueWatchingItem
import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem
import com.nuvio.app.features.watching.application.WatchingState
import com.nuvio.app.features.watching.domain.WatchingContentRef
import com.nuvio.app.features.watching.domain.isReleasedBy
import com.nuvio.app.features.collection.CollectionRepository
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.home.components.HomeCollectionRowSection
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
import kotlinx.coroutines.async
@ -54,6 +64,8 @@ import kotlinx.coroutines.sync.withPermit
import com.nuvio.app.features.home.components.ContinueWatchingLayout
import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWidth
import com.nuvio.app.features.home.components.rememberContinueWatchingLayout
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun HomeScreen(
@ -83,6 +95,10 @@ fun HomeScreen(
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
val traktSettingsUiState by remember {
TraktSettingsRepository.ensureLoaded()
TraktSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val isTraktAuthenticated by remember {
TraktAuthRepository.ensureLoaded()
TraktAuthRepository.isAuthenticated
@ -110,17 +126,31 @@ fun HomeScreen(
}
}
val effectiveWatchProgressEntries = remember(watchProgressUiState.entries, isTraktAuthenticated) {
if (!isTraktAuthenticated) {
watchProgressUiState.entries
} else {
val cutoffMs = WatchProgressClock.nowEpochMs() - (TRAKT_CONTINUE_WATCHING_DAYS_CAP_DEFAULT.toLong() * 24L * 60L * 60L * 1000L)
watchProgressUiState.entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs }
}
val isTraktProgressActive = remember(
isTraktAuthenticated,
traktSettingsUiState.watchProgressSource,
) {
shouldUseTraktProgress(
isAuthenticated = isTraktAuthenticated,
source = traktSettingsUiState.watchProgressSource,
)
}
val effectiveWatchedItems = remember(watchedUiState.items, isTraktAuthenticated) {
if (isTraktAuthenticated) emptyList() else watchedUiState.items
val effectiveWatchProgressEntries = remember(
watchProgressUiState.entries,
isTraktProgressActive,
traktSettingsUiState.continueWatchingDaysCap,
) {
filterEntriesForTraktContinueWatchingWindow(
entries = watchProgressUiState.entries,
isTraktProgressActive = isTraktProgressActive,
daysCap = traktSettingsUiState.continueWatchingDaysCap,
nowEpochMs = WatchProgressClock.nowEpochMs(),
)
}
val effectiveWatchedItems = remember(watchedUiState.items, isTraktProgressActive) {
if (isTraktProgressActive) emptyList() else watchedUiState.items
}
val latestCompletedBySeries = remember(effectiveWatchProgressEntries, effectiveWatchedItems, continueWatchingPreferences.upNextFromFurthestEpisode) {
@ -140,6 +170,9 @@ fun HomeScreen(
)
}
}
val completedSeriesContentIds = remember(completedSeriesCandidates) {
completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id }
}
val visibleContinueWatchingEntries = remember(
effectiveWatchProgressEntries,
latestCompletedBySeries,
@ -149,14 +182,34 @@ fun HomeScreen(
latestCompletedBySeries = latestCompletedBySeries,
)
}
var nextUpItemsBySeries by remember { mutableStateOf<Map<String, Pair<Long, ContinueWatchingItem>>>(emptyMap()) }
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val activeProfileId = profileState.activeProfile?.profileIndex ?: 1
val cachedSnapshots = remember { ContinueWatchingEnrichmentCache.getSnapshots() }
val cachedNextUpItems = remember(cachedSnapshots.first, continueWatchingPreferences.dismissedNextUpKeys) {
var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf<Map<String, Pair<Long, ContinueWatchingItem>>>(emptyMap()) }
val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() }
val cachedNextUpItems = remember(
cachedSnapshots.first,
continueWatchingPreferences.dismissedNextUpKeys,
completedSeriesContentIds,
isTraktProgressActive,
continueWatchingPreferences.showUnairedNextUp,
watchedUiState.isLoaded,
) {
cachedSnapshots.first.mapNotNull { cached ->
if (
!isTraktProgressActive &&
watchedUiState.isLoaded &&
cached.contentId !in completedSeriesContentIds
) {
return@mapNotNull null
}
if (nextUpDismissKey(cached.contentId, cached.seedSeason, cached.seedEpisode) in continueWatchingPreferences.dismissedNextUpKeys) {
return@mapNotNull null
}
if (!cached.hasAired && !continueWatchingPreferences.showUnairedNextUp) {
return@mapNotNull null
}
val item = cached.toContinueWatchingItem() ?: return@mapNotNull null
cached.contentId to (cached.sortTimestamp to item)
}.toMap()
@ -235,7 +288,11 @@ fun HomeScreen(
HomeCatalogSettingsRepository.syncCollections(collections)
}
LaunchedEffect(completedSeriesCandidates, metaProviderKey) {
LaunchedEffect(
completedSeriesCandidates,
metaProviderKey,
continueWatchingPreferences.showUnairedNextUp,
) {
if (completedSeriesCandidates.isEmpty()) {
nextUpItemsBySeries = emptyMap()
return@LaunchedEffect
@ -256,7 +313,7 @@ fun HomeScreen(
seasonNumber = completedEntry.seasonNumber,
episodeNumber = completedEntry.episodeNumber,
todayIsoDate = todayIsoDate,
showUnairedNextUp = isTraktAuthenticated,
showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp,
) ?: return@withPermit null
val item = completedEntry.toContinueWatchingSeed(meta)
.toUpNextContinueWatchingItem(nextEpisode)
@ -284,6 +341,10 @@ fun HomeScreen(
episodeTitle = item.episodeTitle,
episodeThumbnail = item.episodeThumbnail,
pauseDescription = item.pauseDescription,
released = item.released,
hasAired = item.released?.let { released ->
isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = released)
} ?: true,
lastWatched = pair.first,
sortTimestamp = pair.first,
seedSeason = item.nextUpSeedSeasonNumber,
@ -346,12 +407,19 @@ fun HomeScreen(
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val homeSectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value)
val continueWatchingLayout = rememberContinueWatchingLayout(maxWidth.value)
val nativeBottomNavigationOverlayHeight =
if (LocalNuvioBottomNavigationOverlayPadding.current > 0.dp) {
nuvioSafeBottomPadding()
} else {
0.dp
}
val mobileHeroBelowSectionHeightHint = remember(
maxWidth.value,
continueWatchingPreferences.isVisible,
continueWatchingPreferences.style,
continueWatchingItems.isNotEmpty(),
continueWatchingLayout,
nativeBottomNavigationOverlayHeight,
) {
heroMobileBelowSectionHeightHint(
maxWidthDp = maxWidth.value,
@ -359,6 +427,7 @@ fun HomeScreen(
hasContinueWatchingItems = continueWatchingItems.isNotEmpty(),
continueWatchingStyle = continueWatchingPreferences.style,
continueWatchingLayout = continueWatchingLayout,
bottomNavigationOverlayHeight = nativeBottomNavigationOverlayHeight,
)
}
@ -402,6 +471,8 @@ fun HomeScreen(
HomeContinueWatchingSection(
items = continueWatchingItems,
style = continueWatchingPreferences.style,
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
blurNextUp = continueWatchingPreferences.blurNextUp,
modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding,
layout = continueWatchingLayout,
@ -413,8 +484,8 @@ fun HomeScreen(
item {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = "No active addons",
message = "Install and validate at least one addon before loading catalog rows on Home.",
title = stringResource(Res.string.compose_search_empty_no_active_addons_title),
message = stringResource(Res.string.home_empty_no_active_addons_message),
)
}
}
@ -425,6 +496,8 @@ fun HomeScreen(
HomeContinueWatchingSection(
items = continueWatchingItems,
style = continueWatchingPreferences.style,
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
blurNextUp = continueWatchingPreferences.blurNextUp,
modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding,
layout = continueWatchingLayout,
@ -453,9 +526,9 @@ fun HomeScreen(
} else {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = "No home rows available",
title = stringResource(Res.string.home_empty_no_rows_title),
message = homeUiState.errorMessage
?: "Installed addons do not currently expose board-compatible catalogs without required extras.",
?: stringResource(Res.string.home_empty_no_rows_message),
)
}
}
@ -467,6 +540,8 @@ fun HomeScreen(
HomeContinueWatchingSection(
items = continueWatchingItems,
style = continueWatchingPreferences.style,
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
blurNextUp = continueWatchingPreferences.blurNextUp,
modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding,
layout = continueWatchingLayout,
@ -518,7 +593,21 @@ fun HomeScreen(
}
private const val HOME_CATALOG_PREVIEW_LIMIT = 18
private const val TRAKT_CONTINUE_WATCHING_DAYS_CAP_DEFAULT = 60
private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L
internal fun filterEntriesForTraktContinueWatchingWindow(
entries: List<WatchProgressEntry>,
isTraktProgressActive: Boolean,
daysCap: Int,
nowEpochMs: Long,
): List<WatchProgressEntry> {
if (!isTraktProgressActive) return entries
val normalizedDaysCap = normalizeTraktContinueWatchingDaysCap(daysCap)
if (normalizedDaysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return entries
val cutoffMs = nowEpochMs - (normalizedDaysCap.toLong() * MILLIS_PER_DAY)
return entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs }
}
private fun heroMobileBelowSectionHeightHint(
maxWidthDp: Float,
@ -526,14 +615,16 @@ private fun heroMobileBelowSectionHeightHint(
hasContinueWatchingItems: Boolean,
continueWatchingStyle: ContinueWatchingSectionStyle,
continueWatchingLayout: ContinueWatchingLayout,
bottomNavigationOverlayHeight: Dp,
): Dp? {
if (maxWidthDp >= 600f || !continueWatchingVisible || !hasContinueWatchingItems) return null
return when (continueWatchingStyle) {
val sectionHeight = when (continueWatchingStyle) {
ContinueWatchingSectionStyle.Wide -> continueWatchingLayout.wideCardHeight + 56.dp
ContinueWatchingSectionStyle.Poster ->
continueWatchingLayout.posterCardHeight + continueWatchingLayout.posterTitleBlockHeight + 70.dp
}
return sectionHeight + bottomNavigationOverlayHeight
}
internal fun buildHomeContinueWatchingItems(
@ -541,6 +632,13 @@ internal fun buildHomeContinueWatchingItems(
cachedInProgressByVideoId: Map<String, ContinueWatchingItem> = emptyMap(),
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
): List<ContinueWatchingItem> {
val inProgressSeriesIds = visibleEntries
.asSequence()
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
.map { entry -> entry.parentMetaId }
.filter(String::isNotBlank)
.toSet()
return buildList {
addAll(
visibleEntries.map { entry ->
@ -553,7 +651,8 @@ internal fun buildHomeContinueWatchingItems(
},
)
addAll(
nextUpItemsBySeries.values.map { (lastUpdatedEpochMs, item) ->
nextUpItemsBySeries.values.mapNotNull { (lastUpdatedEpochMs, item) ->
if (item.parentMetaId in inProgressSeriesIds) return@mapNotNull null
HomeContinueWatchingCandidate(
lastUpdatedEpochMs = lastUpdatedEpochMs,
item = item,
@ -567,7 +666,7 @@ internal fun buildHomeContinueWatchingItems(
.thenByDescending { it.isProgressEntry },
)
.filter { candidate -> candidate.item.shouldDisplayInContinueWatching() }
.distinctBy { it.item.videoId }
.distinctBy { candidate -> candidate.item.parentMetaId.ifBlank { candidate.item.videoId } }
.map(HomeContinueWatchingCandidate::item)
}
@ -606,25 +705,16 @@ private fun ContinueWatchingItem.shouldDisplayInContinueWatching(): Boolean =
isNextUp || progressFraction < 0.995f
private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? {
val subtitle = buildString {
append("Up Next")
if (season != null && episode != null) {
append(" • S")
append(season)
append("E")
append(episode)
}
episodeTitle?.takeIf { it.isNotBlank() }?.let {
append("")
append(it)
}
}
return ContinueWatchingItem(
parentMetaId = contentId,
parentMetaType = contentType,
videoId = videoId,
title = name,
subtitle = subtitle,
subtitle = buildContinueWatchingEpisodeSubtitle(
seasonNumber = season,
episodeNumber = episode,
episodeTitle = episodeTitle,
),
imageUrl = episodeThumbnail ?: backdrop ?: poster,
logo = logo,
poster = poster,
@ -634,6 +724,7 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? {
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
pauseDescription = pauseDescription,
released = released,
isNextUp = true,
nextUpSeedSeasonNumber = seedSeason,
nextUpSeedEpisodeNumber = seedEpisode,
@ -645,20 +736,6 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? {
}
private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem {
val subtitle = if (season != null && episode != null) {
buildString {
append("S")
append(season)
append("E")
append(episode)
episodeTitle?.takeIf { it.isNotBlank() }?.let {
append("")
append(it)
}
}
} else {
"Movie"
}
val explicitResumeProgressFraction = progressPercent
?.takeIf { duration <= 0L && it > 0f }
?.let { (it / 100f).coerceIn(0f, 1f) }
@ -675,7 +752,11 @@ private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem
parentMetaType = contentType,
videoId = videoId,
title = name,
subtitle = subtitle,
subtitle = buildContinueWatchingEpisodeSubtitle(
seasonNumber = season,
episodeNumber = episode,
episodeTitle = episodeTitle,
),
imageUrl = episodeThumbnail ?: backdrop ?: poster,
logo = logo,
poster = poster,
@ -710,5 +791,6 @@ private fun ContinueWatchingItem.withFallbackMetadata(
episodeTitle = episodeTitle ?: fallback.episodeTitle,
episodeThumbnail = episodeThumbnail ?: fallback.episodeThumbnail,
pauseDescription = pauseDescription ?: fallback.pauseDescription,
released = released ?: fallback.released,
)
}

Some files were not shown because too many files have changed in this diff Show more