Merge branch 'cmp-rewrite' into desktop

This commit is contained in:
tapframe 2026-05-12 13:32:26 +05:30
commit fdde8ba1c2
252 changed files with 29587 additions and 3667 deletions

2
.gitignore vendored
View file

@ -25,3 +25,5 @@ keystore/
scripts/build-distribution.sh scripts/build-distribution.sh
asset asset
scripts/scrape_android_compose_animation_docs.py 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

@ -75,6 +75,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 { outDir.resolve("com/nuvio/app/core/build").apply {
mkdirs() mkdirs()
resolve("AppVersionConfig.kt").writeText( resolve("AppVersionConfig.kt").writeText(
@ -96,6 +110,7 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|package com.nuvio.app.features.settings |package com.nuvio.app.features.settings
| |
|object CommunityConfig { |object CommunityConfig {
| const val CONTRIBUTIONS_URL = "${props.getProperty("CONTRIBUTIONS_URL", "")}"
| const val DONATIONS_BASE_URL = "${props.getProperty("DONATIONS_BASE_URL", "")}" | const val DONATIONS_BASE_URL = "${props.getProperty("DONATIONS_BASE_URL", "")}"
| const val DONATIONS_DONATE_URL = "${props.getProperty("DONATIONS_DONATE_URL", "")}" | const val DONATIONS_DONATE_URL = "${props.getProperty("DONATIONS_DONATE_URL", "")}"
|} |}
@ -256,6 +271,7 @@ kotlin {
} }
androidMain.dependencies { androidMain.dependencies {
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.work.runtime) implementation(libs.androidx.work.runtime)
@ -281,6 +297,7 @@ kotlin {
commonMain.dependencies { commonMain.dependencies {
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.coil.network.ktor3) implementation(libs.coil.network.ktor3)
implementation(libs.coil.svg)
implementation("dev.chrisbanes.haze:haze:1.7.2") implementation("dev.chrisbanes.haze:haze:1.7.2")
implementation(libs.compose.runtime) implementation(libs.compose.runtime)
implementation(libs.compose.foundation) implementation(libs.compose.foundation)
@ -307,12 +324,13 @@ kotlin {
afterEvaluate { afterEvaluate {
dependencies { dependencies {
add("fullImplementation", libs.quickjs.kt) add("fullImplementation", files("libs/quickjs-kt-android-1.0.5-nuvio.aar"))
add("fullImplementation", libs.ksoup) add("fullImplementation", libs.ksoup)
} }
} }
dependencies { dependencies {
coreLibraryDesugaring(libs.desugar.jdk.libs)
debugImplementation(libs.compose.uiTooling) debugImplementation(libs.compose.uiTooling)
} }
@ -418,6 +436,7 @@ android {
} }
} }
compileOptions { compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
} }

Binary file not shown.

View file

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

View file

@ -7,11 +7,13 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.SystemBarStyle import androidx.activity.SystemBarStyle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.nuvio.app.core.auth.AuthStorage import com.nuvio.app.core.auth.AuthStorage
import com.nuvio.app.core.deeplink.handleAppUrl import com.nuvio.app.core.deeplink.handleAppUrl
import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner
import com.nuvio.app.features.addons.AddonStorage import com.nuvio.app.features.addons.AddonStorage
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
import com.nuvio.app.features.collection.CollectionStorage import com.nuvio.app.features.collection.CollectionStorage
import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform
import com.nuvio.app.features.downloads.DownloadsPlatformDownloader import com.nuvio.app.features.downloads.DownloadsPlatformDownloader
@ -34,6 +36,7 @@ import com.nuvio.app.features.settings.ThemeSettingsStorage
import com.nuvio.app.features.trakt.TraktAuthStorage import com.nuvio.app.features.trakt.TraktAuthStorage
import com.nuvio.app.features.trakt.TraktCommentsStorage import com.nuvio.app.features.trakt.TraktCommentsStorage
import com.nuvio.app.features.trakt.TraktLibraryStorage 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.tmdb.TmdbSettingsStorage
import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform
import com.nuvio.app.core.ui.PosterCardStyleStorage import com.nuvio.app.core.ui.PosterCardStyleStorage
@ -44,7 +47,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage
import com.nuvio.app.features.watchprogress.ResumePromptStorage import com.nuvio.app.features.watchprogress.ResumePromptStorage
import com.nuvio.app.features.watchprogress.WatchProgressStorage import com.nuvio.app.features.watchprogress.WatchProgressStorage
class MainActivity : ComponentActivity() { class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen() installSplashScreen()
enableEdgeToEdge( enableEdgeToEdge(
@ -52,6 +55,7 @@ class MainActivity : ComponentActivity() {
scrim = 0xFF020404.toInt(), scrim = 0xFF020404.toInt(),
), ),
) )
ThemeSettingsStorage.initialize(applicationContext)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
window.setBackgroundDrawableResource(R.color.nuvio_background) window.setBackgroundDrawableResource(R.color.nuvio_background)
AddonStorage.initialize(applicationContext) AddonStorage.initialize(applicationContext)
@ -66,13 +70,13 @@ class MainActivity : ComponentActivity() {
ProfilePinCacheStorage.initialize(applicationContext) ProfilePinCacheStorage.initialize(applicationContext)
SearchHistoryStorage.initialize(applicationContext) SearchHistoryStorage.initialize(applicationContext)
SeasonViewModeStorage.initialize(applicationContext) SeasonViewModeStorage.initialize(applicationContext)
ThemeSettingsStorage.initialize(applicationContext)
PosterCardStyleStorage.initialize(applicationContext) PosterCardStyleStorage.initialize(applicationContext)
TmdbSettingsStorage.initialize(applicationContext) TmdbSettingsStorage.initialize(applicationContext)
MdbListSettingsStorage.initialize(applicationContext) MdbListSettingsStorage.initialize(applicationContext)
TraktAuthStorage.initialize(applicationContext) TraktAuthStorage.initialize(applicationContext)
TraktCommentsStorage.initialize(applicationContext) TraktCommentsStorage.initialize(applicationContext)
TraktLibraryStorage.initialize(applicationContext) TraktLibraryStorage.initialize(applicationContext)
TraktSettingsStorage.initialize(applicationContext)
ContinueWatchingPreferencesStorage.initialize(applicationContext) ContinueWatchingPreferencesStorage.initialize(applicationContext)
ResumePromptStorage.initialize(applicationContext) ResumePromptStorage.initialize(applicationContext)
ContinueWatchingEnrichmentStorage.initialize(applicationContext) ContinueWatchingEnrichmentStorage.initialize(applicationContext)
@ -80,6 +84,7 @@ class MainActivity : ComponentActivity() {
WatchProgressStorage.initialize(applicationContext) WatchProgressStorage.initialize(applicationContext)
StreamLinkCacheStorage.initialize(applicationContext) StreamLinkCacheStorage.initialize(applicationContext)
PluginStorage.initialize(applicationContext) PluginStorage.initialize(applicationContext)
CollectionMobileSettingsStorage.initialize(applicationContext)
CollectionStorage.initialize(applicationContext) CollectionStorage.initialize(applicationContext)
DownloadsStorage.initialize(applicationContext) DownloadsStorage.initialize(applicationContext)
DownloadsPlatformDownloader.initialize(applicationContext) DownloadsPlatformDownloader.initialize(applicationContext)

View file

@ -16,12 +16,15 @@ internal actual object PlatformLocalAccountDataCleaner {
"nuvio_mdblist_settings", "nuvio_mdblist_settings",
"nuvio_trakt_auth", "nuvio_trakt_auth",
"nuvio_trakt_library", "nuvio_trakt_library",
"nuvio_trakt_settings",
"nuvio_watched", "nuvio_watched",
"nuvio_stream_link_cache", "nuvio_stream_link_cache",
"nuvio_continue_watching_preferences", "nuvio_continue_watching_preferences",
"nuvio_episode_release_notifications", "nuvio_episode_release_notifications",
"nuvio_episode_release_notifications_platform", "nuvio_episode_release_notifications_platform",
"nuvio_watch_progress", "nuvio_watch_progress",
"nuvio_collection_mobile_settings",
"nuvio_collections",
"nuvio_plugins", "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, url: String,
headers: Map<String, String>, headers: Map<String, String>,
body: String, body: String,
followRedirects: Boolean,
): RawHttpResponse = ): RawHttpResponse =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val normalizedMethod = method.uppercase() val normalizedMethod = method.uppercase()
@ -228,7 +229,16 @@ actual suspend fun httpRequestRaw(
builder.method(normalizedMethod, null) builder.method(normalizedMethod, null)
}.build() }.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( RawHttpResponse(
status = response.code, status = response.code,
statusText = response.message, statusText = response.message,

View file

@ -0,0 +1,26 @@
package com.nuvio.app.features.collection
import android.content.Context
import android.content.SharedPreferences
import com.nuvio.app.core.storage.ProfileScopedKey
actual object CollectionMobileSettingsStorage {
private const val preferencesName = "nuvio_collection_mobile_settings"
private const val payloadKey = "collection_mobile_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

@ -12,12 +12,13 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.nuvio.app.core.deeplink.buildDownloadsDeepLinkUrl 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 import kotlin.math.abs
internal actual object DownloadsLiveStatusPlatform { internal actual object DownloadsLiveStatusPlatform {
private const val channelId = "downloads_live_status" 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 notificationsPrefName = "nuvio_download_live_notifications"
private const val trackedDownloadIdsKey = "tracked_download_ids" private const val trackedDownloadIdsKey = "tracked_download_ids"
@ -143,7 +144,7 @@ internal actual object DownloadsLiveStatusPlatform {
.setProgress(0, 0, false) .setProgress(0, 0, false)
.addAction( .addAction(
0, 0,
"Resume", runBlocking { getString(Res.string.action_resume) },
buildActionPendingIntent( buildActionPendingIntent(
context = context, context = context,
action = DownloadsNotificationActionReceiver.actionResume, action = DownloadsNotificationActionReceiver.actionResume,
@ -163,15 +164,15 @@ internal actual object DownloadsLiveStatusPlatform {
val downloaded = formatBytes(item.downloadedBytes) val downloaded = formatBytes(item.downloadedBytes)
val total = item.totalBytes?.let(::formatBytes) val total = item.totalBytes?.let(::formatBytes)
if (total != null) { if (total != null) {
"Downloading $detail$downloaded / $total" runBlocking { getString(Res.string.downloads_live_downloading_with_total, detail, downloaded, total) }
} else { } else {
"Downloading $detail$downloaded" runBlocking { getString(Res.string.downloads_live_downloading, detail, downloaded) }
} }
} }
DownloadStatus.Paused -> "Paused $detail" DownloadStatus.Paused -> runBlocking { getString(Res.string.downloads_live_paused, detail) }
DownloadStatus.Failed -> item.errorMessage?.takeIf { it.isNotBlank() } ?: "Download failed" DownloadStatus.Failed -> item.errorMessage?.takeIf { it.isNotBlank() } ?: runBlocking { getString(Res.string.downloads_live_failed) }
DownloadStatus.Completed -> "Download completed" DownloadStatus.Completed -> runBlocking { getString(Res.string.downloads_live_completed) }
} }
} }
@ -224,8 +225,12 @@ internal actual object DownloadsLiveStatusPlatform {
if (manager.getNotificationChannel(channelId) != null) return if (manager.getNotificationChannel(channelId) != null) return
manager.createNotificationChannel( manager.createNotificationChannel(
NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW).apply { NotificationChannel(
description = channelDescription 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.SupervisorJob
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import okhttp3.Call import okhttp3.Call
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.net.URI import java.net.URI
@ -44,7 +47,7 @@ internal actual object DownloadsPlatformDownloader {
scope.launch { scope.launch {
val context = appContext val context = appContext
if (context == null) { if (context == null) {
onFailure("Download system is not initialized") onFailure(runBlocking { getString(Res.string.downloads_error_not_initialized) })
return@launch return@launch
} }
@ -69,7 +72,9 @@ internal actual object DownloadsPlatformDownloader {
var attemptedRangeRequest = resumeFromBytes > 0L var attemptedRangeRequest = resumeFromBytes > 0L
var httpRequest = buildRequest(if (attemptedRangeRequest) resumeFromBytes else null) var httpRequest = buildRequest(if (attemptedRangeRequest) resumeFromBytes else null)
call = downloadHttpClient.newCall(httpRequest) 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) { if (attemptedRangeRequest && response.code == 416) {
response.close() response.close()
@ -78,12 +83,18 @@ internal actual object DownloadsPlatformDownloader {
attemptedRangeRequest = false attemptedRangeRequest = false
httpRequest = buildRequest(null) httpRequest = buildRequest(null)
call = downloadHttpClient.newCall(httpRequest) 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 -> response.use { response ->
if (!response.isSuccessful) { 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 val isPartialResume = attemptedRangeRequest && response.code == 206 && resumeFromBytes > 0L
@ -94,7 +105,9 @@ internal actual object DownloadsPlatformDownloader {
tempFile.delete() 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( val totalBytes = resolveTotalBytes(
startingBytes = startingBytes, startingBytes = startingBytes,
isPartialResume = isPartialResume, isPartialResume = isPartialResume,
@ -131,7 +144,7 @@ internal actual object DownloadsPlatformDownloader {
onSuccess(destination.toURI().toString(), totalBytes ?: finalSize) onSuccess(destination.toURI().toString(), totalBytes ?: finalSize)
} }
} catch (error: Throwable) { } 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 if (!tempFile.exists()) return true
return runCatching { tempFile.delete() }.getOrDefault(false) 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( private class AndroidDownloadsTaskHandle(

View file

@ -23,6 +23,9 @@ import androidx.work.WorkManager
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.engine.android.Android 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.plugins.HttpTimeout
import io.ktor.client.request.get import io.ktor.client.request.get
import java.time.LocalDate import java.time.LocalDate
@ -285,13 +288,13 @@ internal actual object EpisodeReleaseNotificationPlatform {
val channel = NotificationChannel( val channel = NotificationChannel(
channelId, channelId,
"Episode Releases", runBlocking { getString(Res.string.notifications_channel_episode_releases_name) },
NotificationManager.IMPORTANCE_DEFAULT, NotificationManager.IMPORTANCE_DEFAULT,
).apply { ).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) notificationManager.createNotificationChannel(channel)
} }
private fun uniqueWorkName(requestId: String): String = "$workTag:$requestId" 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.viewinterop.AndroidView
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle 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.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.C import androidx.media3.common.C
@ -55,7 +58,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.util.Locale
private const val TAG = "NuvioPlayer" private const val TAG = "NuvioPlayer"
@ -177,6 +179,10 @@ actual fun PlatformPlayerSurface(
var currentSubtitleStyle by remember { mutableStateOf(SubtitleStyleState.DEFAULT) } var currentSubtitleStyle by remember { mutableStateOf(SubtitleStyleState.DEFAULT) }
var subtitleSelectionJob by remember { mutableStateOf<Job?>(null) } var subtitleSelectionJob by remember { mutableStateOf<Job?>(null) }
fun syncPlayerViewKeepScreenOn() {
playerViewRef?.keepScreenOn = exoPlayer.shouldKeepPlayerScreenOn()
}
DisposableEffect(exoPlayer) { DisposableEffect(exoPlayer) {
PlayerPictureInPictureManager.registerPausePlaybackCallback { PlayerPictureInPictureManager.registerPausePlaybackCallback {
exoPlayer.pause() exoPlayer.pause()
@ -184,7 +190,8 @@ actual fun PlatformPlayerSurface(
val listener = object : Player.Listener { val listener = object : Player.Listener {
override fun onPlayerError(error: PlaybackException) { 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) { override fun onPlaybackStateChanged(playbackState: Int) {
@ -200,10 +207,12 @@ actual fun PlatformPlayerSurface(
latestOnError.value(null) latestOnError.value(null)
exoPlayer.logCurrentTracks("STATE_READY") exoPlayer.logCurrentTracks("STATE_READY")
} }
syncPlayerViewKeepScreenOn()
latestOnSnapshot.value(exoPlayer.snapshot()) latestOnSnapshot.value(exoPlayer.snapshot())
} }
override fun onIsPlayingChanged(isPlaying: Boolean) { override fun onIsPlayingChanged(isPlaying: Boolean) {
syncPlayerViewKeepScreenOn()
latestOnSnapshot.value(exoPlayer.snapshot()) latestOnSnapshot.value(exoPlayer.snapshot())
} }
@ -233,6 +242,7 @@ actual fun PlatformPlayerSurface(
onDispose { onDispose {
PlayerPictureInPictureManager.registerPausePlaybackCallback(null) PlayerPictureInPictureManager.registerPausePlaybackCallback(null)
exoPlayer.removeListener(listener) exoPlayer.removeListener(listener)
playerViewRef?.keepScreenOn = false
subtitleSelectionJob?.cancel() subtitleSelectionJob?.cancel()
} }
} }
@ -262,6 +272,7 @@ actual fun PlatformPlayerSurface(
LaunchedEffect(exoPlayer, playWhenReady) { LaunchedEffect(exoPlayer, playWhenReady) {
exoPlayer.playWhenReady = playWhenReady exoPlayer.playWhenReady = playWhenReady
syncPlayerViewKeepScreenOn()
latestOnSnapshot.value(exoPlayer.snapshot()) latestOnSnapshot.value(exoPlayer.snapshot())
} }
@ -295,10 +306,10 @@ actual fun PlatformPlayerSurface(
} }
override fun getAudioTracks(): List<AudioTrack> = override fun getAudioTracks(): List<AudioTrack> =
exoPlayer.extractAudioTracks() exoPlayer.extractAudioTracks(context)
override fun getSubtitleTracks(): List<SubtitleTrack> { override fun getSubtitleTracks(): List<SubtitleTrack> {
val tracks = exoPlayer.extractSubtitleTracks() val tracks = exoPlayer.extractSubtitleTracks(context)
Log.d(TAG, "getSubtitleTracks: found ${tracks.size} tracks") Log.d(TAG, "getSubtitleTracks: found ${tracks.size} tracks")
tracks.forEach { t -> tracks.forEach { t ->
Log.d(TAG, " track idx=${t.index} id=${t.id} label='${t.label}' lang=${t.language} selected=${t.isSelected}") 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 useController = useNativeController
layoutParams = android.view.ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT) layoutParams = android.view.ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
player = exoPlayer player = exoPlayer
keepScreenOn = true keepScreenOn = exoPlayer.shouldKeepPlayerScreenOn()
this.resizeMode = resizeMode.toExoResizeMode() this.resizeMode = resizeMode.toExoResizeMode()
setShutterBackgroundColor(android.graphics.Color.BLACK) setShutterBackgroundColor(android.graphics.Color.BLACK)
playerViewRef = this playerViewRef = this
@ -439,6 +450,7 @@ actual fun PlatformPlayerSurface(
playerView.useController = useNativeController playerView.useController = useNativeController
playerView.resizeMode = resizeMode.toExoResizeMode() playerView.resizeMode = resizeMode.toExoResizeMode()
playerViewRef = playerView playerViewRef = playerView
syncPlayerViewKeepScreenOn()
playerView.syncLibassOverlay( playerView.syncLibassOverlay(
player = exoPlayer, player = exoPlayer,
enabled = useLibass, enabled = useLibass,
@ -467,6 +479,11 @@ private fun ExoPlayer.snapshot(): PlayerPlaybackSnapshot =
playbackSpeed = playbackParameters.speed, 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 = private fun PlayerResizeMode.toExoResizeMode(): Int =
when (this) { when (this) {
PlayerResizeMode.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT 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 tracks = mutableListOf<AudioTrack>()
val trackNameProvider = CustomDefaultTrackNameProvider(context.resources)
var idx = 0 var idx = 0
for (group in currentTracks.groups) { for (group in currentTracks.groups) {
if (group.type != C.TRACK_TYPE_AUDIO) continue if (group.type != C.TRACK_TYPE_AUDIO) continue
val format = group.mediaTrackGroup.getFormat(0) val format = group.mediaTrackGroup.getFormat(0)
val channelLabel = when { val label = trackNameProvider.getTrackName(format).takeIf { it.isNotBlank() }
format.channelCount == 1 -> "Mono" ?: runBlocking { getString(Res.string.compose_player_track_number, idx + 1) }
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 "" }
tracks.add( tracks.add(
AudioTrack( AudioTrack(
index = idx, index = idx,
id = format.id ?: idx.toString(), id = format.id ?: idx.toString(),
label = "$baseName$suffix", label = label,
language = format.language, language = format.language,
isSelected = group.isSelected, isSelected = group.isSelected,
) )
@ -603,8 +596,9 @@ private fun ExoPlayer.extractAudioTracks(): List<AudioTrack> {
return tracks return tracks
} }
private fun ExoPlayer.extractSubtitleTracks(): List<SubtitleTrack> { private fun ExoPlayer.extractSubtitleTracks(context: Context): List<SubtitleTrack> {
val tracks = mutableListOf<SubtitleTrack>() val tracks = mutableListOf<SubtitleTrack>()
val trackNameProvider = CustomDefaultTrackNameProvider(context.resources)
var idx = 0 var idx = 0
for (group in currentTracks.groups) { for (group in currentTracks.groups) {
if (group.type != C.TRACK_TYPE_TEXT) continue if (group.type != C.TRACK_TYPE_TEXT) continue
@ -614,7 +608,7 @@ private fun ExoPlayer.extractSubtitleTracks(): List<SubtitleTrack> {
SubtitleTrack( SubtitleTrack(
index = idx, index = idx,
id = format.id ?: idx.toString(), id = format.id ?: idx.toString(),
label = format.label ?: "", label = trackNameProvider.getTrackName(format),
language = format.language, language = format.language,
isSelected = group.isSelected, isSelected = group.isSelected,
isForced = inferForcedSubtitleTrack( isForced = inferForcedSubtitleTrack(

View file

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

View file

@ -45,6 +45,8 @@ actual object PlayerSettingsStorage {
private const val skipIntroEnabledKey = "skip_intro_enabled" private const val skipIntroEnabledKey = "skip_intro_enabled"
private const val animeSkipEnabledKey = "animeskip_enabled" private const val animeSkipEnabledKey = "animeskip_enabled"
private const val animeSkipClientIdKey = "animeskip_client_id" 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 streamAutoPlayNextEpisodeEnabledKey = "stream_auto_play_next_episode_enabled"
private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group" private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group"
private const val nextEpisodeThresholdModeKey = "next_episode_threshold_mode" private const val nextEpisodeThresholdModeKey = "next_episode_threshold_mode"
@ -480,6 +482,33 @@ actual object PlayerSettingsStorage {
?.apply() ?.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? = actual fun loadStreamAutoPlayNextEpisodeEnabled(): Boolean? =
preferences?.let { sharedPreferences -> preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(streamAutoPlayNextEpisodeEnabledKey) val key = ProfileScopedKey.of(streamAutoPlayNextEpisodeEnabledKey)
@ -652,6 +681,8 @@ actual object PlayerSettingsStorage {
payload.decodeSyncBoolean(skipIntroEnabledKey)?.let(::saveSkipIntroEnabled) payload.decodeSyncBoolean(skipIntroEnabledKey)?.let(::saveSkipIntroEnabled)
payload.decodeSyncBoolean(animeSkipEnabledKey)?.let(::saveAnimeSkipEnabled) payload.decodeSyncBoolean(animeSkipEnabledKey)?.let(::saveAnimeSkipEnabled)
payload.decodeSyncString(animeSkipClientIdKey)?.let(::saveAnimeSkipClientId) payload.decodeSyncString(animeSkipClientIdKey)?.let(::saveAnimeSkipClientId)
payload.decodeSyncString(introDbApiKeyKey)?.let(::saveIntroDbApiKey)
payload.decodeSyncBoolean(introSubmitEnabledKey)?.let(::saveIntroSubmitEnabled)
payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled) payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled)
payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup) payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup)
payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode) payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode)

View file

@ -0,0 +1,7 @@
package com.nuvio.app.features.profiles
internal actual object ProfileHoverHapticFeedback {
actual fun prepare() = Unit
actual fun perform() = Unit
actual fun release() = Unit
}

View file

@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import com.nuvio.app.R import com.nuvio.app.R
import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.introdb_favicon
import nuvio.composeapp.generated.resources.rating_tmdb import nuvio.composeapp.generated.resources.rating_tmdb
import org.jetbrains.compose.resources.painterResource as composePainterResource import org.jetbrains.compose.resources.painterResource as composePainterResource
@ -14,4 +15,5 @@ internal actual fun integrationLogoPainter(logo: IntegrationLogo): Painter =
IntegrationLogo.Tmdb -> composePainterResource(Res.drawable.rating_tmdb) IntegrationLogo.Tmdb -> composePainterResource(Res.drawable.rating_tmdb)
IntegrationLogo.Trakt -> painterResource(id = R.drawable.trakt_tv_favicon) IntegrationLogo.Trakt -> painterResource(id = R.drawable.trakt_tv_favicon)
IntegrationLogo.MdbList -> painterResource(id = R.drawable.mdblist_logo) IntegrationLogo.MdbList -> painterResource(id = R.drawable.mdblist_logo)
IntegrationLogo.IntroDb -> composePainterResource(Res.drawable.introdb_favicon)
} }

View file

@ -2,6 +2,8 @@ package com.nuvio.app.features.settings
import android.content.Context import android.content.Context
import android.content.SharedPreferences 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.decodeSyncBoolean
import com.nuvio.app.core.sync.decodeSyncString import com.nuvio.app.core.sync.decodeSyncString
import com.nuvio.app.core.sync.encodeSyncBoolean import com.nuvio.app.core.sync.encodeSyncBoolean
@ -15,12 +17,20 @@ actual object ThemeSettingsStorage {
private const val preferencesName = "nuvio_theme_settings" private const val preferencesName = "nuvio_theme_settings"
private const val selectedThemeKey = "selected_theme" private const val selectedThemeKey = "selected_theme"
private const val amoledEnabledKey = "amoled_enabled" 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 private var preferences: SharedPreferences? = null
fun initialize(context: Context) { fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
} }
actual fun loadSelectedTheme(): String? = actual fun loadSelectedTheme(): String? =
@ -46,17 +56,57 @@ actual object ThemeSettingsStorage {
?.apply() ?.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 { actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) }
loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) }
} }
actual fun replaceFromSyncPayload(payload: JsonObject) { actual fun replaceFromSyncPayload(payload: JsonObject) {
preferences?.edit()?.apply { preferences?.edit()?.apply {
syncKeys.forEach { remove(ProfileScopedKey.of(it)) } profileScopedSyncKeys.forEach { remove(ProfileScopedKey.of(it)) }
globalSyncKeys.forEach { remove(it) }
}?.apply() }?.apply()
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled)
payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage)
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
} }
} }

View file

@ -5,7 +5,7 @@ import java.time.Instant
internal actual object TraktPlatformClock { internal actual object TraktPlatformClock {
actual fun nowEpochMs(): Long = System.currentTimeMillis() actual fun nowEpochMs(): Long = System.currentTimeMillis()
actual fun parseIsoDateTimeToEpochMs(value: String): Long? = runCatching { actual fun parseIsoDateTimeToEpochMs(value: String): Long? =
Instant.parse(value).toEpochMilli() runCatching { Instant.parse(value).toEpochMilli() }.getOrNull()
}.getOrNull() ?: parseTraktIsoDateTimeToEpochMs(value)
} }

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"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <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> <item name="android:windowBackground">@color/nuvio_background</item>
</style> </style>
@ -9,4 +9,4 @@
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash_logo</item> <item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash_logo</item>
<item name="postSplashScreenTheme">@style/Theme.Nuvio</item> <item name="postSplashScreenTheme">@style/Theme.Nuvio</item>
</style> </style>
</resources> </resources>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <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> <item name="android:windowBackground">@color/nuvio_background</item>
</style> </style>

View file

@ -0,0 +1,13 @@
<?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 android:name="cs"/>
</locale-config>

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

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

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.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -60,6 +61,8 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@ -70,6 +73,7 @@ import coil3.ImageLoader
import coil3.compose.setSingletonImageLoaderFactory import coil3.compose.setSingletonImageLoaderFactory
import coil3.request.CachePolicy import coil3.request.CachePolicy
import coil3.request.crossfade import coil3.request.crossfade
import coil3.svg.SvgDecoder
import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.auth.AuthState
@ -92,6 +96,11 @@ import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.core.ui.NuvioFloatingPrompt import com.nuvio.app.core.ui.NuvioFloatingPrompt
import com.nuvio.app.core.ui.TraktListPickerDialog import com.nuvio.app.core.ui.TraktListPickerDialog
import com.nuvio.app.core.ui.NuvioTheme 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.auth.AuthScreen
import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.CatalogRepository import com.nuvio.app.features.catalog.CatalogRepository
@ -121,11 +130,13 @@ import com.nuvio.app.features.player.PlayerRoute
import com.nuvio.app.features.player.PlayerScreen import com.nuvio.app.features.player.PlayerScreen
import com.nuvio.app.features.player.sanitizePlaybackHeaders import com.nuvio.app.features.player.sanitizePlaybackHeaders
import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders 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.NuvioProfile
import com.nuvio.app.features.profiles.ProfileEditScreen import com.nuvio.app.features.profiles.ProfileEditScreen
import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.profiles.ProfileSelectionScreen import com.nuvio.app.features.profiles.ProfileSelectionScreen
import com.nuvio.app.features.profiles.ProfileSwitcherTab 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.search.SearchScreen
import com.nuvio.app.features.settings.SettingsScreen import com.nuvio.app.features.settings.SettingsScreen
import com.nuvio.app.features.settings.HomescreenSettingsScreen import com.nuvio.app.features.settings.HomescreenSettingsScreen
@ -135,6 +146,7 @@ import com.nuvio.app.features.settings.AddonsSettingsScreen
import com.nuvio.app.features.settings.PluginsSettingsScreen import com.nuvio.app.features.settings.PluginsSettingsScreen
import com.nuvio.app.features.settings.AccountSettingsScreen import com.nuvio.app.features.settings.AccountSettingsScreen
import com.nuvio.app.features.settings.SupportersContributorsSettingsScreen import com.nuvio.app.features.settings.SupportersContributorsSettingsScreen
import com.nuvio.app.features.settings.LicensesAttributionsSettingsScreen
import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.settings.ThemeSettingsRepository
import com.nuvio.app.features.collection.CollectionManagementScreen import com.nuvio.app.features.collection.CollectionManagementScreen
import com.nuvio.app.features.collection.CollectionEditorScreen import com.nuvio.app.features.collection.CollectionEditorScreen
@ -151,8 +163,6 @@ import com.nuvio.app.features.streams.StreamsRepository
import com.nuvio.app.features.streams.StreamsScreen import com.nuvio.app.features.streams.StreamsScreen
import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.player.PlayerSettingsRepository 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.trakt.TraktListTab
import com.nuvio.app.features.updater.AppUpdaterHost import com.nuvio.app.features.updater.AppUpdaterHost
import com.nuvio.app.features.updater.rememberAppUpdaterController import com.nuvio.app.features.updater.rememberAppUpdaterController
@ -167,12 +177,20 @@ import com.nuvio.app.features.watching.application.WatchingState
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.*
import nuvio.composeapp.generated.resources.app_logo_wordmark import nuvio.composeapp.generated.resources.app_logo_wordmark
import nuvio.composeapp.generated.resources.compose_catalog_subtitle_library
import nuvio.composeapp.generated.resources.compose_catalog_subtitle_trakt_library
import nuvio.composeapp.generated.resources.compose_nav_home
import nuvio.composeapp.generated.resources.compose_nav_library
import nuvio.composeapp.generated.resources.compose_nav_profile
import nuvio.composeapp.generated.resources.compose_nav_search
import nuvio.composeapp.generated.resources.sidebar_library import nuvio.composeapp.generated.resources.sidebar_library
import nuvio.composeapp.generated.resources.sidebar_search import nuvio.composeapp.generated.resources.sidebar_search
import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
@Serializable @Serializable
object TabsRoute object TabsRoute
@ -221,6 +239,9 @@ object AccountSettingsRoute
@Serializable @Serializable
object SupportersContributorsSettingsRoute object SupportersContributorsSettingsRoute
@Serializable
object LicensesAttributionsSettingsRoute
@Serializable @Serializable
object CollectionsRoute object CollectionsRoute
@ -253,6 +274,20 @@ enum class AppScreenTab {
Settings, 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 { private enum class AppGateScreen {
Loading, Loading,
Auth, Auth,
@ -270,6 +305,9 @@ fun App() {
.crossfade(true) .crossfade(true)
.diskCachePolicy(CachePolicy.ENABLED) .diskCachePolicy(CachePolicy.ENABLED)
.memoryCachePolicy(CachePolicy.ENABLED) .memoryCachePolicy(CachePolicy.ENABLED)
.components {
add(SvgDecoder.Factory())
}
.configurePlatformImageLoader() .configurePlatformImageLoader()
.build() .build()
} }
@ -278,7 +316,6 @@ fun App() {
ThemeSettingsRepository.selectedTheme ThemeSettingsRepository.selectedTheme
}.collectAsStateWithLifecycle() }.collectAsStateWithLifecycle()
val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle() val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle()
NuvioTheme(appTheme = selectedTheme, amoled = amoledEnabled) { NuvioTheme(appTheme = selectedTheme, amoled = amoledEnabled) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
AuthRepository.initialize() AuthRepository.initialize()
@ -287,13 +324,36 @@ fun App() {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
NetworkStatusRepository.ensureStarted() NetworkStatusRepository.ensureStarted()
ProfileRepository.loadCachedProfiles() ProfileRepository.loadCachedProfiles()
AvatarRepository.fetchAvatars()
} }
val authState by AuthRepository.state.collectAsStateWithLifecycle() val authState by AuthRepository.state.collectAsStateWithLifecycle()
val profileState by ProfileRepository.state.collectAsStateWithLifecycle() val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val profileAvatars by AvatarRepository.avatars.collectAsStateWithLifecycle()
val networkStatusUiState by remember { val networkStatusUiState by remember {
NetworkStatusRepository.uiState NetworkStatusRepository.uiState
}.collectAsStateWithLifecycle() }.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 gateScreen by rememberSaveable { mutableStateOf(AppGateScreen.Loading.name) }
var editingProfile by remember { mutableStateOf<NuvioProfile?>(null) } var editingProfile by remember { mutableStateOf<NuvioProfile?>(null) }
var isNewProfile by remember { mutableStateOf(false) } var isNewProfile by remember { mutableStateOf(false) }
@ -460,6 +520,12 @@ private fun MainAppContent(
val hapticFeedback = LocalHapticFeedback.current val hapticFeedback = LocalHapticFeedback.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
val currentBackStackEntry by navController.currentBackStackEntryAsState()
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 showExitConfirmation by rememberSaveable { mutableStateOf(false) }
var selectedPosterForActions by remember { mutableStateOf<MetaPreview?>(null) } var selectedPosterForActions by remember { mutableStateOf<MetaPreview?>(null) }
var selectedContinueWatchingForActions by remember { mutableStateOf<ContinueWatchingItem?>(null) } var selectedContinueWatchingForActions by remember { mutableStateOf<ContinueWatchingItem?>(null) }
@ -478,10 +544,6 @@ private fun MainAppContent(
LibraryRepository.ensureLoaded() LibraryRepository.ensureLoaded()
LibraryRepository.uiState LibraryRepository.uiState
}.collectAsStateWithLifecycle() }.collectAsStateWithLifecycle()
val traktAuthUiState by remember {
TraktAuthRepository.ensureLoaded()
TraktAuthRepository.uiState
}.collectAsStateWithLifecycle()
val authState by AuthRepository.state.collectAsStateWithLifecycle() val authState by AuthRepository.state.collectAsStateWithLifecycle()
val profileState by ProfileRepository.state.collectAsStateWithLifecycle() val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val playerSettingsUiState by remember { val playerSettingsUiState by remember {
@ -499,7 +561,8 @@ private fun MainAppContent(
val networkStatusUiState by remember { val networkStatusUiState by remember {
NetworkStatusRepository.uiState NetworkStatusRepository.uiState
}.collectAsStateWithLifecycle() }.collectAsStateWithLifecycle()
val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED val downloadedProviderLabel = stringResource(Res.string.provider_downloaded)
val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT
var initialHomeReady by rememberSaveable { mutableStateOf(false) } var initialHomeReady by rememberSaveable { mutableStateOf(false) }
var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) } var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) }
var networkToastBaselineReady by rememberSaveable { mutableStateOf(false) } var networkToastBaselineReady by rememberSaveable { mutableStateOf(false) }
@ -512,6 +575,42 @@ private fun MainAppContent(
.sorted() .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) { LaunchedEffect(Unit) {
NetworkStatusRepository.ensureStarted() NetworkStatusRepository.ensureStarted()
EpisodeReleaseNotificationsRepository.refreshAsync() EpisodeReleaseNotificationsRepository.refreshAsync()
@ -542,11 +641,11 @@ private fun MainAppContent(
when (condition) { when (condition) {
NetworkCondition.NoInternet -> { NetworkCondition.NoInternet -> {
NuvioToastController.show("No internet connection") NuvioToastController.show(getString(Res.string.network_no_internet_connection))
} }
NetworkCondition.ServersUnreachable -> { NetworkCondition.ServersUnreachable -> {
NuvioToastController.show("Cannot reach servers") NuvioToastController.show(getString(Res.string.network_cannot_reach_servers))
} }
NetworkCondition.Online -> { NetworkCondition.Online -> {
@ -554,7 +653,7 @@ private fun MainAppContent(
previousConditionName == NetworkCondition.NoInternet.name || previousConditionName == NetworkCondition.NoInternet.name ||
previousConditionName == NetworkCondition.ServersUnreachable.name previousConditionName == NetworkCondition.ServersUnreachable.name
) { ) {
NuvioToastController.show("Back online") NuvioToastController.show(getString(Res.string.network_back_online))
} }
} }
@ -587,7 +686,9 @@ private fun MainAppContent(
NetworkCondition.ServersUnreachable, NetworkCondition.ServersUnreachable,
-> { -> {
offlineLaunchRouteHandled = true offlineLaunchRouteHandled = true
val hasPlayableDownload = downloadsUiState.completedItems.any { it.isPlayable } val hasPlayableDownload = downloadsUiState.completedItems.any {
DownloadsRepository.playableLocalFileUri(it) != null
}
if (hasPlayableDownload) { if (hasPlayableDownload) {
selectedTab = AppScreenTab.Settings selectedTab = AppScreenTab.Settings
navController.navigate(DownloadsSettingsRoute) { navController.navigate(DownloadsSettingsRoute) {
@ -680,7 +781,7 @@ private fun MainAppContent(
episodeNumber = episodeNumber, episodeNumber = episodeNumber,
videoId = videoId, videoId = videoId,
) )
val localSourceUrl = downloadedItem?.localFileUri val localSourceUrl = downloadedItem?.let(DownloadsRepository::playableLocalFileUri)
if (!localSourceUrl.isNullOrBlank()) { if (!localSourceUrl.isNullOrBlank()) {
val launchId = PlayerLaunchStore.put( val launchId = PlayerLaunchStore.put(
PlayerLaunch( PlayerLaunch(
@ -698,7 +799,7 @@ private fun MainAppContent(
streamTitle = downloadedItem.streamTitle.ifBlank { title }, streamTitle = downloadedItem.streamTitle.ifBlank { title },
streamSubtitle = downloadedItem.streamSubtitle, streamSubtitle = downloadedItem.streamSubtitle,
pauseDescription = pauseDescription, pauseDescription = pauseDescription,
providerName = downloadedItem.providerName.ifBlank { "Downloaded" }, providerName = downloadedItem.providerName.ifBlank { downloadedProviderLabel },
providerAddonId = downloadedItem.providerAddonId, providerAddonId = downloadedItem.providerAddonId,
contentType = type, contentType = type,
videoId = videoId, videoId = videoId,
@ -798,15 +899,17 @@ private fun MainAppContent(
) )
} }
val librarySectionSubtitle = if (libraryUiState.sourceMode == LibrarySourceMode.TRAKT) {
stringResource(Res.string.compose_catalog_subtitle_trakt_library)
} else {
stringResource(Res.string.compose_catalog_subtitle_library)
}
val onLibrarySectionViewAllClick: (LibrarySection) -> Unit = { section -> val onLibrarySectionViewAllClick: (LibrarySection) -> Unit = { section ->
navController.navigate( navController.navigate(
CatalogRoute( CatalogRoute(
title = section.displayTitle, title = section.displayTitle,
subtitle = if (libraryUiState.sourceMode == LibrarySourceMode.TRAKT) { subtitle = librarySectionSubtitle,
"Trakt Library"
} else {
"Library"
},
manifestUrl = INTERNAL_LIBRARY_MANIFEST_URL, manifestUrl = INTERNAL_LIBRARY_MANIFEST_URL,
type = section.items.firstOrNull()?.type ?: "movie", type = section.items.firstOrNull()?.type ?: "movie",
catalogId = section.type, catalogId = section.type,
@ -879,6 +982,9 @@ private fun MainAppContent(
BoxWithConstraints(modifier = Modifier.fillMaxSize()) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val isTabletLayout = maxWidth >= 768.dp val isTabletLayout = maxWidth >= 768.dp
val useNativeBottomTabs =
liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady
val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute<TabsRoute>() == true
val onProfileSelected: (NuvioProfile) -> Unit = { profile -> val onProfileSelected: (NuvioProfile) -> Unit = { profile ->
profileSwitchLoading = true profileSwitchLoading = true
selectedTab = AppScreenTab.Home selectedTab = AppScreenTab.Home
@ -893,25 +999,25 @@ private fun MainAppContent(
containerColor = Color.Transparent, containerColor = Color.Transparent,
contentWindowInsets = WindowInsets(0), contentWindowInsets = WindowInsets(0),
bottomBar = { bottomBar = {
if (!isTabletLayout) { if (!isTabletLayout && !useNativeBottomTabs) {
NuvioNavigationBar { NuvioNavigationBar {
NavItem( NavItem(
selected = selectedTab == AppScreenTab.Home, selected = selectedTab == AppScreenTab.Home,
onClick = { selectedTab = AppScreenTab.Home }, onClick = { selectedTab = AppScreenTab.Home },
icon = Icons.Filled.Home, icon = Icons.Filled.Home,
contentDescription = "Home", contentDescription = stringResource(Res.string.compose_nav_home),
) )
NavItem( NavItem(
selected = selectedTab == AppScreenTab.Search, selected = selectedTab == AppScreenTab.Search,
onClick = { selectedTab = AppScreenTab.Search }, onClick = { selectedTab = AppScreenTab.Search },
icon = Res.drawable.sidebar_search, icon = Res.drawable.sidebar_search,
contentDescription = "Search", contentDescription = stringResource(Res.string.compose_nav_search),
) )
NavItem( NavItem(
selected = selectedTab == AppScreenTab.Library, selected = selectedTab == AppScreenTab.Library,
onClick = { selectedTab = AppScreenTab.Library }, onClick = { selectedTab = AppScreenTab.Library },
icon = Res.drawable.sidebar_library, icon = Res.drawable.sidebar_library,
contentDescription = "Library", contentDescription = stringResource(Res.string.compose_nav_library),
) )
NavItem( NavItem(
selected = selectedTab == AppScreenTab.Settings, selected = selectedTab == AppScreenTab.Settings,
@ -929,58 +1035,66 @@ private fun MainAppContent(
}, },
) { innerPadding -> ) { innerPadding ->
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
AppTabHost( CompositionLocalProvider(
modifier = Modifier LocalNuvioBottomNavigationOverlayPadding provides if (useNativeBottomTabs) 49.dp else 0.dp,
.fillMaxSize() ) {
.padding(innerPadding), AppTabHost(
selectedTab = selectedTab, modifier = Modifier
onCatalogClick = onCatalogClick, .fillMaxSize()
onPosterClick = { meta -> .padding(innerPadding),
navController.navigate(DetailRoute(type = meta.type, id = meta.id)) selectedTab = selectedTab,
}, animateHomeCollectionGifs = tabsRouteActive,
onPosterLongClick = { meta -> onCatalogClick = onCatalogClick,
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) onPosterClick = { meta ->
selectedPosterForActions = meta navController.navigate(DetailRoute(type = meta.type, id = meta.id))
}, },
onLibraryPosterClick = { item -> onPosterLongClick = { meta ->
navController.navigate(DetailRoute(type = item.type, id = item.id)) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}, selectedPosterForActions = meta
onLibrarySectionViewAllClick = onLibrarySectionViewAllClick, },
onContinueWatchingClick = onContinueWatchingClick, onLibraryPosterClick = { item ->
onContinueWatchingLongPress = onContinueWatchingLongPress, navController.navigate(DetailRoute(type = item.type, id = item.id))
onSwitchProfile = onSwitchProfile, },
onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) }, onLibrarySectionViewAllClick = onLibrarySectionViewAllClick,
onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) }, onContinueWatchingClick = onContinueWatchingClick,
onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) }, onContinueWatchingLongPress = onContinueWatchingLongPress,
onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) }, onSwitchProfile = onSwitchProfile,
onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) }, onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) },
onPluginsSettingsClick = { onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) },
if (AppFeaturePolicy.pluginsEnabled) { onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) },
navController.navigate(PluginsSettingsRoute) onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) },
} onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) },
}, onPluginsSettingsClick = {
onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) }, if (AppFeaturePolicy.pluginsEnabled) {
onSupportersContributorsSettingsClick = { navController.navigate(PluginsSettingsRoute)
navController.navigate(SupportersContributorsSettingsRoute) }
}, },
onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) { onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) },
{ onSupportersContributorsSettingsClick = {
appUpdaterController.checkForUpdates( navController.navigate(SupportersContributorsSettingsRoute)
force = true, },
showNoUpdateFeedback = true, onLicensesAttributionsSettingsClick = {
) navController.navigate(LicensesAttributionsSettingsRoute)
} },
} else { onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) {
null {
}, appUpdaterController.checkForUpdates(
onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) }, force = true,
onFolderClick = { collectionId, folderId -> showNoUpdateFeedback = true,
navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId)) )
}, }
onInitialHomeContentRendered = { initialHomeReady = 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( TabletFloatingTopBar(
selectedTab = selectedTab, selectedTab = selectedTab,
onTabSelected = { selectedTab = it }, onTabSelected = { selectedTab = it },
@ -994,6 +1108,9 @@ private fun MainAppContent(
} }
composable<DetailRoute> { backStackEntry -> composable<DetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<DetailRoute>() val route = backStackEntry.toRoute<DetailRoute>()
val directorRole = stringResource(Res.string.person_role_director)
val writerRole = stringResource(Res.string.person_role_writer)
val creatorRole = stringResource(Res.string.person_role_creator)
MetaDetailsScreen( MetaDetailsScreen(
type = route.type, type = route.type,
id = route.id, id = route.id,
@ -1034,8 +1151,11 @@ private fun MainAppContent(
castAvatarTransitionKey = avatarTransitionKey, castAvatarTransitionKey = avatarTransitionKey,
preferCrew = person.role?.let { preferCrew = person.role?.let {
it.equals("Director", ignoreCase = true) || it.equals("Director", ignoreCase = true) ||
it.equals(directorRole, ignoreCase = true) ||
it.equals("Writer", ignoreCase = true) || it.equals("Writer", ignoreCase = true) ||
it.equals(writerRole, ignoreCase = true) ||
it.equals("Creator", ignoreCase = true) it.equals("Creator", ignoreCase = true)
|| it.equals(creatorRole, ignoreCase = true)
} ?: false, } ?: false,
), ),
) )
@ -1218,7 +1338,13 @@ private fun MainAppContent(
reuseHandled = true reuseHandled = true
if (launch.manualSelection) return@LaunchedEffect if (launch.manualSelection) return@LaunchedEffect
if (!playerSettings.streamReuseLastLinkEnabled) return@LaunchedEffect if (!playerSettings.streamReuseLastLinkEnabled) return@LaunchedEffect
val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId) val cacheKey = StreamLinkCacheRepository.contentKey(
type = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId,
season = launch.seasonNumber,
episode = launch.episodeNumber,
)
val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L
val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs) val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs)
if (cached != null) { if (cached != null) {
@ -1258,17 +1384,37 @@ private fun MainAppContent(
} }
val streamsUiState by StreamsRepository.uiState.collectAsStateWithLifecycle() val streamsUiState by StreamsRepository.uiState.collectAsStateWithLifecycle()
val expectedStreamsRequestToken = StreamsRepository.requestToken(
type = launch.type,
videoId = effectiveVideoId,
season = launch.seasonNumber,
episode = launch.episodeNumber,
manualSelection = launch.manualSelection,
)
var autoPlayHandled by rememberSaveable(launch.videoId, effectiveVideoId) { mutableStateOf(false) } var autoPlayHandled by rememberSaveable(launch.videoId, effectiveVideoId) { mutableStateOf(false) }
LaunchedEffect(streamsUiState.autoPlayStream, reuseHandled, launch.manualSelection) { LaunchedEffect(
streamsUiState.autoPlayStream,
streamsUiState.requestToken,
expectedStreamsRequestToken,
reuseHandled,
launch.manualSelection,
) {
if (!reuseHandled) return@LaunchedEffect if (!reuseHandled) return@LaunchedEffect
if (launch.manualSelection) return@LaunchedEffect if (launch.manualSelection) return@LaunchedEffect
if (reuseNavigated) return@LaunchedEffect if (reuseNavigated) return@LaunchedEffect
if (autoPlayHandled) return@LaunchedEffect if (autoPlayHandled) return@LaunchedEffect
if (streamsUiState.requestToken != expectedStreamsRequestToken) return@LaunchedEffect
val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect
val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect
autoPlayHandled = true autoPlayHandled = true
if (playerSettings.streamReuseLastLinkEnabled) { if (playerSettings.streamReuseLastLinkEnabled) {
val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId) val cacheKey = StreamLinkCacheRepository.contentKey(
type = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId,
season = launch.seasonNumber,
episode = launch.episodeNumber,
)
StreamLinkCacheRepository.save( StreamLinkCacheRepository.save(
contentKey = cacheKey, contentKey = cacheKey,
url = sourceUrl, url = sourceUrl,
@ -1310,6 +1456,7 @@ private fun MainAppContent(
) )
) )
StreamsRepository.consumeAutoPlay() StreamsRepository.consumeAutoPlay()
StreamsRepository.cancelLoading()
navController.navigate(PlayerRoute(launchId = launchId)) { navController.navigate(PlayerRoute(launchId = launchId)) {
popUpTo<StreamRoute> { inclusive = true } popUpTo<StreamRoute> { inclusive = true }
} }
@ -1347,7 +1494,13 @@ private fun MainAppContent(
if (sourceUrl != null) { if (sourceUrl != null) {
// Persist for Reuse Last Link // Persist for Reuse Last Link
if (playerSettings.streamReuseLastLinkEnabled) { if (playerSettings.streamReuseLastLinkEnabled) {
val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId) val cacheKey = StreamLinkCacheRepository.contentKey(
type = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId,
season = launch.seasonNumber,
episode = launch.episodeNumber,
)
StreamLinkCacheRepository.save( StreamLinkCacheRepository.save(
contentKey = cacheKey, contentKey = cacheKey,
url = sourceUrl, url = sourceUrl,
@ -1388,6 +1541,7 @@ private fun MainAppContent(
initialProgressFraction = resolvedResumeProgressFraction, initialProgressFraction = resolvedResumeProgressFraction,
) )
) )
StreamsRepository.cancelLoading()
navController.navigate( navController.navigate(
PlayerRoute(launchId = launchId) PlayerRoute(launchId = launchId)
) )
@ -1514,7 +1668,7 @@ private fun MainAppContent(
DownloadsScreen( DownloadsScreen(
onBack = onBack, onBack = onBack,
onOpenDownload = { item -> onOpenDownload = { item ->
val sourceUrl = item.localFileUri ?: return@DownloadsScreen val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return@DownloadsScreen
val resumeEntry = item.videoId val resumeEntry = item.videoId
.takeIf { it.isNotBlank() } .takeIf { it.isNotBlank() }
?.let(WatchProgressRepository::progressForVideo) ?.let(WatchProgressRepository::progressForVideo)
@ -1587,6 +1741,15 @@ private fun MainAppContent(
onBack = onBack, onBack = onBack,
) )
} }
composable<LicensesAttributionsSettingsRoute> { backStackEntry ->
val onBack = rememberGuardedPopBackStack(
navController = navController,
backStackEntry = backStackEntry,
)
LicensesAttributionsSettingsScreen(
onBack = onBack,
)
}
composable<CollectionsRoute> { backStackEntry -> composable<CollectionsRoute> { backStackEntry ->
val onBack = rememberGuardedPopBackStack( val onBack = rememberGuardedPopBackStack(
navController = navController, navController = navController,
@ -1643,12 +1806,12 @@ private fun MainAppContent(
onToggleLibrary = { onToggleLibrary = {
selectedPosterForActions?.let { preview -> selectedPosterForActions?.let { preview ->
val libraryItem = preview.toLibraryItem(savedAtEpochMs = 0L) val libraryItem = preview.toLibraryItem(savedAtEpochMs = 0L)
if (!isTraktConnected) { if (!isTraktLibrarySource) {
LibraryRepository.toggleSaved(libraryItem) LibraryRepository.toggleSaved(libraryItem)
} else { } else {
pickerItem = libraryItem pickerItem = libraryItem
pickerTitle = preview.name pickerTitle = preview.name
pickerTabs = LibraryRepository.traktListTabs() pickerTabs = LibraryRepository.libraryListTabs()
pickerMembership = pickerTabs.associate { it.key to false } pickerMembership = pickerTabs.associate { it.key to false }
pickerPending = true pickerPending = true
pickerError = null pickerError = null
@ -1656,13 +1819,13 @@ private fun MainAppContent(
coroutineScope.launch { coroutineScope.launch {
runCatching { runCatching {
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
val tabs = LibraryRepository.traktListTabs() val tabs = LibraryRepository.libraryListTabs()
pickerTabs = tabs pickerTabs = tabs
pickerMembership = tabs.associate { tab -> pickerMembership = tabs.associate { tab ->
tab.key to (snapshot[tab.key] == true) tab.key to (snapshot[tab.key] == true)
} }
}.onFailure { error -> }.onFailure { error ->
pickerError = error.message ?: "Failed to load Trakt lists" pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed)
} }
pickerPending = false pickerPending = false
} }
@ -1748,7 +1911,7 @@ private fun MainAppContent(
pickerItem = null pickerItem = null
pickerError = null pickerError = null
}.onFailure { error -> }.onFailure { error ->
pickerError = error.message ?: "Failed to update Trakt lists" pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed)
} }
pickerPending = false pickerPending = false
} }
@ -1756,11 +1919,11 @@ private fun MainAppContent(
) )
NuvioStatusModal( NuvioStatusModal(
title = "Exit app", title = stringResource(Res.string.app_exit_title),
message = "Do you want to exit the app?", message = stringResource(Res.string.app_exit_message),
isVisible = showExitConfirmation, isVisible = showExitConfirmation,
confirmText = "Yes", confirmText = stringResource(Res.string.action_yes),
dismissText = "No", dismissText = stringResource(Res.string.action_no),
onConfirm = { onConfirm = {
showExitConfirmation = false showExitConfirmation = false
platformExitApp() platformExitApp()
@ -1791,9 +1954,9 @@ private fun MainAppContent(
visible = resumePromptItem != null, visible = resumePromptItem != null,
imageUrl = resumePromptItem?.poster ?: resumePromptItem?.imageUrl, imageUrl = resumePromptItem?.poster ?: resumePromptItem?.imageUrl,
title = resumePromptItem?.title.orEmpty(), title = resumePromptItem?.title.orEmpty(),
subtitle = resumePromptItem?.subtitle.orEmpty(), subtitle = resumePromptItem?.let { localizedContinueWatchingSubtitle(it) }.orEmpty(),
progressFraction = resumePromptItem?.progressFraction ?: 0f, progressFraction = resumePromptItem?.progressFraction ?: 0f,
actionLabel = "Resume", actionLabel = stringResource(Res.string.resume_prompt_action),
onAction = { onAction = {
val item = resumePromptItem ?: return@NuvioFloatingPrompt val item = resumePromptItem ?: return@NuvioFloatingPrompt
resumePromptItem = null resumePromptItem = null
@ -1844,6 +2007,7 @@ private fun rememberGuardedPopBackStack(
private fun AppTabHost( private fun AppTabHost(
selectedTab: AppScreenTab, selectedTab: AppScreenTab,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
animateHomeCollectionGifs: Boolean = true,
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null, onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null,
@ -1860,6 +2024,7 @@ private fun AppTabHost(
onPluginsSettingsClick: () -> Unit = {}, onPluginsSettingsClick: () -> Unit = {},
onAccountSettingsClick: () -> Unit = {}, onAccountSettingsClick: () -> Unit = {},
onSupportersContributorsSettingsClick: () -> Unit = {}, onSupportersContributorsSettingsClick: () -> Unit = {},
onLicensesAttributionsSettingsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null, onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsSettingsClick: () -> Unit = {}, onCollectionsSettingsClick: () -> Unit = {},
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null, onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
@ -1873,6 +2038,7 @@ private fun AppTabHost(
AppScreenTab.Home -> { AppScreenTab.Home -> {
HomeScreen( HomeScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
animateCollectionGifs = animateHomeCollectionGifs,
onCatalogClick = onCatalogClick, onCatalogClick = onCatalogClick,
onPosterClick = onPosterClick, onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick, onPosterLongClick = onPosterLongClick,
@ -1911,6 +2077,7 @@ private fun AppTabHost(
onPluginsClick = onPluginsSettingsClick, onPluginsClick = onPluginsSettingsClick,
onAccountClick = onAccountSettingsClick, onAccountClick = onAccountSettingsClick,
onSupportersContributorsClick = onSupportersContributorsSettingsClick, onSupportersContributorsClick = onSupportersContributorsSettingsClick,
onLicensesAttributionsClick = onLicensesAttributionsSettingsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick, onCheckForUpdatesClick = onCheckForUpdatesClick,
onCollectionsClick = onCollectionsSettingsClick, onCollectionsClick = onCollectionsSettingsClick,
) )
@ -1948,13 +2115,13 @@ private fun TabletFloatingTopBar(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
TabletTopPillItem( TabletTopPillItem(
label = "Home", label = stringResource(Res.string.compose_nav_home),
selected = selectedTab == AppScreenTab.Home, selected = selectedTab == AppScreenTab.Home,
onClick = { onTabSelected(AppScreenTab.Home) }, onClick = { onTabSelected(AppScreenTab.Home) },
icon = { icon = {
Icon( Icon(
imageVector = Icons.Filled.Home, imageVector = Icons.Filled.Home,
contentDescription = "Home", contentDescription = stringResource(Res.string.compose_nav_home),
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
tint = if (selectedTab == AppScreenTab.Home) { tint = if (selectedTab == AppScreenTab.Home) {
MaterialTheme.colorScheme.onPrimaryContainer MaterialTheme.colorScheme.onPrimaryContainer
@ -1965,13 +2132,13 @@ private fun TabletFloatingTopBar(
}, },
) )
TabletTopPillItem( TabletTopPillItem(
label = "Search", label = stringResource(Res.string.compose_nav_search),
selected = selectedTab == AppScreenTab.Search, selected = selectedTab == AppScreenTab.Search,
onClick = { onTabSelected(AppScreenTab.Search) }, onClick = { onTabSelected(AppScreenTab.Search) },
icon = { icon = {
Icon( Icon(
painter = painterResource(Res.drawable.sidebar_search), painter = painterResource(Res.drawable.sidebar_search),
contentDescription = "Search", contentDescription = stringResource(Res.string.compose_nav_search),
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
tint = if (selectedTab == AppScreenTab.Search) { tint = if (selectedTab == AppScreenTab.Search) {
MaterialTheme.colorScheme.onPrimaryContainer MaterialTheme.colorScheme.onPrimaryContainer
@ -1982,13 +2149,13 @@ private fun TabletFloatingTopBar(
}, },
) )
TabletTopPillItem( TabletTopPillItem(
label = "Library", label = stringResource(Res.string.compose_nav_library),
selected = selectedTab == AppScreenTab.Library, selected = selectedTab == AppScreenTab.Library,
onClick = { onTabSelected(AppScreenTab.Library) }, onClick = { onTabSelected(AppScreenTab.Library) },
icon = { icon = {
Icon( Icon(
painter = painterResource(Res.drawable.sidebar_library), painter = painterResource(Res.drawable.sidebar_library),
contentDescription = "Library", contentDescription = stringResource(Res.string.compose_nav_library),
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
tint = if (selectedTab == AppScreenTab.Library) { tint = if (selectedTab == AppScreenTab.Library) {
MaterialTheme.colorScheme.onPrimaryContainer MaterialTheme.colorScheme.onPrimaryContainer
@ -2018,7 +2185,7 @@ private fun TabletFloatingTopBar(
onAddProfileRequested = onAddProfileRequested, onAddProfileRequested = onAddProfileRequested,
) )
Text( Text(
text = "Profile", text = stringResource(Res.string.compose_nav_profile),
modifier = Modifier.clickable { onTabSelected(AppScreenTab.Settings) }, modifier = Modifier.clickable { onTabSelected(AppScreenTab.Settings) },
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = if (selectedTab == AppScreenTab.Settings) { color = if (selectedTab == AppScreenTab.Settings) {
@ -2081,7 +2248,7 @@ private fun AppLaunchOverlay(
) { ) {
Image( Image(
painter = painterResource(Res.drawable.app_logo_wordmark), painter = painterResource(Res.drawable.app_logo_wordmark),
contentDescription = "Nuvio", contentDescription = stringResource(Res.string.app_brand_name),
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.48f) .fillMaxWidth(0.48f)
.height(44.dp), .height(44.dp),

View file

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

View file

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

@ -3,6 +3,7 @@ package com.nuvio.app.core.storage
import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.CatalogRepository import com.nuvio.app.features.catalog.CatalogRepository
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
import com.nuvio.app.features.collection.CollectionRepository import com.nuvio.app.features.collection.CollectionRepository
import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.details.MetaScreenSettingsRepository
@ -21,6 +22,7 @@ import com.nuvio.app.features.streams.StreamContextStore
import com.nuvio.app.features.streams.StreamLaunchStore import com.nuvio.app.features.streams.StreamLaunchStore
import com.nuvio.app.features.streams.StreamsRepository import com.nuvio.app.features.streams.StreamsRepository
import com.nuvio.app.features.trakt.TraktAuthRepository 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.core.ui.PosterCardStyleRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository
@ -43,10 +45,12 @@ internal object LocalAccountDataCleaner {
WatchedRepository.clearLocalState() WatchedRepository.clearLocalState()
ContinueWatchingPreferencesRepository.clearLocalState() ContinueWatchingPreferencesRepository.clearLocalState()
EpisodeReleaseNotificationsRepository.clearLocalState() EpisodeReleaseNotificationsRepository.clearLocalState()
CollectionMobileSettingsRepository.clearLocalState()
CollectionRepository.clearLocalState() CollectionRepository.clearLocalState()
ThemeSettingsRepository.clearLocalState() ThemeSettingsRepository.clearLocalState()
PosterCardStyleRepository.clearLocalState() PosterCardStyleRepository.clearLocalState()
TraktAuthRepository.clearLocalState() TraktAuthRepository.clearLocalState()
TraktSettingsRepository.clearLocalState()
PlayerSettingsRepository.clearLocalState() PlayerSettingsRepository.clearLocalState()
CatalogRepository.clear() CatalogRepository.clear()
StreamsRepository.clear() StreamsRepository.clear()

View file

@ -4,6 +4,8 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
import com.nuvio.app.features.details.MetaScreenSettingsStorage import com.nuvio.app.features.details.MetaScreenSettingsStorage
import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.mdblist.MdbListMetadataService import com.nuvio.app.features.mdblist.MdbListMetadataService
@ -21,6 +23,8 @@ import com.nuvio.app.features.tmdb.TmdbSettingsStorage
import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.trakt.TraktCommentsStorage import com.nuvio.app.features.trakt.TraktCommentsStorage
import com.nuvio.app.features.trakt.TraktCommentsSettings 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.ContinueWatchingPreferencesStorage
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.postgrest
@ -150,12 +154,15 @@ object ProfileSettingsSync {
val signatureFlows = listOf( val signatureFlows = listOf(
ThemeSettingsRepository.selectedTheme.map { "theme" }, ThemeSettingsRepository.selectedTheme.map { "theme" },
ThemeSettingsRepository.amoledEnabled.map { "amoled" }, ThemeSettingsRepository.amoledEnabled.map { "amoled" },
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" },
PosterCardStyleRepository.uiState.map { "poster_card_style" }, PosterCardStyleRepository.uiState.map { "poster_card_style" },
PlayerSettingsRepository.uiState.map { "player" }, PlayerSettingsRepository.uiState.map { "player" },
TmdbSettingsRepository.uiState.map { "tmdb" }, TmdbSettingsRepository.uiState.map { "tmdb" },
MdbListSettingsRepository.uiState.map { "mdblist" }, MdbListSettingsRepository.uiState.map { "mdblist" },
MetaScreenSettingsRepository.uiState.map { "meta" }, MetaScreenSettingsRepository.uiState.map { "meta" },
CollectionMobileSettingsRepository.uiState.map { "collection_mobile_settings" },
ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" }, ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" },
TraktSettingsRepository.uiState.map { "trakt_settings" },
TraktCommentsSettings.enabled.map { "trakt_comments" }, TraktCommentsSettings.enabled.map { "trakt_comments" },
EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" }, EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" },
) )
@ -198,7 +205,9 @@ object ProfileSettingsSync {
tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(), tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(),
mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(), mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(),
metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(), metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(),
collectionMobileSettingsPayload = CollectionMobileSettingsStorage.loadPayload().orEmpty().trim(),
continueWatchingSettingsPayload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim(), continueWatchingSettingsPayload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim(),
traktSettingsPayload = TraktSettingsStorage.loadPayload().orEmpty().trim(),
traktCommentsSettings = TraktCommentsStorage.exportToSyncPayload(), traktCommentsSettings = TraktCommentsStorage.exportToSyncPayload(),
notificationsSettings = NotificationsSettingsPayload( notificationsSettings = NotificationsSettingsPayload(
episodeReleaseAlertsEnabled = EpisodeReleaseNotificationsRepository.uiState.value.isEnabled, episodeReleaseAlertsEnabled = EpisodeReleaseNotificationsRepository.uiState.value.isEnabled,
@ -227,9 +236,15 @@ object ProfileSettingsSync {
MetaScreenSettingsStorage.savePayload(blob.features.metaScreenSettingsPayload) MetaScreenSettingsStorage.savePayload(blob.features.metaScreenSettingsPayload)
MetaScreenSettingsRepository.onProfileChanged() MetaScreenSettingsRepository.onProfileChanged()
CollectionMobileSettingsStorage.savePayload(blob.features.collectionMobileSettingsPayload)
CollectionMobileSettingsRepository.onProfileChanged()
ContinueWatchingPreferencesStorage.savePayload(blob.features.continueWatchingSettingsPayload) ContinueWatchingPreferencesStorage.savePayload(blob.features.continueWatchingSettingsPayload)
ContinueWatchingPreferencesRepository.onProfileChanged() ContinueWatchingPreferencesRepository.onProfileChanged()
TraktSettingsStorage.savePayload(blob.features.traktSettingsPayload)
TraktSettingsRepository.onProfileChanged()
TraktCommentsStorage.replaceFromSyncPayload(blob.features.traktCommentsSettings) TraktCommentsStorage.replaceFromSyncPayload(blob.features.traktCommentsSettings)
TraktCommentsSettings.onProfileChanged() TraktCommentsSettings.onProfileChanged()
@ -243,7 +258,9 @@ object ProfileSettingsSync {
TmdbSettingsRepository.ensureLoaded() TmdbSettingsRepository.ensureLoaded()
MdbListSettingsRepository.ensureLoaded() MdbListSettingsRepository.ensureLoaded()
MetaScreenSettingsRepository.ensureLoaded() MetaScreenSettingsRepository.ensureLoaded()
CollectionMobileSettingsRepository.ensureLoaded()
ContinueWatchingPreferencesRepository.ensureLoaded() ContinueWatchingPreferencesRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
TraktCommentsSettings.ensureLoaded() TraktCommentsSettings.ensureLoaded()
EpisodeReleaseNotificationsRepository.ensureLoaded() EpisodeReleaseNotificationsRepository.ensureLoaded()
} }
@ -257,12 +274,15 @@ object ProfileSettingsSync {
private fun currentObservedStateSignature(): String = listOf( private fun currentObservedStateSignature(): String = listOf(
"theme=${ThemeSettingsRepository.selectedTheme.value.name}", "theme=${ThemeSettingsRepository.selectedTheme.value.name}",
"amoled=${ThemeSettingsRepository.amoledEnabled.value}", "amoled=${ThemeSettingsRepository.amoledEnabled.value}",
"liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}",
"poster_card_style=${PosterCardStyleRepository.uiState.value}", "poster_card_style=${PosterCardStyleRepository.uiState.value}",
"player=${PlayerSettingsRepository.uiState.value}", "player=${PlayerSettingsRepository.uiState.value}",
"tmdb=${TmdbSettingsRepository.uiState.value}", "tmdb=${TmdbSettingsRepository.uiState.value}",
"mdblist=${MdbListSettingsRepository.uiState.value}", "mdblist=${MdbListSettingsRepository.uiState.value}",
"meta=${MetaScreenSettingsRepository.uiState.value}", "meta=${MetaScreenSettingsRepository.uiState.value}",
"collection_mobile_settings=${CollectionMobileSettingsRepository.uiState.value}",
"continue=${ContinueWatchingPreferencesRepository.uiState.value}", "continue=${ContinueWatchingPreferencesRepository.uiState.value}",
"trakt_settings=${TraktSettingsRepository.uiState.value}",
"trakt_comments=${TraktCommentsSettings.enabled.value}", "trakt_comments=${TraktCommentsSettings.enabled.value}",
"episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}", "episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}",
).joinToString(separator = "||") ).joinToString(separator = "||")
@ -282,7 +302,9 @@ private data class MobileProfileSettingsFeatures(
@SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()), @SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()), @SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "", @SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "",
@SerialName("collection_mobile_settings_payload") val collectionMobileSettingsPayload: String = "",
@SerialName("continue_watching_settings_payload") val continueWatchingSettingsPayload: 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("trakt_comments_settings") val traktCommentsSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("notifications_settings") val notificationsSettings: NotificationsSettingsPayload = NotificationsSettingsPayload(), @SerialName("notifications_settings") val notificationsSettings: NotificationsSettingsPayload = NotificationsSettingsPayload(),
) )

View file

@ -1,11 +1,32 @@
package com.nuvio.app.core.ui package com.nuvio.app.core.ui
enum class AppTheme(val displayName: String) { import nuvio.composeapp.generated.resources.Res
CRIMSON("Crimson"), import nuvio.composeapp.generated.resources.theme_amber
OCEAN("Ocean"), import nuvio.composeapp.generated.resources.theme_crimson
VIOLET("Violet"), import nuvio.composeapp.generated.resources.theme_emerald
EMERALD("Emerald"), import nuvio.composeapp.generated.resources.theme_ocean
AMBER("Amber"), import nuvio.composeapp.generated.resources.theme_rose
ROSE("Rose"), import nuvio.composeapp.generated.resources.theme_violet
WHITE("White"), 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.ui.unit.dp
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack 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.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -141,7 +145,7 @@ fun NuvioScreenHeader(
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack, imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "Back", contentDescription = stringResource(Res.string.action_back),
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
) )
} }
@ -233,7 +237,7 @@ fun NuvioBackButton(
contentColor: Color = MaterialTheme.colorScheme.onSurface, contentColor: Color = MaterialTheme.colorScheme.onSurface,
buttonSize: Dp = 40.dp, buttonSize: Dp = 40.dp,
iconSize: Dp = 22.dp, iconSize: Dp = 22.dp,
contentDescription: String = "Back", contentDescription: String = stringResource(Res.string.action_back),
) { ) {
Box( Box(
modifier = modifier modifier = modifier
@ -375,7 +379,7 @@ fun NuvioStatusModal(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isVisible: Boolean, isVisible: Boolean,
isBusy: Boolean = false, isBusy: Boolean = false,
confirmText: String = "OK", confirmText: String = stringResource(Res.string.action_ok),
dismissText: String? = null, dismissText: String? = null,
onConfirm: () -> Unit, onConfirm: () -> Unit,
onDismiss: (() -> Unit)? = null, onDismiss: (() -> Unit)? = null,

View file

@ -30,6 +30,12 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import kotlinx.coroutines.launch 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -70,14 +76,14 @@ fun NuvioContinueWatchingActionSheet(
NuvioBottomSheetDivider() NuvioBottomSheetDivider()
NuvioBottomSheetActionRow( NuvioBottomSheetActionRow(
icon = Icons.Default.Info, icon = Icons.Default.Info,
title = "Go to details", title = stringResource(Res.string.cw_action_go_to_details),
onClick = { dismissAfter(onOpenDetails) }, onClick = { dismissAfter(onOpenDetails) },
) )
if (showManualPlayOption && onPlayManually != null) { if (showManualPlayOption && onPlayManually != null) {
NuvioBottomSheetDivider() NuvioBottomSheetDivider()
NuvioBottomSheetActionRow( NuvioBottomSheetActionRow(
icon = Icons.Default.PlayArrow, icon = Icons.Default.PlayArrow,
title = "Play manually", title = stringResource(Res.string.play_manually),
onClick = { dismissAfter(onPlayManually) }, onClick = { dismissAfter(onPlayManually) },
) )
} }
@ -85,14 +91,14 @@ fun NuvioContinueWatchingActionSheet(
NuvioBottomSheetDivider() NuvioBottomSheetDivider()
NuvioBottomSheetActionRow( NuvioBottomSheetActionRow(
icon = Icons.Default.Replay, icon = Icons.Default.Replay,
title = "Start from beginning", title = stringResource(Res.string.cw_action_start_from_beginning),
onClick = { dismissAfter(onStartFromBeginning) }, onClick = { dismissAfter(onStartFromBeginning) },
) )
} }
NuvioBottomSheetDivider() NuvioBottomSheetDivider()
NuvioBottomSheetActionRow( NuvioBottomSheetActionRow(
icon = Icons.Default.DeleteOutline, icon = Icons.Default.DeleteOutline,
title = "Remove", title = stringResource(Res.string.cw_action_remove),
onClick = { dismissAfter(onRemove) }, onClick = { dismissAfter(onRemove) },
) )
} }
@ -152,7 +158,7 @@ private fun ContinueWatchingSheetHeader(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Text( Text(
text = item.subtitle, text = localizedContinueWatchingSubtitle(item),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2, 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 coil3.compose.AsyncImage
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch 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 import kotlin.math.roundToInt
private const val AutoDismissDelayMs = 15_000L private const val AutoDismissDelayMs = 15_000L
@ -202,7 +205,7 @@ fun NuvioFloatingPrompt(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Text( Text(
text = "Continue where you left off", text = stringResource(Res.string.floating_prompt_continue_where_left_off),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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.NetworkCondition
import com.nuvio.app.core.network.messageForEmptyState import com.nuvio.app.core.network.messageForEmptyState
import com.nuvio.app.core.network.titleForEmptyState 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 @Composable
fun NuvioNetworkOfflineCard( fun NuvioNetworkOfflineCard(
@ -32,9 +35,9 @@ fun NuvioNetworkOfflineCard(
if (onRetry != null) { if (onRetry != null) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
NuvioPrimaryButton( NuvioPrimaryButton(
text = "Retry", text = stringResource(Res.string.action_retry),
onClick = onRetry, 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.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -12,10 +13,14 @@ internal expect val nuvioBottomNavigationExtraVerticalPadding: Dp
@Composable @Composable
internal expect fun nuvioBottomNavigationBarInsets(): WindowInsets internal expect fun nuvioBottomNavigationBarInsets(): WindowInsets
internal val LocalNuvioBottomNavigationOverlayPadding = staticCompositionLocalOf { 0.dp }
@Composable @Composable
internal fun nuvioSafeBottomPadding(extra: Dp = 0.dp): Dp { internal fun nuvioSafeBottomPadding(extra: Dp = 0.dp): Dp {
val navigationBarBottom = nuvioBottomNavigationBarInsets() val navigationBarBottom = nuvioBottomNavigationBarInsets()
.asPaddingValues() .asPaddingValues()
.calculateBottomPadding() .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.core.format.formatReleaseDateForDisplay
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
import kotlinx.coroutines.launch 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -72,7 +79,11 @@ fun NuvioPosterActionSheet(
NuvioBottomSheetDivider() NuvioBottomSheetDivider()
NuvioBottomSheetActionRow( NuvioBottomSheetActionRow(
icon = if (isSaved) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, 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 = { onClick = {
onToggleLibrary() onToggleLibrary()
coroutineScope.launch { coroutineScope.launch {
@ -86,7 +97,11 @@ fun NuvioPosterActionSheet(
NuvioBottomSheetDivider() NuvioBottomSheetDivider()
NuvioBottomSheetActionRow( NuvioBottomSheetActionRow(
icon = if (isWatched) Icons.Default.CheckCircle else Icons.Default.CheckCircleOutline, 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 = { onClick = {
onToggleWatched() onToggleWatched()
coroutineScope.launch { coroutineScope.launch {
@ -114,7 +129,7 @@ fun NuvioWatchedBadge(
) { ) {
Icon( Icon(
imageVector = Icons.Default.Check, imageVector = Icons.Default.Check,
contentDescription = "Watched", contentDescription = stringResource(Res.string.episodes_cd_watched),
tint = MaterialTheme.colorScheme.onPrimary, tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(12.dp), 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage 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 { enum class NuvioPosterShape {
Poster, Poster,
@ -78,10 +82,10 @@ fun <T> NuvioShelfSection(
) { ) {
if (key != null) { if (key != null) {
items( items(
items = entries, items = entries.withDuplicateSafeLazyKeys(key),
key = key, key = { entry -> entry.lazyKey },
) { entry -> ) { keyedEntry ->
itemContent(entry) itemContent(keyedEntry.value)
} }
} else { } else {
items(entries) { entry -> items(entries) { entry ->
@ -156,7 +160,7 @@ fun NuvioPosterCard(
if (!bottomLeftLogoUrl.isNullOrBlank()) { if (!bottomLeftLogoUrl.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = bottomLeftLogoUrl, model = bottomLeftLogoUrl,
contentDescription = "$title logo", contentDescription = stringResource(Res.string.poster_logo_content_description, title),
modifier = Modifier modifier = Modifier
.width(catalogLogoOverlaySize.width) .width(catalogLogoOverlaySize.width)
.height(catalogLogoOverlaySize.height), .height(catalogLogoOverlaySize.height),
@ -280,7 +284,7 @@ private fun NuvioViewAllPill(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "View All", text = stringResource(Res.string.home_view_all),
style = textStyle, style = textStyle,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )

View file

@ -44,9 +44,9 @@ private fun buildColorScheme(palette: ThemeColorPalette, amoled: Boolean = false
onSecondary = palette.onSecondaryVariant, onSecondary = palette.onSecondaryVariant,
background = if (amoled) Color.Black else palette.background, background = if (amoled) Color.Black else palette.background,
onBackground = Color(0xFFF5F7F8), onBackground = Color(0xFFF5F7F8),
surface = if (amoled) Color(0xFF050505) else palette.backgroundElevated, surface = palette.backgroundElevated,
onSurface = Color(0xFFF5F7F8), onSurface = Color(0xFFF5F7F8),
surfaceVariant = if (amoled) Color(0xFF0A0A0A) else palette.backgroundCard, surfaceVariant = palette.backgroundCard,
onSurfaceVariant = Color(0xFF969CA3), onSurfaceVariant = Color(0xFF969CA3),
outline = Color(0xFF252A2A), outline = Color(0xFF252A2A),
error = Color(0xFFE36A8A), error = Color(0xFFE36A8A),

View file

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

View file

@ -1,5 +1,10 @@
package com.nuvio.app.features.addons 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( data class AddonManifest(
val id: String, val id: String,
val name: String, val name: String,
@ -54,7 +59,9 @@ data class ManagedAddon(
val displayTitle: String val displayTitle: String
get() = userSetName?.takeIf { it.isNotBlank() && it != manifest?.name } get() = userSetName?.takeIf { it.isNotBlank() && it != manifest?.name }
?: manifest?.name ?: manifest?.name
?: manifestUrl.substringBefore("?").substringAfterLast("/").ifBlank { "Addon" } ?: manifestUrl.substringBefore("?").substringAfterLast("/").ifBlank {
runBlocking { getString(Res.string.generic_addon) }
}
} }
data class AddonsUiState( data class AddonsUiState(

View file

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

View file

@ -23,6 +23,8 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
@Serializable @Serializable
private data class AddonRow( private data class AddonRow(
@ -198,17 +200,17 @@ object AddonRepository {
suspend fun addAddon(rawUrl: String): AddAddonResult { suspend fun addAddon(rawUrl: String): AddAddonResult {
if (isUsingPrimaryAddonsFromSecondaryProfile()) { 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" } log.i { "addAddon() — rawUrl=$rawUrl" }
val manifestUrl = try { val manifestUrl = try {
normalizeManifestUrl(rawUrl) normalizeManifestUrl(rawUrl)
} catch (error: IllegalArgumentException) { } 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 }) { 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 { val manifest = try {
@ -220,7 +222,7 @@ object AddonRepository {
) )
} }
} catch (error: Throwable) { } 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 -> _uiState.update { current ->
@ -250,6 +252,27 @@ object AddonRepository {
pushToServer() 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() { fun refreshAll() {
_uiState.value.addons.distinctBy { it.manifestUrl }.forEach { addon -> _uiState.value.addons.distinctBy { it.manifestUrl }.forEach { addon ->
refreshAddon(addon.manifestUrl) refreshAddon(addon.manifestUrl)
@ -289,7 +312,7 @@ object AddonRepository {
onFailure = { error -> onFailure = { error ->
addon.copy( addon.copy(
isRefreshing = false, isRefreshing = false,
errorMessage = error.message ?: "Unable to load manifest", errorMessage = error.message ?: getString(Res.string.addon_load_manifest_failed),
) )
}, },
) )

View file

@ -12,11 +12,15 @@ internal fun buildAddonResourceUrl(
): String { ): String {
val encodedId = id.encodeAddonPathSegment() val encodedId = id.encodeAddonPathSegment()
val baseUrl = addonTransportBaseUrl(manifestUrl) val baseUrl = addonTransportBaseUrl(manifestUrl)
return if (extraPathSegment.isNullOrEmpty()) { val query = manifestUrl.substringAfter("?", "").let { query ->
if (query.isBlank()) "" else "?$query"
}
val resourceUrl = if (extraPathSegment.isNullOrEmpty()) {
"$baseUrl/$resource/$type/$encodedId.json" "$baseUrl/$resource/$type/$encodedId.json"
} else { } else {
"$baseUrl/$resource/$type/$encodedId/$extraPathSegment.json" "$baseUrl/$resource/$type/$encodedId/$extraPathSegment.json"
} }
return resourceUrl + query
} }
@ -43,4 +47,4 @@ internal fun String.encodeAddonPathSegment(): String =
} }
} }
private const val ADDON_URL_HEX = "0123456789ABCDEF" private const val ADDON_URL_HEX = "0123456789ABCDEF"

View file

@ -12,12 +12,14 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.Delete
import androidx.compose.material.icons.rounded.Extension import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme 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.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.NuvioStatusModal
import com.nuvio.app.core.ui.NuvioSurfaceCard import com.nuvio.app.core.ui.NuvioSurfaceCard
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun AddonsScreen( fun AddonsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
title: String = "Addons", title: String? = null,
onBack: (() -> Unit)? = null, onBack: (() -> Unit)? = null,
) { ) {
NuvioScreen(modifier = modifier) { NuvioScreen(modifier = modifier) {
stickyHeader { stickyHeader {
NuvioScreenHeader( NuvioScreenHeader(
title = title, title = title ?: stringResource(Res.string.addon_title),
onBack = onBack, onBack = onBack,
) { ) {
} }
@ -80,10 +85,12 @@ internal fun AddonsSettingsPageContent(
} }
val uiState by AddonRepository.uiState.collectAsStateWithLifecycle() val uiState by AddonRepository.uiState.collectAsStateWithLifecycle()
val uriHandler = LocalUriHandler.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var addonUrl by rememberSaveable { mutableStateOf("") } var addonUrl by rememberSaveable { mutableStateOf("") }
var formMessage by rememberSaveable { mutableStateOf<String?>(null) } var formMessage by rememberSaveable { mutableStateOf<String?>(null) }
var installModalState by remember { mutableStateOf<AddonInstallModalState?>(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() } val overview = remember(uiState.addons) { uiState.addons.toOverview() }
@ -91,10 +98,10 @@ internal fun AddonsSettingsPageContent(
modifier = modifier, modifier = modifier,
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
SectionHeader("OVERVIEW") SectionHeader(stringResource(Res.string.addons_section_overview))
OverviewCard(overview = overview) OverviewCard(overview = overview)
SectionHeader("ADD ADDON") SectionHeader(stringResource(Res.string.addons_section_add_addon))
AddAddonCard( AddAddonCard(
addonUrl = addonUrl, addonUrl = addonUrl,
formMessage = formMessage, formMessage = formMessage,
@ -105,7 +112,7 @@ internal fun AddonsSettingsPageContent(
onAddClick = { onAddClick = {
val requestedUrl = addonUrl.trim() val requestedUrl = addonUrl.trim()
if (requestedUrl.isBlank()) { if (requestedUrl.isBlank()) {
formMessage = "Enter an addon URL." formMessage = enterAddonUrlMessage
return@AddAddonCard return@AddAddonCard
} }
@ -127,14 +134,38 @@ internal fun AddonsSettingsPageContent(
}, },
) )
SectionHeader("INSTALLED ADDONS") SectionHeader(stringResource(Res.string.addons_section_installed))
if (uiState.addons.isEmpty()) { if (uiState.addons.isEmpty()) {
EmptyStateCard() EmptyStateCard()
} else { } 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( InstalledAddonCard(
addon = addon, 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) }, onRefreshClick = { AddonRepository.refreshAddon(addon.manifestUrl) },
onConfigureClick = if (showConfigureAction && !configureUrl.isNullOrBlank()) {
{
runCatching {
uriHandler.openUri(configureUrl)
}
}
} else {
null
},
onDeleteClick = { AddonRepository.removeAddon(addon.manifestUrl) }, onDeleteClick = { AddonRepository.removeAddon(addon.manifestUrl) },
) )
} }
@ -143,12 +174,30 @@ internal fun AddonsSettingsPageContent(
val modalState = installModalState val modalState = installModalState
if (modalState != null) { 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( NuvioStatusModal(
title = modalState.title, title = modalTitle,
message = modalState.message, message = modalMessage,
isVisible = true, isVisible = true,
isBusy = modalState.isBusy, isBusy = modalState.isBusy,
confirmText = modalState.confirmText, confirmText = modalConfirmText,
onConfirm = { onConfirm = {
if (!modalState.isBusy) { if (!modalState.isBusy) {
installModalState = null installModalState = null
@ -172,19 +221,19 @@ private fun OverviewCard(overview: AddonOverview) {
) { ) {
OverviewStat( OverviewStat(
value = overview.totalAddons.toString(), value = overview.totalAddons.toString(),
label = "Addons", label = stringResource(Res.string.addons_overview_addons),
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
VerticalSeparator() VerticalSeparator()
OverviewStat( OverviewStat(
value = overview.activeAddons.toString(), value = overview.activeAddons.toString(),
label = "Active", label = stringResource(Res.string.addons_overview_active),
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
VerticalSeparator() VerticalSeparator()
OverviewStat( OverviewStat(
value = overview.totalCatalogs.toString(), value = overview.totalCatalogs.toString(),
label = "Catalogs", label = stringResource(Res.string.addons_overview_catalogs),
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
@ -236,11 +285,11 @@ private fun AddAddonCard(
NuvioInputField( NuvioInputField(
value = addonUrl, value = addonUrl,
onValueChange = onAddonUrlChange, onValueChange = onAddonUrlChange,
placeholder = "Addon URL", placeholder = stringResource(Res.string.addons_input_placeholder),
) )
Spacer(modifier = Modifier.height(18.dp)) Spacer(modifier = Modifier.height(18.dp))
NuvioPrimaryButton( NuvioPrimaryButton(
text = "Install Addon", text = stringResource(Res.string.addons_install_button),
enabled = addonUrl.isNotBlank(), enabled = addonUrl.isNotBlank(),
onClick = onAddClick, onClick = onAddClick,
) )
@ -256,33 +305,21 @@ private fun AddAddonCard(
} }
private sealed interface AddonInstallModalState { private sealed interface AddonInstallModalState {
val title: String
val message: String
val confirmText: String
val isBusy: Boolean val isBusy: Boolean
data object Checking : AddonInstallModalState { 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 override val isBusy: Boolean = true
} }
data class Success( data class Success(
private val addonName: String, val addonName: String,
) : AddonInstallModalState { ) : 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 override val isBusy: Boolean = false
} }
data class Error( data class Error(
private val reason: String, val reason: String,
) : AddonInstallModalState { ) : AddonInstallModalState {
override val title: String = "Install Failed"
override val message: String = reason
override val confirmText: String = "Close"
override val isBusy: Boolean = false override val isBusy: Boolean = false
} }
} }
@ -291,13 +328,13 @@ private sealed interface AddonInstallModalState {
private fun EmptyStateCard() { private fun EmptyStateCard() {
NuvioSurfaceCard { NuvioSurfaceCard {
Text( Text(
text = "No addons installed yet.", text = stringResource(Res.string.addons_empty_title),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( 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, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -307,7 +344,10 @@ private fun EmptyStateCard() {
@Composable @Composable
private fun InstalledAddonCard( private fun InstalledAddonCard(
addon: ManagedAddon, addon: ManagedAddon,
onMoveUpClick: (() -> Unit)?,
onMoveDownClick: (() -> Unit)?,
onRefreshClick: () -> Unit, onRefreshClick: () -> Unit,
onConfigureClick: (() -> Unit)?,
onDeleteClick: () -> Unit, onDeleteClick: () -> Unit,
) { ) {
val manifest = addon.manifest val manifest = addon.manifest
@ -315,54 +355,79 @@ private fun InstalledAddonCard(
NuvioSurfaceCard { NuvioSurfaceCard {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
) { ) {
Row( AddonIconBadge(
modifier = Modifier.weight(1f), imageUrl = manifest?.logoUrl,
verticalAlignment = Alignment.Top, icon = Icons.Rounded.Extension,
) { tint = if (manifest != null) Color(0xFF71BDE8) else MaterialTheme.colorScheme.onSurfaceVariant,
AddonIconBadge( )
imageUrl = manifest?.logoUrl, Spacer(modifier = Modifier.width(16.dp))
icon = Icons.Rounded.Extension, Column(modifier = Modifier.weight(1f)) {
tint = if (manifest != null) Color(0xFF71BDE8) else MaterialTheme.colorScheme.onSurfaceVariant, Text(
text = addon.displayTitle,
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
) )
Spacer(modifier = Modifier.width(16.dp)) manifest?.version?.let { version ->
Column(modifier = Modifier.weight(1f)) { Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = addon.displayTitle, text = stringResource(Res.string.addons_version_format, version),
style = MaterialTheme.typography.headlineLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
) )
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) HorizontalDivider(color = MaterialTheme.colorScheme.outline)
Spacer(modifier = Modifier.height(18.dp)) Spacer(modifier = Modifier.height(18.dp))
@ -373,16 +438,16 @@ private fun InstalledAddonCard(
) { ) {
NuvioInfoBadge( NuvioInfoBadge(
text = when { text = when {
addon.isRefreshing -> "Refreshing" addon.isRefreshing -> stringResource(Res.string.addons_badge_refreshing)
manifest != null -> "Active" manifest != null -> stringResource(Res.string.addons_badge_active)
else -> "Unavailable" else -> stringResource(Res.string.addons_badge_unavailable)
}, },
) )
manifest?.let { manifest?.let {
NuvioInfoBadge(text = "${it.resources.size} resources") NuvioInfoBadge(text = stringResource(Res.string.addons_badge_resources, it.resources.size))
NuvioInfoBadge(text = "${it.catalogs.size} catalogs") NuvioInfoBadge(text = stringResource(Res.string.addons_badge_catalogs, it.catalogs.size))
if (it.behaviorHints.configurable) { if (it.behaviorHints.configurable) {
NuvioInfoBadge(text = "Configurable") NuvioInfoBadge(text = stringResource(Res.string.addons_badge_configurable))
} }
} }
} }
@ -391,7 +456,7 @@ private fun InstalledAddonCard(
addon.isRefreshing -> { addon.isRefreshing -> {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = "Loading manifest details...", text = stringResource(Res.string.addons_loading_manifest_details),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -468,6 +533,7 @@ private fun AddonIconBadge(
} }
} }
@Composable
private fun manifestSummary(manifest: AddonManifest): String { private fun manifestSummary(manifest: AddonManifest): String {
val resources = manifest.resources.joinToString(separator = ", ") { it.name } val resources = manifest.resources.joinToString(separator = ", ") { it.name }
val types = manifest.types.joinToString(separator = " / ") { it.replaceFirstChar(Char::uppercase) } val types = manifest.types.joinToString(separator = " / ") { it.replaceFirstChar(Char::uppercase) }
@ -477,10 +543,19 @@ private fun manifestSummary(manifest: AddonManifest): String {
append(resources) append(resources)
if (manifest.idPrefixes.isNotEmpty()) { if (manifest.idPrefixes.isNotEmpty()) {
append("") append("")
append("${manifest.idPrefixes.size} id rules") append(stringResource(Res.string.addons_summary_id_rules, manifest.idPrefixes.size))
} }
if (manifest.behaviorHints.p2p) { if (manifest.behaviorHints.p2p) {
append(" • 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 kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.app_logo_wordmark 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.painterResource
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun AuthScreen( fun AuthScreen(
@ -97,7 +112,7 @@ fun AuthScreen(
) { ) {
Image( Image(
painter = painterResource(Res.drawable.app_logo_wordmark), painter = painterResource(Res.drawable.app_logo_wordmark),
contentDescription = "Nuvio", contentDescription = null,
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.6f) .fillMaxWidth(0.6f)
.height(48.dp), .height(48.dp),
@ -105,7 +120,7 @@ fun AuthScreen(
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "Stream everything, everywhere", text = stringResource(Res.string.compose_auth_tagline),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -119,7 +134,8 @@ fun AuthScreen(
label = "heading", label = "heading",
) { signUp -> ) { signUp ->
Text( 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, style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
@ -131,8 +147,8 @@ fun AuthScreen(
label = "subtitle", label = "subtitle",
) { signUp -> ) { signUp ->
Text( Text(
text = if (signUp) "Sign up to sync your data across devices" text = if (signUp) stringResource(Res.string.compose_auth_sign_up_subtitle)
else "Sign in to access your library and progress", else stringResource(Res.string.compose_auth_sign_in_subtitle),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -150,7 +166,7 @@ fun AuthScreen(
singleLine = true, singleLine = true,
placeholder = { placeholder = {
Text( Text(
text = "Email", text = stringResource(Res.string.compose_auth_email),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
}, },
@ -183,7 +199,7 @@ fun AuthScreen(
singleLine = true, singleLine = true,
placeholder = { placeholder = {
Text( Text(
text = "Password", text = stringResource(Res.string.compose_auth_password),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
}, },
@ -240,7 +256,13 @@ fun AuthScreen(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
NuvioPrimaryButton( 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, enabled = email.isNotBlank() && password.length >= 6 && !isLoading,
onClick = { onClick = {
isLoading = true isLoading = true
@ -279,7 +301,8 @@ fun AuthScreen(
label = "togglePrompt", label = "togglePrompt",
) { signUp -> ) { signUp ->
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -290,7 +313,8 @@ fun AuthScreen(
label = "toggleAction", label = "toggleAction",
) { signUp -> ) { signUp ->
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
@ -317,7 +341,7 @@ fun AuthScreen(
.background(MaterialTheme.colorScheme.outline), .background(MaterialTheme.colorScheme.outline),
) )
Text( Text(
text = " or ", text = stringResource(Res.string.compose_auth_or_separator),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -346,7 +370,7 @@ fun AuthScreen(
), ),
) { ) {
Text( Text(
text = "Continue Without Account", text = stringResource(Res.string.compose_auth_continue_without_account),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
@ -354,7 +378,7 @@ fun AuthScreen(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text( Text(
text = "Your data will only be stored locally", text = stringResource(Res.string.compose_auth_store_locally),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center, 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.LibraryRepository
import com.nuvio.app.features.library.toMetaPreview 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -10,6 +13,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
const val INTERNAL_LIBRARY_MANIFEST_URL = "nuvio://library" const val INTERNAL_LIBRARY_MANIFEST_URL = "nuvio://library"
@ -92,7 +97,7 @@ object CatalogRepository {
items = emptyList(), items = emptyList(),
isLoading = false, isLoading = false,
nextSkip = null, 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, catalogId = request.catalogId,
genre = request.genre, genre = request.genre,
skip = requestedSkip.takeIf { it > 0 }, skip = requestedSkip.takeIf { it > 0 },
) ).withUnreleasedFilter()
}.fold( }.fold(
onSuccess = { page -> onSuccess = { page ->
if (activeRequest != request) return@fold if (activeRequest != request) return@fold
@ -148,7 +153,7 @@ object CatalogRepository {
items = if (reset) emptyList() else current.items, items = if (reset) emptyList() else current.items,
isLoading = false, isLoading = false,
nextSkip = null, 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( private data class CatalogRequest(
val manifestUrl: String, val manifestUrl: String,
val type: 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.rememberPosterCardStyleUiState
import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.core.ui.posterCardClickable
import com.nuvio.app.core.ui.nuvioSafeBottomPadding 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.MetaPreview
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.home.stableKey import com.nuvio.app.features.home.stableKey
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun CatalogScreen( fun CatalogScreen(
@ -71,20 +75,21 @@ fun CatalogScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val uiState by CatalogRepository.uiState.collectAsStateWithLifecycle() val uiState by CatalogRepository.uiState.collectAsStateWithLifecycle()
val homeCatalogSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle()
val posterCardStyle = rememberPosterCardStyleUiState() val posterCardStyle = rememberPosterCardStyleUiState()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
var headerHeightPx by remember { mutableIntStateOf(0) } var headerHeightPx by remember { mutableIntStateOf(0) }
var observedOfflineState by remember { mutableStateOf(false) } var observedOfflineState by remember { mutableStateOf(false) }
LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination) { LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination, homeCatalogSettingsUiState.hideUnreleasedContent) {
CatalogRepository.load( CatalogRepository.load(
manifestUrl = manifestUrl, manifestUrl = manifestUrl,
type = type, type = type,
catalogId = catalogId, catalogId = catalogId,
genre = genre, genre = genre,
supportsPagination = supportsPagination, supportsPagination = supportsPagination,
force = false, force = true,
) )
} }
@ -173,9 +178,10 @@ fun CatalogScreen(
} }
} else { } else {
items( items(
items = uiState.items, items = uiState.items.withDuplicateSafeLazyKeys { item -> item.stableKey() },
key = { item -> item.stableKey() }, key = { item -> item.lazyKey },
) { item -> ) { keyedItem ->
val item = keyedItem.value
CatalogPosterTile( CatalogPosterTile(
item = item, item = item,
cornerRadiusDp = posterCardStyle.cornerRadiusDp, cornerRadiusDp = posterCardStyle.cornerRadiusDp,
@ -329,12 +335,12 @@ private fun CatalogEmptyState(
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Text( Text(
text = "No titles found", text = stringResource(Res.string.catalog_empty_title),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
) )
Text( Text(
text = errorMessage ?: "This catalog did not return any items.", text = errorMessage ?: stringResource(Res.string.catalog_empty_message),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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 co.touchlab.kermit.Logger
import com.nuvio.app.features.home.PosterShape 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@ -22,11 +28,46 @@ data class CollectionEditorUiState(
val editingFolder: CollectionFolder? = null, val editingFolder: CollectionFolder? = null,
val showFolderEditor: Boolean = false, val showFolderEditor: Boolean = false,
val showCatalogPicker: Boolean = false, val showCatalogPicker: Boolean = false,
val showTmdbSourcePicker: Boolean = false,
val showTraktSourcePicker: Boolean = false,
val editingTraktSourceIndex: Int? = null,
val genrePickerSourceIndex: 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 { object CollectionEditorRepository {
private val log = Logger.withTag("CollectionEditorRepository") private val log = Logger.withTag("CollectionEditorRepository")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _uiState = MutableStateFlow(CollectionEditorUiState()) private val _uiState = MutableStateFlow(CollectionEditorUiState())
val uiState: StateFlow<CollectionEditorUiState> = _uiState.asStateFlow() val uiState: StateFlow<CollectionEditorUiState> = _uiState.asStateFlow()
@ -93,10 +134,10 @@ object CollectionEditorRepository {
} }
@OptIn(ExperimentalUuidApi::class) @OptIn(ExperimentalUuidApi::class)
fun addFolder() { fun addFolder(defaultTitle: String) {
val newFolder = CollectionFolder( val newFolder = CollectionFolder(
id = Uuid.random().toString(), id = Uuid.random().toString(),
title = "New Folder", title = defaultTitle,
) )
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
editingFolder = newFolder, editingFolder = newFolder,
@ -154,10 +195,10 @@ object CollectionEditorRepository {
) )
} }
fun updateFolderFocusGifEnabled(enabled: Boolean) { fun updateFolderMobileFocusGifEnabled(enabled: Boolean) {
val folder = _uiState.value.editingFolder ?: return val folder = _uiState.value.editingFolder ?: return
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
editingFolder = folder.copy(focusGifEnabled = enabled), editingFolder = folder.copy(mobileFocusGifEnabled = enabled),
) )
} }
@ -177,13 +218,8 @@ object CollectionEditorRepository {
fun updateFolderTileShape(shape: PosterShape) { fun updateFolderTileShape(shape: PosterShape) {
val folder = _uiState.value.editingFolder ?: return val folder = _uiState.value.editingFolder ?: return
val shapeStr = when (shape) {
PosterShape.Poster -> "Poster"
PosterShape.Landscape -> "Landscape"
PosterShape.Square -> "Square"
}
_uiState.value = _uiState.value.copy( _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, catalogId = catalog.catalogId,
genre = defaultGenre, genre = defaultGenre,
) )
if (folder.catalogSources.any { if (folder.resolvedCatalogSources.any {
it.addonId == source.addonId && it.type == source.type && it.catalogId == source.catalogId it.addonId == source.addonId && it.type == source.type && it.catalogId == source.catalogId
}) return }) return
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
editingFolder = folder.copy(catalogSources = folder.catalogSources + source), editingFolder = folder.withSources(folder.resolvedSources + source.toCollectionSource()),
) )
} }
fun removeCatalogSource(index: Int) { fun removeCatalogSource(index: Int) {
val folder = _uiState.value.editingFolder ?: return 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( _uiState.value = _uiState.value.copy(
editingFolder = folder.copy( editingFolder = folder.withSources(sources.toMutableList().apply { removeAt(index) }),
catalogSources = folder.catalogSources.toMutableList().apply { removeAt(index) },
),
genrePickerSourceIndex = null, genrePickerSourceIndex = null,
) )
} }
fun updateCatalogSourceGenre(index: Int, genre: String?) { fun updateCatalogSourceGenre(index: Int, genre: String?) {
val folder = _uiState.value.editingFolder ?: return val folder = _uiState.value.editingFolder ?: return
if (index !in folder.catalogSources.indices) return val sources = folder.resolvedSources
val updated = folder.catalogSources.toMutableList() if (index !in sources.indices || sources[index].addonCatalogSource() == null) return
val updated = sources.toMutableList()
updated[index] = updated[index].copy(genre = genre) updated[index] = updated[index].copy(genre = genre)
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
editingFolder = folder.copy(catalogSources = updated), editingFolder = folder.withSources(updated),
) )
} }
fun toggleCatalogSource(catalog: AvailableCatalog) { fun toggleCatalogSource(catalog: AvailableCatalog) {
val folder = _uiState.value.editingFolder ?: return val folder = _uiState.value.editingFolder ?: return
val existingIndex = folder.catalogSources.indexOfFirst { val sources = folder.resolvedSources
it.addonId == catalog.addonId && it.type == catalog.type && it.catalogId == catalog.catalogId val existingIndex = sources.indexOfFirst {
!it.isTmdb &&
!it.isTrakt &&
it.addonId == catalog.addonId &&
it.type == catalog.type &&
it.catalogId == catalog.catalogId
} }
if (existingIndex >= 0) { if (existingIndex >= 0) {
removeCatalogSource(existingIndex) removeCatalogSource(existingIndex)
@ -247,6 +288,9 @@ object CollectionEditorRepository {
fun showCatalogPicker() { fun showCatalogPicker() {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
showCatalogPicker = true, showCatalogPicker = true,
showTmdbSourcePicker = false,
showTraktSourcePicker = false,
editingTraktSourceIndex = null,
genrePickerSourceIndex = null, genrePickerSourceIndex = null,
) )
} }
@ -255,12 +299,154 @@ object CollectionEditorRepository {
_uiState.value = _uiState.value.copy(showCatalogPicker = false) _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) { fun showGenrePicker(index: Int) {
val folder = _uiState.value.editingFolder ?: return 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( _uiState.value = _uiState.value.copy(
genrePickerSourceIndex = index, genrePickerSourceIndex = index,
showCatalogPicker = false, showCatalogPicker = false,
showTmdbSourcePicker = false,
showTraktSourcePicker = false,
) )
} }
@ -270,17 +456,21 @@ object CollectionEditorRepository {
fun saveFolderEdit() { fun saveFolderEdit() {
val folder = _uiState.value.editingFolder ?: return val folder = _uiState.value.editingFolder ?: return
val normalizedFolder = folder.withSources(folder.resolvedSources)
val existing = _uiState.value.folders val existing = _uiState.value.folders
val updated = if (existing.any { it.id == folder.id }) { val updated = if (existing.any { it.id == normalizedFolder.id }) {
existing.map { if (it.id == folder.id) folder else it } existing.map { if (it.id == normalizedFolder.id) normalizedFolder else it }
} else { } else {
existing + folder existing + normalizedFolder
} }
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
folders = updated, folders = updated,
editingFolder = null, editingFolder = null,
showFolderEditor = false, showFolderEditor = false,
showCatalogPicker = false, showCatalogPicker = false,
showTmdbSourcePicker = false,
showTraktSourcePicker = false,
editingTraktSourceIndex = null,
genrePickerSourceIndex = null, genrePickerSourceIndex = null,
) )
} }
@ -290,10 +480,320 @@ object CollectionEditorRepository {
editingFolder = null, editingFolder = null,
showFolderEditor = false, showFolderEditor = false,
showCatalogPicker = false, showCatalogPicker = false,
showTmdbSourcePicker = false,
showTraktSourcePicker = false,
editingTraktSourceIndex = null,
genrePickerSourceIndex = 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 { fun save(): Boolean {
val state = _uiState.value val state = _uiState.value
if (state.title.isBlank()) return false if (state.title.isBlank()) return false
@ -308,6 +808,8 @@ object CollectionEditorRepository {
folders = state.folders, folders = state.folders,
) )
CollectionMobileSettingsRepository.replaceCollectionFolderGifSettings(collection.id, collection.folders)
if (state.isNew) { if (state.isNew) {
CollectionRepository.addCollection(collection) CollectionRepository.addCollection(collection)
} else { } else {
@ -316,3 +818,92 @@ object CollectionEditorRepository {
return true 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.NuvioSectionLabel
import com.nuvio.app.core.ui.NuvioStatusModal import com.nuvio.app.core.ui.NuvioStatusModal
import com.nuvio.app.core.ui.NuvioSurfaceCard 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.ReorderableCollectionItemScope
import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState import sh.calvin.reorderable.rememberReorderableLazyListState
@ -75,7 +77,7 @@ fun CollectionManagementScreen(
NuvioScreen { NuvioScreen {
stickyHeader { stickyHeader {
NuvioScreenHeader( NuvioScreenHeader(
title = "Collections", title = stringResource(Res.string.collections_header),
onBack = onBack, onBack = onBack,
) { ) {
IconButton(onClick = { IconButton(onClick = {
@ -84,14 +86,14 @@ fun CollectionManagementScreen(
}) { }) {
Icon( Icon(
imageVector = Icons.Rounded.ContentCopy, imageVector = Icons.Rounded.ContentCopy,
contentDescription = "Copy JSON", contentDescription = stringResource(Res.string.collections_copy_json),
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
IconButton(onClick = { showImportDialog = true }) { IconButton(onClick = { showImportDialog = true }) {
Icon( Icon(
imageVector = Icons.Rounded.ContentPaste, imageVector = Icons.Rounded.ContentPaste,
contentDescription = "Import", contentDescription = stringResource(Res.string.collections_import),
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
@ -100,8 +102,11 @@ fun CollectionManagementScreen(
item { item {
NuvioSurfaceCard { NuvioSurfaceCard {
Text( Text(
text = "${collections.size} collection${if (collections.size != 1) "s" else ""}, " + text = stringResource(
"${collections.sumOf { it.folders.size }} folder${if (collections.sumOf { it.folders.size } != 1) "s" else ""}", Res.string.collections_count_summary,
collections.size,
collections.sumOf { it.folders.size },
),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -110,13 +115,13 @@ fun CollectionManagementScreen(
item { item {
NuvioPrimaryButton( NuvioPrimaryButton(
text = "New Collection", text = stringResource(Res.string.collections_new),
onClick = { onNavigateToEditor(null) }, onClick = { onNavigateToEditor(null) },
) )
} }
if (collections.isNotEmpty()) { if (collections.isNotEmpty()) {
item { NuvioSectionLabel(text = "YOUR COLLECTIONS") } item { NuvioSectionLabel(text = stringResource(Res.string.collections_your_collections)) }
} }
if (collections.isNotEmpty()) { if (collections.isNotEmpty()) {
@ -142,13 +147,13 @@ fun CollectionManagementScreen(
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text( Text(
text = "No collections yet", text = stringResource(Res.string.collections_empty_title),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "Create one to organize your catalogs.", text = stringResource(Res.string.collections_empty_subtitle),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -187,11 +192,11 @@ fun CollectionManagementScreen(
val deleteId = showDeleteConfirm val deleteId = showDeleteConfirm
val deleteCollection = deleteId?.let { id -> collections.find { it.id == id } } val deleteCollection = deleteId?.let { id -> collections.find { it.id == id } }
NuvioStatusModal( NuvioStatusModal(
title = "Delete Collection", title = stringResource(Res.string.collections_delete_title),
message = "Delete \"${deleteCollection?.title ?: ""}\"? This cannot be undone.", message = stringResource(Res.string.collections_delete_message, deleteCollection?.title.orEmpty()),
isVisible = deleteId != null, isVisible = deleteId != null,
confirmText = "Delete", confirmText = stringResource(Res.string.action_delete),
dismissText = "Cancel", dismissText = stringResource(Res.string.action_cancel),
onConfirm = { onConfirm = {
if (deleteId != null) { if (deleteId != null) {
CollectionRepository.removeCollection(deleteId) CollectionRepository.removeCollection(deleteId)
@ -261,6 +266,13 @@ private fun CollectionListItem(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column(modifier = Modifier.weight(1f)) { 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(
text = collection.title, text = collection.title,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
@ -271,8 +283,7 @@ private fun CollectionListItem(
) )
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Text( Text(
text = "${collection.folders.size} folder${if (collection.folders.size != 1) "s" else ""}" + text = summary,
if (collection.pinToTop) " · Pinned" else "",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -298,7 +309,7 @@ private fun CollectionListItem(
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.Menu, imageVector = Icons.Rounded.Menu,
contentDescription = "Reorder", contentDescription = stringResource(Res.string.action_reorder),
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -310,7 +321,7 @@ private fun CollectionListItem(
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.Edit, imageVector = Icons.Rounded.Edit,
contentDescription = "Edit", contentDescription = stringResource(Res.string.action_edit),
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
) )
@ -321,7 +332,7 @@ private fun CollectionListItem(
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.Delete, imageVector = Icons.Rounded.Delete,
contentDescription = "Delete", contentDescription = stringResource(Res.string.action_delete),
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.error, tint = MaterialTheme.colorScheme.error,
) )
@ -349,13 +360,13 @@ private fun ImportDialog(
) { ) {
Column(modifier = Modifier.padding(20.dp)) { Column(modifier = Modifier.padding(20.dp)) {
Text( Text(
text = "Import Collections", text = stringResource(Res.string.collections_import_header),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "Paste your collections JSON below.", text = stringResource(Res.string.collections_import_paste_description),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -366,7 +377,12 @@ private fun ImportDialog(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(160.dp), .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, isError = importError != null,
supportingText = importError?.let { supportingText = importError?.let {
{ Text(it, color = MaterialTheme.colorScheme.error) } { Text(it, color = MaterialTheme.colorScheme.error) }
@ -399,7 +415,7 @@ private fun ImportDialog(
contentColor = MaterialTheme.colorScheme.onSurface, contentColor = MaterialTheme.colorScheme.onSurface,
), ),
) { ) {
Text("Cancel") Text(stringResource(Res.string.action_cancel))
} }
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(10.dp))
androidx.compose.material3.Button( androidx.compose.material3.Button(
@ -407,7 +423,7 @@ private fun ImportDialog(
enabled = importText.isNotBlank(), enabled = importText.isNotBlank(),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
) { ) {
Text("Import") Text(stringResource(Res.string.action_import))
} }
} }
} }

View file

@ -0,0 +1,155 @@
package com.nuvio.app.features.collection
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
data class CollectionMobileSettingsUiState(
val folderGifOverrides: Map<String, Boolean> = emptyMap(),
)
object CollectionMobileSettingsRepository {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val _uiState = MutableStateFlow(CollectionMobileSettingsUiState())
val uiState: StateFlow<CollectionMobileSettingsUiState> = _uiState.asStateFlow()
private var hasLoaded = false
fun ensureLoaded() {
if (hasLoaded) return
loadFromDisk()
}
fun onProfileChanged() {
loadFromDisk()
CollectionRepository.onMobileSettingsChanged()
}
fun clearLocalState() {
hasLoaded = false
_uiState.value = CollectionMobileSettingsUiState()
}
fun isFolderGifEnabled(collectionId: String, folderId: String): Boolean {
ensureLoaded()
return _uiState.value.folderGifOverrides[folderKey(collectionId, folderId)] ?: true
}
fun applyToCollections(collections: List<Collection>): List<Collection> {
ensureLoaded()
return collections.map(::applyToCollection)
}
fun applyToCollection(collection: Collection): Collection {
ensureLoaded()
return collection.copy(
folders = collection.folders.map { folder ->
folder.copy(
mobileFocusGifEnabled = isFolderGifEnabled(
collectionId = collection.id,
folderId = folder.id,
),
)
},
)
}
fun replaceCollectionFolderGifSettings(collectionId: String, folders: List<CollectionFolder>) {
ensureLoaded()
val collectionPrefix = "${collectionId.trim()}$FolderKeySeparator"
val next = _uiState.value.folderGifOverrides
.filterKeys { key -> !key.startsWith(collectionPrefix) }
.toMutableMap()
folders.forEach { folder ->
val key = folderKey(collectionId, folder.id)
if (folder.mobileFocusGifEnabled) {
next.remove(key)
} else {
next[key] = false
}
}
_uiState.value = CollectionMobileSettingsUiState(folderGifOverrides = next)
persist()
CollectionRepository.onMobileSettingsChanged()
}
private fun loadFromDisk() {
hasLoaded = true
val payload = CollectionMobileSettingsStorage.loadPayload().orEmpty().trim()
if (payload.isEmpty()) {
_uiState.value = CollectionMobileSettingsUiState()
return
}
val stored = runCatching {
json.decodeFromString<StoredCollectionMobileSettingsPayload>(payload)
}.getOrNull()
_uiState.value = CollectionMobileSettingsUiState(
folderGifOverrides = stored
?.folderGifOverrides
.orEmpty()
.mapNotNull { item ->
if (item.collectionId.isBlank() || item.folderId.isBlank()) {
null
} else {
folderKey(item.collectionId, item.folderId) to item.enabled
}
}
.toMap(),
)
}
private fun persist() {
if (_uiState.value.folderGifOverrides.isEmpty()) {
CollectionMobileSettingsStorage.savePayload("")
return
}
val payload = StoredCollectionMobileSettingsPayload(
folderGifOverrides = _uiState.value.folderGifOverrides
.mapNotNull { (key, enabled) ->
val parts = key.split(FolderKeySeparator, limit = 2)
val collectionId = parts.getOrNull(0).orEmpty()
val folderId = parts.getOrNull(1).orEmpty()
if (collectionId.isBlank() || folderId.isBlank()) {
null
} else {
StoredFolderGifOverride(
collectionId = collectionId,
folderId = folderId,
enabled = enabled,
)
}
}
.sortedWith(compareBy<StoredFolderGifOverride> { it.collectionId }.thenBy { it.folderId }),
)
CollectionMobileSettingsStorage.savePayload(json.encodeToString(payload))
}
private fun folderKey(collectionId: String, folderId: String): String =
"${collectionId.trim()}$FolderKeySeparator${folderId.trim()}"
}
private const val FolderKeySeparator = "\u001F"
@Serializable
private data class StoredCollectionMobileSettingsPayload(
@SerialName("folder_gif_overrides") val folderGifOverrides: List<StoredFolderGifOverride> = emptyList(),
)
@Serializable
private data class StoredFolderGifOverride(
@SerialName("collection_id") val collectionId: String,
@SerialName("folder_id") val folderId: String,
val enabled: Boolean = true,
)

View file

@ -0,0 +1,6 @@
package com.nuvio.app.features.collection
internal expect object CollectionMobileSettingsStorage {
fun loadPayload(): String?
fun savePayload(payload: String)
}

View file

@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.PosterShape
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
enum class FolderViewMode { enum class FolderViewMode {
TABBED_GRID, TABBED_GRID,
@ -13,7 +14,7 @@ enum class FolderViewMode {
companion object { companion object {
fun fromString(value: String): FolderViewMode = fun fromString(value: String): FolderViewMode =
when { when {
value.equals(FOLLOW_LAYOUT.name, ignoreCase = true) -> ROWS value.equals(FOLLOW_LAYOUT.name, ignoreCase = true) -> FOLLOW_LAYOUT
value.equals(ROWS.name, ignoreCase = true) -> ROWS value.equals(ROWS.name, ignoreCase = true) -> ROWS
value.equals(TABBED_GRID.name, ignoreCase = true) -> TABBED_GRID value.equals(TABBED_GRID.name, ignoreCase = true) -> TABBED_GRID
else -> TABBED_GRID else -> TABBED_GRID
@ -30,6 +31,136 @@ data class CollectionCatalogSource(
val genre: String? = null, 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 @Immutable
@Serializable @Serializable
data class CollectionFolder( data class CollectionFolder(
@ -38,10 +169,16 @@ data class CollectionFolder(
val coverImageUrl: String? = null, val coverImageUrl: String? = null,
val focusGifUrl: String? = null, val focusGifUrl: String? = null,
val focusGifEnabled: Boolean = true, val focusGifEnabled: Boolean = true,
@Transient
val mobileFocusGifEnabled: Boolean = true,
val coverEmoji: String? = null, val coverEmoji: String? = null,
val tileShape: String = "Poster", val tileShape: String = "poster",
val hideTitle: Boolean = false, val hideTitle: Boolean = false,
val sources: List<CollectionSource> = emptyList(),
val catalogSources: List<CollectionCatalogSource> = emptyList(), val catalogSources: List<CollectionCatalogSource> = emptyList(),
val heroBackdropUrl: String? = null,
val heroVideoUrl: String? = null,
val titleLogoUrl: String? = null,
) { ) {
val posterShape: PosterShape val posterShape: PosterShape
get() = when (tileShape.lowercase()) { get() = when (tileShape.lowercase()) {
@ -50,6 +187,22 @@ data class CollectionFolder(
"square" -> PosterShape.Square "square" -> PosterShape.Square
else -> PosterShape.Poster 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 @Immutable
@ -67,6 +220,11 @@ data class Collection(
get() = FolderViewMode.fromString(viewMode) get() = FolderViewMode.fromString(viewMode)
} }
private fun String?.normalizedOptionalGenre(): String? =
this
?.trim()
?.takeIf { it.isNotEmpty() && !it.equals("none", ignoreCase = true) }
data class AvailableCatalog( data class AvailableCatalog(
val addonId: String, val addonId: String,
val addonName: 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.AddonRepository
import com.nuvio.app.features.addons.ManagedAddon import com.nuvio.app.features.addons.ManagedAddon
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json 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.ExperimentalUuidApi
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@ -21,6 +37,9 @@ object CollectionRepository {
private val _collections = MutableStateFlow<List<Collection>>(emptyList()) private val _collections = MutableStateFlow<List<Collection>>(emptyList())
val collections: StateFlow<List<Collection>> = _collections.asStateFlow() 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 private var hasLoaded = false
@ -31,7 +50,10 @@ object CollectionRepository {
if (payload.isNullOrBlank()) return if (payload.isNullOrBlank()) return
runCatching { runCatching {
_collections.value = json.decodeFromString<List<Collection>>(payload) val parsed = json.parseToJsonElement(payload)
rawCollectionsJson = parsed
val decoded = json.decodeFromString<List<Collection>>(payload)
_collections.value = CollectionMobileSettingsRepository.applyToCollections(decoded)
}.onFailure { e -> }.onFailure { e ->
log.e(e) { "Failed to load collections from storage" } log.e(e) { "Failed to load collections from storage" }
} }
@ -40,11 +62,13 @@ object CollectionRepository {
fun onProfileChanged() { fun onProfileChanged() {
hasLoaded = false hasLoaded = false
_collections.value = emptyList() _collections.value = emptyList()
rawCollectionsJson = JsonArray(emptyList())
} }
fun clearLocalState() { fun clearLocalState() {
hasLoaded = false hasLoaded = false
_collections.value = emptyList() _collections.value = emptyList()
rawCollectionsJson = JsonArray(emptyList())
} }
fun getCollection(id: String): Collection? = fun getCollection(id: String): Collection? =
@ -52,14 +76,15 @@ object CollectionRepository {
fun addCollection(collection: Collection) { fun addCollection(collection: Collection) {
ensureLoaded() ensureLoaded()
_collections.value = _collections.value + collection _collections.value = _collections.value + CollectionMobileSettingsRepository.applyToCollection(collection)
persist() persist()
} }
fun updateCollection(collection: Collection) { fun updateCollection(collection: Collection) {
ensureLoaded() ensureLoaded()
val decorated = CollectionMobileSettingsRepository.applyToCollection(collection)
_collections.value = _collections.value.map { _collections.value = _collections.value.map {
if (it.id == collection.id) collection else it if (it.id == collection.id) decorated else it
} }
persist() persist()
} }
@ -71,7 +96,8 @@ object CollectionRepository {
} }
fun setCollections(collections: List<Collection>) { fun setCollections(collections: List<Collection>) {
_collections.value = collections ensureLoaded()
_collections.value = CollectionMobileSettingsRepository.applyToCollections(collections)
persist() persist()
} }
@ -96,13 +122,14 @@ object CollectionRepository {
fun exportToJson(): String { fun exportToJson(): String {
ensureLoaded() ensureLoaded()
return json.encodeToString(_collections.value) return mergedCollectionsJson().toString()
} }
fun importFromJson(jsonString: String): Result<List<Collection>> { fun importFromJson(jsonString: String): Result<List<Collection>> {
return runCatching { return runCatching {
rawCollectionsJson = json.parseToJsonElement(jsonString)
val imported = json.decodeFromString<List<Collection>>(jsonString) val imported = json.decodeFromString<List<Collection>>(jsonString)
_collections.value = imported _collections.value = CollectionMobileSettingsRepository.applyToCollections(imported)
persist() persist()
imported imported
} }
@ -110,28 +137,85 @@ object CollectionRepository {
fun validateJson(jsonString: String): ValidationResult { fun validateJson(jsonString: String): ValidationResult {
if (jsonString.isBlank()) { 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 { return try {
val collections = json.decodeFromString<List<Collection>>(jsonString) val collections = json.decodeFromString<List<Collection>>(jsonString)
var totalFolders = 0 var totalFolders = 0
collections.forEachIndexed { ci, c -> collections.forEachIndexed { ci, c ->
if (c.id.isBlank()) { 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()) { 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 -> c.folders.forEachIndexed { fi, f ->
if (f.id.isBlank()) { 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()) { 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 -> f.resolvedSources.forEachIndexed { si, s ->
if (s.addonId.isBlank() || s.type.isBlank() || s.catalogId.isBlank()) { if (s.hasInvalidTraktListId()) {
return ValidationResult(valid = false, error = "Source ${si + 1} in folder '${f.title}' has blank fields.") 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++ totalFolders++
@ -143,7 +227,12 @@ object CollectionRepository {
folderCount = totalFolders, folderCount = totalFolders,
) )
} catch (e: Exception) { } 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 +262,34 @@ object CollectionRepository {
} }
} }
internal fun applyFromRemote(collections: List<Collection>) { internal fun applyFromRemote(collections: List<Collection>, rawJson: JsonElement) {
_collections.value = collections rawCollectionsJson = rawJson
persist() _collections.value = CollectionMobileSettingsRepository.applyToCollections(collections)
persist(sync = false)
}
internal fun onMobileSettingsChanged() {
if (!hasLoaded) return
_collections.value = CollectionMobileSettingsRepository.applyToCollections(_collections.value)
} }
private fun ensureLoaded() { private fun ensureLoaded() {
if (!hasLoaded) initialize() if (!hasLoaded) initialize()
} }
private fun persist() { private fun persist(sync: Boolean = true) {
runCatching { runCatching {
CollectionStorage.savePayload(json.encodeToString(_collections.value)) CollectionStorage.savePayload(mergedCollectionsJson().toString())
if (sync) {
_localChangeEvents.tryEmit(Unit)
}
}.onFailure { e -> }.onFailure { e ->
log.e(e) { "Failed to persist collections" } 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.SupervisorJob
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
@ -58,16 +57,13 @@ object CollectionSyncService {
return return
} }
val remoteJson = blob.collectionsJson.toString() val remoteCollectionsJson = if (blob.collectionsJson == JsonNull) {
val localJson = CollectionRepository.exportToJson() JsonArray(emptyList())
} else {
if (remoteJson == "[]" || remoteJson == "null") { blob.collectionsJson
val currentCollections = CollectionRepository.collections.value
if (currentCollections.isNotEmpty()) {
log.i { "pullFromServer — remote empty, preserving local ${currentCollections.size} collections" }
return
}
} }
val remoteJson = remoteCollectionsJson.toString()
val localJson = CollectionRepository.exportToJson()
if (remoteJson == localJson) { if (remoteJson == localJson) {
log.d { "pullFromServer — remote matches local, no update needed" } log.d { "pullFromServer — remote matches local, no update needed" }
@ -80,7 +76,7 @@ object CollectionSyncService {
if (remoteCollections != null) { if (remoteCollections != null) {
isSyncingFromRemote = true isSyncingFromRemote = true
CollectionRepository.applyFromRemote(remoteCollections) CollectionRepository.applyFromRemote(remoteCollections, remoteCollectionsJson)
isSyncingFromRemote = false isSyncingFromRemote = false
log.i { "pullFromServer — applied ${remoteCollections.size} collections from remote" } log.i { "pullFromServer — applied ${remoteCollections.size} collections from remote" }
} else { } else {
@ -125,9 +121,7 @@ object CollectionSyncService {
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
private fun observeLocalChangesAndPush() { private fun observeLocalChangesAndPush() {
observeJob = scope.launch { observeJob = scope.launch {
CollectionRepository.collections CollectionRepository.localChangeEvents
.drop(1)
.distinctUntilChanged()
.debounce(PUSH_DEBOUNCE_MS) .debounce(PUSH_DEBOUNCE_MS)
.collect { .collect {
if (isSyncingFromRemote) return@collect if (isSyncingFromRemote) return@collect

View file

@ -3,12 +3,18 @@ package com.nuvio.app.features.collection
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE 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.fetchCatalogPage
import com.nuvio.app.features.catalog.mergeCatalogItems import com.nuvio.app.features.catalog.mergeCatalogItems
import com.nuvio.app.features.catalog.supportsPagination 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.HomeCatalogSection
import com.nuvio.app.features.home.MetaPreview 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.home.stableKey
import com.nuvio.app.features.trakt.TraktPublicListSourceResolver
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -17,10 +23,16 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch 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( data class FolderTab(
val label: String, val label: String,
val typeLabel: String = "", val typeLabel: String = "",
val source: CollectionSource? = null,
val manifestUrl: String? = null, val manifestUrl: String? = null,
val type: String = "", val type: String = "",
val catalogId: String = "", val catalogId: String = "",
@ -108,36 +120,81 @@ object FolderDetailRepository {
return 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 addons = AddonRepository.uiState.value.addons
val tabs = buildList { val tabs = buildList {
if (showAll) { 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( add(
FolderTab( FolderTab(
label = "$label ($typeLabel)$genreSuffix", label = runBlocking { getString(Res.string.collections_tab_all) },
typeLabel = typeLabel, isAllTab = true,
manifestUrl = addon?.manifestUrl,
type = source.type,
catalogId = source.catalogId,
genre = source.genre,
supportsPagination = catalog?.supportsPagination() == true,
isLoading = 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( _uiState.value = FolderDetailUiState(
@ -151,11 +208,19 @@ object FolderDetailRepository {
) )
// Load catalog data for each source // Load catalog data for each source
folder.catalogSources.forEachIndexed { sourceIndex, source -> sources.forEachIndexed { sourceIndex, source ->
val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex
val addon = addons.find { it.manifest?.id == source.addonId } val catalogSource = source.addonCatalogSource()
if (addon == null) { val resolvedCatalog = catalogSource?.let { addons.findCollectionCatalog(it) }
updateTab(tabIndex) { it.copy(isLoading = false, error = "Addon not found: ${source.addonId}") } 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 return@forEachIndexed
} }
@ -163,7 +228,7 @@ object FolderDetailRepository {
} }
// If no sources, mark as done // If no sources, mark as done
if (folder.catalogSources.isEmpty()) { if (sources.isEmpty()) {
_uiState.value = _uiState.value.copy(isLoading = false) _uiState.value = _uiState.value.copy(isLoading = false)
} }
} }
@ -212,8 +277,13 @@ object FolderDetailRepository {
private fun loadTabPage(index: Int, reset: Boolean) { private fun loadTabPage(index: Int, reset: Boolean) {
val currentTab = _uiState.value.tabs.getOrNull(index) ?: return val currentTab = _uiState.value.tabs.getOrNull(index) ?: return
val manifestUrl = currentTab.manifestUrl ?: return
val requestedSkip = if (reset) 0 else currentTab.nextSkip ?: 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 -> updateTab(index) { tab ->
if (reset) { if (reset) {
@ -235,13 +305,26 @@ object FolderDetailRepository {
loadJobs.remove(index)?.cancel() loadJobs.remove(index)?.cancel()
val job = scope.launch { val job = scope.launch {
runCatching { runCatching {
fetchCatalogPage( val source = currentTab.source
manifestUrl = manifestUrl, when {
type = currentTab.type, source?.isTmdb == true -> TmdbCollectionSourceResolver.resolve(
catalogId = currentTab.catalogId, source = source,
genre = currentTab.genre, page = if (reset) 1 else requestedSkip,
skip = requestedSkip.takeIf { it > 0 }, )
)
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 -> }.onSuccess { page ->
updateTab(index) { tab -> updateTab(index) { tab ->
val mergedItems = if (reset) { val mergedItems = if (reset) {
@ -262,7 +345,7 @@ object FolderDetailRepository {
} }
rebuildAllTab() rebuildAllTab()
}.onFailure { error -> }.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 -> updateTab(index) { tab ->
tab.copy( tab.copy(
isLoading = false, 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.NuvioPosterShape
import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.nuvioSafeBottomPadding 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.HomeCatalogSection
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.PosterShape 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.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map 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 private val FolderCoverHeight = 176.dp
@ -143,7 +149,7 @@ fun FolderDetailScreen(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = "Folder not found", text = stringResource(Res.string.collections_folder_not_found),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -229,7 +235,11 @@ private fun TabbedGridContent(
onClick = { onTabSelected(index) }, onClick = { onTabSelected(index) },
text = { text = {
Text( Text(
text = tab.label, text = if (tab.isAllTab) {
stringResource(Res.string.collections_tab_all)
} else {
tab.label
},
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@ -266,9 +276,10 @@ private fun TabbedGridContent(
verticalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
) { ) {
items( items(
items = selectedTab.items, items = selectedTab.items.withDuplicateSafeLazyKeys { item -> item.stableKey() },
key = { item -> item.stableKey() }, key = { item -> item.lazyKey },
) { item -> ) { keyedItem ->
val item = keyedItem.value
NuvioPosterCard( NuvioPosterCard(
title = item.name, title = item.name,
imageUrl = item.poster, imageUrl = item.poster,
@ -317,9 +328,10 @@ private fun RowsContent(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
items( items(
items = sections, items = sections.withDuplicateSafeLazyKeys { it.key },
key = { it.key }, key = { it.lazyKey },
) { section -> ) { keyedSection ->
val section = keyedSection.value
HomeCatalogRowSection( HomeCatalogRowSection(
section = section, section = section,
entries = section.items.take(18), entries = section.items.take(18),
@ -395,7 +407,7 @@ private fun EmptyMessage() {
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = "No items found", text = stringResource(Res.string.collections_folder_empty_items),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center, 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.StreamBehaviorHints
import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamProxyHeaders import com.nuvio.app.features.streams.StreamProxyHeaders
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
@ -13,6 +14,8 @@ import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
internal object MetaDetailsParser { internal object MetaDetailsParser {
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
@ -248,10 +251,10 @@ internal object MetaDetailsParser {
MetaTrailer( MetaTrailer(
id = trailer.string("id")?.takeIf(String::isNotBlank) ?: normalizedKey, id = trailer.string("id")?.takeIf(String::isNotBlank) ?: normalizedKey,
key = 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", site = trailer.string("site")?.takeIf(String::isNotBlank) ?: "YouTube",
size = trailer.int("size"), 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, official = trailer.boolean("official") == true,
publishedAt = trailer.string("published_at") ?: trailer.string("publishedAt"), publishedAt = trailer.string("published_at") ?: trailer.string("publishedAt"),
seasonNumber = trailer.int("seasonNumber") ?: trailer.int("season_number"), seasonNumber = trailer.int("seasonNumber") ?: trailer.int("season_number"),
@ -273,7 +276,9 @@ internal object MetaDetailsParser {
?.objectValue("proxyHeaders") ?.objectValue("proxyHeaders")
?.toProxyHeaders() ?.toProxyHeaders()
val streamData = obj["streamData"] as? JsonObject 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( StreamItem(
name = obj.string("name"), name = obj.string("name"),
description = obj.string("description") ?: obj.string("title"), 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.AddonRepository
import com.nuvio.app.features.addons.buildAddonResourceUrl import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.httpGetText 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.MdbListMetadataService
import com.nuvio.app.features.mdblist.MdbListSettingsRepository import com.nuvio.app.features.mdblist.MdbListSettingsRepository
import com.nuvio.app.features.tmdb.TmdbMetadataService 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.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -19,6 +23,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
object MetaDetailsRepository { object MetaDetailsRepository {
private data class CachedMetaEntry( private data class CachedMetaEntry(
@ -45,14 +51,14 @@ object MetaDetailsRepository {
cachedEntry.metaScreenMeta cachedEntry.metaScreenMeta
?.takeIf { cachedEntry.metaScreenSettingsFingerprint == metaScreenSettingsFingerprint } ?.takeIf { cachedEntry.metaScreenSettingsFingerprint == metaScreenSettingsFingerprint }
?.let { cachedMeta -> ?.let { cachedMeta ->
_uiState.value = MetaDetailsUiState(meta = cachedMeta) _uiState.value = MetaDetailsUiState(meta = cachedMeta.withUnreleasedFilter())
activeRequestKey = requestKey activeRequestKey = requestKey
return return
} }
val cachedBaseMeta = cachedEntry.baseMeta val cachedBaseMeta = cachedEntry.baseMeta
if (!shouldFetchMdbListOnMetaScreen(cachedBaseMeta, id, mdbListSettings)) { if (!shouldFetchMdbListOnMetaScreen(cachedBaseMeta, id, mdbListSettings)) {
_uiState.value = MetaDetailsUiState(meta = cachedBaseMeta) _uiState.value = MetaDetailsUiState(meta = cachedBaseMeta.withUnreleasedFilter())
activeRequestKey = requestKey activeRequestKey = requestKey
return return
} }
@ -78,7 +84,7 @@ object MetaDetailsRepository {
settingsFingerprint = metaScreenSettingsFingerprint, settingsFingerprint = metaScreenSettingsFingerprint,
) )
} }
_uiState.value = MetaDetailsUiState(meta = enrichedMeta) _uiState.value = MetaDetailsUiState(meta = enrichedMeta.withUnreleasedFilter())
activeRequestKey = requestKey activeRequestKey = requestKey
} }
return return
@ -99,20 +105,25 @@ object MetaDetailsRepository {
_uiState.value = MetaDetailsUiState(isLoading = true) _uiState.value = MetaDetailsUiState(isLoading = true)
scope.launch { scope.launch {
val manifests = AddonRepository.uiState.value.addons val metaLookupId = resolveMetaLookupId(itemId = id, itemType = type)
.mapNotNull { it.manifest } val manifests = findMetaManifests(type = type, id = metaLookupId)
.filter { manifest ->
manifest.resources.any { resource ->
resource.name == "meta" &&
resource.types.contains(type) &&
(resource.idPrefixes.isEmpty() || resource.idPrefixes.any { id.startsWith(it) })
}
}
if (manifests.isEmpty()) { 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" } log.w { "No addon provides meta for type=$type id=$id" }
_uiState.value = MetaDetailsUiState( _uiState.value = MetaDetailsUiState(
errorMessage = "No addon provides meta for this content.", errorMessage = getString(Res.string.details_no_addon_meta),
) )
activeRequestKey = null activeRequestKey = null
return@launch return@launch
@ -120,44 +131,34 @@ object MetaDetailsRepository {
for (manifest in manifests) { for (manifest in manifests) {
val result = withContext(Dispatchers.Default) { val result = withContext(Dispatchers.Default) {
tryFetchMeta(manifest, type, id, includeMdbList = false) tryFetchMeta(manifest, type, metaLookupId, includeMdbList = false)
} }
if (result != null) { if (result != null) {
var cachedEntry = CachedMetaEntry(baseMeta = result) publishLoadedMeta(
cachedMetaByRequestKey[requestKey] = cachedEntry requestKey = requestKey,
if (!shouldFetchMdbListOnMetaScreen(result, id, mdbListSettings)) {
_uiState.value = MetaDetailsUiState(meta = result)
activeRequestKey = requestKey
return@launch
}
_uiState.value = MetaDetailsUiState(
isLoading = true,
meta = result, meta = result,
) fallbackItemId = metaLookupId,
val enrichedMeta = withContext(Dispatchers.Default) { mdbListSettings = mdbListSettings,
enrichForMetaScreen(
requestKey = requestKey,
meta = result,
fallbackItemId = id,
settings = mdbListSettings,
settingsFingerprint = metaScreenSettingsFingerprint,
)
}
cachedEntry = cachedEntry.copy(
metaScreenMeta = enrichedMeta,
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint, metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
) )
cachedMetaByRequestKey[requestKey] = cachedEntry
_uiState.value = MetaDetailsUiState(meta = enrichedMeta)
activeRequestKey = requestKey
return@launch 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( _uiState.value = MetaDetailsUiState(
errorMessage = "Could not load details from any addon.", errorMessage = getString(Res.string.details_load_failed_all_addons),
) )
activeRequestKey = null activeRequestKey = null
} }
@ -185,19 +186,12 @@ object MetaDetailsRepository {
val requestKey = "$type:$id" val requestKey = "$type:$id"
cachedMetaByRequestKey[requestKey]?.let { return it.baseMeta } cachedMetaByRequestKey[requestKey]?.let { return it.baseMeta }
val manifests = AddonRepository.uiState.value.addons val metaLookupId = resolveMetaLookupId(itemId = id, itemType = type)
.mapNotNull { it.manifest } val manifests = findMetaManifests(type = type, id = metaLookupId)
.filter { manifest ->
manifest.resources.any { resource ->
resource.name == "meta" &&
resource.types.contains(type) &&
(resource.idPrefixes.isEmpty() || resource.idPrefixes.any { id.startsWith(it) })
}
}
for (manifest in manifests) { for (manifest in manifests) {
val result = withTimeoutOrNull(FETCH_TIMEOUT_MS) { val result = withTimeoutOrNull(FETCH_TIMEOUT_MS) {
tryFetchMeta(manifest, type, id, includeMdbList = false) tryFetchMeta(manifest, type, metaLookupId, includeMdbList = false)
} }
if (result != null) { if (result != null) {
cachedMetaByRequestKey[requestKey] = CachedMetaEntry(baseMeta = result) 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 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( private suspend fun enrichForMetaScreen(
requestKey: String, requestKey: String,
meta: MetaDetails, meta: MetaDetails,
@ -309,6 +377,15 @@ object MetaDetailsRepository {
return "${settings.enabled}:${settings.apiKey.trim()}:$providers" 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> { fun findEmbeddedStreams(videoId: String): List<com.nuvio.app.features.streams.StreamItem> {
val meta = _uiState.value.meta ?: return emptyList() 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.library.toLibraryItem
import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.streams.StreamAutoPlayPolicy 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.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktCommentReview import com.nuvio.app.features.trakt.TraktCommentReview
import com.nuvio.app.features.trakt.TraktCommentsRepository 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.WatchingActions
import com.nuvio.app.features.watching.application.WatchingState import com.nuvio.app.features.watching.application.WatchingState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class)
@ -164,6 +168,7 @@ fun MetaDetailsScreen(
var pickerMembership by remember(type, id) { mutableStateOf<Map<String, Boolean>>(emptyMap()) } var pickerMembership by remember(type, id) { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
var pickerPending by remember(type, id) { mutableStateOf(false) } var pickerPending by remember(type, id) { mutableStateOf(false) }
var pickerError by remember(type, id) { mutableStateOf<String?>(null) } 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 && val shouldShowComments = commentsEnabled &&
traktAuthUiState.mode == TraktConnectionMode.CONNECTED && traktAuthUiState.mode == TraktConnectionMode.CONNECTED &&
@ -186,11 +191,35 @@ fun MetaDetailsScreen(
commentsCurrentPage = result.currentPage commentsCurrentPage = result.currentPage
commentsPageCount = result.pageCount commentsPageCount = result.pageCount
} catch (e: Exception) { } catch (e: Exception) {
commentsError = e.message ?: "Failed to load comments" commentsError = e.message ?: getString(Res.string.details_comments_load_failed)
} }
isCommentsLoading = false 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) { LaunchedEffect(type, id, displayedMeta, uiState.isLoading, autoLoadAttempted) {
if (!autoLoadAttempted && displayedMeta == null && !uiState.isLoading) { if (!autoLoadAttempted && displayedMeta == null && !uiState.isLoading) {
autoLoadAttempted = true autoLoadAttempted = true
@ -242,14 +271,14 @@ fun MetaDetailsScreen(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text( Text(
text = "Failed to load", text = stringResource(Res.string.details_failed_to_load),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
) )
Text( Text(
text = when (networkStatusUiState.condition) { text = when (networkStatusUiState.condition) {
NetworkCondition.NoInternet -> "Check your Wi-Fi or mobile data connection and try again." NetworkCondition.NoInternet -> stringResource(Res.string.details_check_connection)
NetworkCondition.ServersUnreachable -> "Your device is online, but Nuvio could not reach required servers." NetworkCondition.ServersUnreachable -> stringResource(Res.string.details_servers_unreachable)
else -> uiState.errorMessage.orEmpty() else -> uiState.errorMessage.orEmpty()
}, },
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@ -262,7 +291,7 @@ fun MetaDetailsScreen(
MetaDetailsRepository.load(type, id) MetaDetailsRepository.load(type, id)
}, },
) { ) {
Text("Retry") Text(stringResource(Res.string.action_retry))
} }
} }
} }
@ -273,39 +302,39 @@ fun MetaDetailsScreen(
val isSaved = remember( val isSaved = remember(
libraryUiState.items, libraryUiState.items,
libraryUiState.sections, libraryUiState.sections,
traktAuthUiState.mode, libraryUiState.sourceMode,
meta.id, meta.id,
meta.type, meta.type,
) { ) {
LibraryRepository.isSaved(meta.id, meta.type) LibraryRepository.isSaved(meta.id, meta.type)
} }
val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED val openLibraryListPicker = remember(meta) {
val toggleSaved = remember(meta, isTraktConnected) {
{ {
val libraryItem = meta.toLibraryItem(savedAtEpochMs = 0L) val libraryItem = meta.toLibraryItem(savedAtEpochMs = 0L)
if (!isTraktConnected) { pickerTabs = LibraryRepository.libraryListTabs()
LibraryRepository.toggleSaved(libraryItem) pickerMembership = pickerTabs.associate { it.key to false }
} else { pickerPending = true
pickerTabs = LibraryRepository.traktListTabs() pickerError = null
pickerMembership = pickerTabs.associate { it.key to false } showLibraryListPicker = true
pickerPending = true detailsScope.launch {
pickerError = null runCatching {
showLibraryListPicker = true val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
detailsScope.launch { val tabs = LibraryRepository.libraryListTabs()
runCatching { pickerTabs = tabs
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) pickerMembership = tabs.associate { tab ->
val tabs = LibraryRepository.traktListTabs() tab.key to (snapshot[tab.key] == true)
pickerTabs = tabs
pickerMembership = tabs.associate { tab ->
tab.key to (snapshot[tab.key] == true)
}
}.onFailure { error ->
pickerError = error.message ?: "Failed to load Trakt lists"
} }
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] val movieProgress = watchProgressUiState.byVideoId[meta.id]
@ -394,7 +423,7 @@ fun MetaDetailsScreen(
} }
trailerPlaybackSource = resolvedSource trailerPlaybackSource = resolvedSource
trailerErrorMessage = if (resolvedSource == null) { trailerErrorMessage = if (resolvedSource == null) {
"No playable trailer stream found." getString(Res.string.trailer_no_playable_stream)
} else { } else {
null 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 { when {
(meta.type == "series" || hasEpisodes) && seriesAction != null -> (meta.type == "series" || hasEpisodes) && seriesAction != null ->
seriesAction.label seriesAction.label
meta.type != "series" && !hasEpisodes && movieProgress != null -> meta.type != "series" && !hasEpisodes && movieProgress != null ->
"Resume" resumeText
else -> "Play" else -> playText
} }
} }
val onPrimaryPlayClick: () -> Unit = { val onPrimaryPlayClick: () -> Unit = {
@ -636,8 +667,10 @@ fun MetaDetailsScreen(
onPrimaryPlayClick = onPrimaryPlayClick, onPrimaryPlayClick = onPrimaryPlayClick,
onPrimaryPlayLongClick = onPrimaryPlayLongClick, onPrimaryPlayLongClick = onPrimaryPlayLongClick,
onSaveClick = toggleSaved, onSaveClick = toggleSaved,
onSaveLongClick = openLibraryListPicker,
showManualPlayOption = showManualPlayOption, showManualPlayOption = showManualPlayOption,
preferredEpisodeSeasonNumber = seriesAction?.seasonNumber, preferredEpisodeSeasonNumber = seriesAction?.seasonNumber,
preferredEpisodeNumber = seriesAction?.episodeNumber,
hasProductionSection = hasProductionSection, hasProductionSection = hasProductionSection,
hasTrailersSection = hasTrailersSection, hasTrailersSection = hasTrailersSection,
hasEpisodes = hasEpisodes, hasEpisodes = hasEpisodes,
@ -651,6 +684,7 @@ fun MetaDetailsScreen(
commentsCurrentPage = commentsCurrentPage, commentsCurrentPage = commentsCurrentPage,
commentsPageCount = commentsPageCount, commentsPageCount = commentsPageCount,
commentsError = commentsError, commentsError = commentsError,
episodeImdbRatings = episodeImdbRatings,
onRetryComments = { onRetryComments = {
detailsScope.launch { detailsScope.launch {
isCommentsLoading = true isCommentsLoading = true
@ -661,7 +695,7 @@ fun MetaDetailsScreen(
commentsCurrentPage = result.currentPage commentsCurrentPage = result.currentPage
commentsPageCount = result.pageCount commentsPageCount = result.pageCount
} catch (e: Exception) { } catch (e: Exception) {
commentsError = e.message ?: "Failed to load comments" commentsError = e.message ?: getString(Res.string.details_comments_load_failed)
} }
isCommentsLoading = false isCommentsLoading = false
} }
@ -685,6 +719,7 @@ fun MetaDetailsScreen(
onTrailerClick = resolveTrailer, onTrailerClick = resolveTrailer,
progressByVideoId = watchProgressUiState.byVideoId, progressByVideoId = watchProgressUiState.byVideoId,
watchedKeys = watchedUiState.watchedKeys, watchedKeys = watchedUiState.watchedKeys,
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
onEpisodeClick = onEpisodePlayClick, onEpisodeClick = onEpisodePlayClick,
onEpisodeLongPress = { video -> selectedEpisodeForActions = video }, onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
onOpenMeta = onOpenMeta, onOpenMeta = onOpenMeta,
@ -781,7 +816,9 @@ fun MetaDetailsScreen(
} }
EpisodeWatchedActionSheet( EpisodeWatchedActionSheet(
episode = selectedEpisode, 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, isEpisodeWatched = isSelectedEpisodeWatched,
canMarkPreviousEpisodes = previousEpisodes.isNotEmpty(), canMarkPreviousEpisodes = previousEpisodes.isNotEmpty(),
arePreviousEpisodesWatched = arePreviousEpisodesWatched, arePreviousEpisodesWatched = arePreviousEpisodesWatched,
@ -866,7 +903,7 @@ fun MetaDetailsScreen(
}.onSuccess { }.onSuccess {
showLibraryListPicker = false showLibraryListPicker = false
}.onFailure { error -> }.onFailure { error ->
pickerError = error.message ?: "Failed to update Trakt lists" pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed)
} }
pickerPending = false pickerPending = false
} }
@ -929,6 +966,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 @Composable
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class)
private fun ConfiguredMetaSections( private fun ConfiguredMetaSections(
@ -940,8 +1001,10 @@ private fun ConfiguredMetaSections(
onPrimaryPlayClick: () -> Unit, onPrimaryPlayClick: () -> Unit,
onPrimaryPlayLongClick: (() -> Unit)?, onPrimaryPlayLongClick: (() -> Unit)?,
onSaveClick: () -> Unit, onSaveClick: () -> Unit,
onSaveLongClick: (() -> Unit)?,
showManualPlayOption: Boolean, showManualPlayOption: Boolean,
preferredEpisodeSeasonNumber: Int?, preferredEpisodeSeasonNumber: Int?,
preferredEpisodeNumber: Int?,
hasProductionSection: Boolean, hasProductionSection: Boolean,
hasTrailersSection: Boolean, hasTrailersSection: Boolean,
hasEpisodes: Boolean, hasEpisodes: Boolean,
@ -955,12 +1018,14 @@ private fun ConfiguredMetaSections(
commentsCurrentPage: Int, commentsCurrentPage: Int,
commentsPageCount: Int, commentsPageCount: Int,
commentsError: String?, commentsError: String?,
episodeImdbRatings: Map<Pair<Int, Int>, Double>,
onRetryComments: () -> Unit, onRetryComments: () -> Unit,
onLoadMoreComments: () -> Unit, onLoadMoreComments: () -> Unit,
onCommentClick: (TraktCommentReview) -> Unit, onCommentClick: (TraktCommentReview) -> Unit,
onTrailerClick: (MetaTrailer) -> Unit, onTrailerClick: (MetaTrailer) -> Unit,
progressByVideoId: Map<String, WatchProgressEntry>, progressByVideoId: Map<String, WatchProgressEntry>,
watchedKeys: Set<String>, watchedKeys: Set<String>,
blurUnwatchedEpisodes: Boolean,
onEpisodeClick: (MetaVideo) -> Unit, onEpisodeClick: (MetaVideo) -> Unit,
onEpisodeLongPress: (MetaVideo) -> Unit, onEpisodeLongPress: (MetaVideo) -> Unit,
onOpenMeta: ((MetaPreview) -> Unit)?, onOpenMeta: ((MetaPreview) -> Unit)?,
@ -993,12 +1058,17 @@ private fun ConfiguredMetaSections(
MetaScreenSectionKey.ACTIONS -> { MetaScreenSectionKey.ACTIONS -> {
DetailActionButtons( DetailActionButtons(
playLabel = playButtonLabel, playLabel = playButtonLabel,
saveLabel = if (isSaved) "Saved" else "Save", saveLabel = if (isSaved) {
stringResource(Res.string.action_saved)
} else {
stringResource(Res.string.action_save)
},
isSaved = isSaved, isSaved = isSaved,
isTablet = isTablet, isTablet = isTablet,
onPlayClick = onPrimaryPlayClick, onPlayClick = onPrimaryPlayClick,
onPlayLongClick = if (showManualPlayOption) onPrimaryPlayLongClick else null, onPlayLongClick = if (showManualPlayOption) onPrimaryPlayLongClick else null,
onSaveClick = onSaveClick, onSaveClick = onSaveClick,
onSaveLongClick = onSaveLongClick,
) )
} }
MetaScreenSectionKey.OVERVIEW -> { MetaScreenSectionKey.OVERVIEW -> {
@ -1044,9 +1114,12 @@ private fun ConfiguredMetaSections(
meta = meta, meta = meta,
showHeader = showHeader, showHeader = showHeader,
preferredSeasonNumber = preferredEpisodeSeasonNumber, preferredSeasonNumber = preferredEpisodeSeasonNumber,
preferredEpisodeNumber = preferredEpisodeNumber,
episodeCardStyle = settings.episodeCardStyle, episodeCardStyle = settings.episodeCardStyle,
progressByVideoId = progressByVideoId, progressByVideoId = progressByVideoId,
watchedKeys = watchedKeys, watchedKeys = watchedKeys,
episodeRatings = episodeImdbRatings,
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
onEpisodeClick = onEpisodeClick, onEpisodeClick = onEpisodeClick,
onEpisodeLongPress = onEpisodeLongPress, onEpisodeLongPress = onEpisodeLongPress,
) )
@ -1071,7 +1144,7 @@ private fun ConfiguredMetaSections(
MetaScreenSectionKey.MORE_LIKE_THIS -> { MetaScreenSectionKey.MORE_LIKE_THIS -> {
if (hasMoreLikeThisSection) { if (hasMoreLikeThisSection) {
DetailPosterRailSection( DetailPosterRailSection(
title = "More Like This", title = stringResource(Res.string.details_more_like_this),
items = meta.moreLikeThis, items = meta.moreLikeThis,
watchedKeys = watchedKeys, watchedKeys = watchedKeys,
showHeader = showHeader, showHeader = showHeader,

View file

@ -3,11 +3,15 @@ package com.nuvio.app.features.details
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json 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 { enum class MetaScreenSectionKey {
ACTIONS, ACTIONS,
@ -41,6 +45,7 @@ data class MetaScreenSettingsUiState(
val cinematicBackground: Boolean = false, val cinematicBackground: Boolean = false,
val tabLayout: Boolean = false, val tabLayout: Boolean = false,
val episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal, val episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
val blurUnwatchedEpisodes: Boolean = false,
) )
enum class MetaEpisodeCardStyle { enum class MetaEpisodeCardStyle {
@ -77,12 +82,14 @@ private data class StoredMetaScreenSettingsPayload(
@SerialName("tvStyleLayout") @SerialName("tvStyleLayout")
val tabLayout: Boolean = false, val tabLayout: Boolean = false,
val episodeCardStyle: String = "horizontal", val episodeCardStyle: String = "horizontal",
@SerialName("blur_unwatched_episodes")
val blurUnwatchedEpisodes: Boolean = false,
) )
private data class MetaScreenSectionDefinition( private data class MetaScreenSectionDefinition(
val key: MetaScreenSectionKey, val key: MetaScreenSectionKey,
val title: String, val titleRes: StringResource,
val description: String, val descriptionRes: StringResource,
) )
object MetaScreenSettingsRepository { object MetaScreenSettingsRepository {
@ -94,53 +101,53 @@ object MetaScreenSettingsRepository {
private val definitions = listOf( private val definitions = listOf(
MetaScreenSectionDefinition( MetaScreenSectionDefinition(
key = MetaScreenSectionKey.ACTIONS, key = MetaScreenSectionKey.ACTIONS,
title = "Actions", titleRes = Res.string.meta_section_actions_title,
description = "Play and save controls.", descriptionRes = Res.string.meta_section_actions_description,
), ),
MetaScreenSectionDefinition( MetaScreenSectionDefinition(
key = MetaScreenSectionKey.OVERVIEW, key = MetaScreenSectionKey.OVERVIEW,
title = "Overview", titleRes = Res.string.meta_section_overview_title,
description = "Synopsis, ratings, genres, and core credits.", descriptionRes = Res.string.meta_section_overview_description,
), ),
MetaScreenSectionDefinition( MetaScreenSectionDefinition(
key = MetaScreenSectionKey.PRODUCTION, key = MetaScreenSectionKey.PRODUCTION,
title = "Production", titleRes = Res.string.meta_section_production_title,
description = "Studios and networks.", descriptionRes = Res.string.meta_section_production_description,
), ),
MetaScreenSectionDefinition( MetaScreenSectionDefinition(
key = MetaScreenSectionKey.CAST, key = MetaScreenSectionKey.CAST,
title = "Cast", titleRes = Res.string.settings_meta_cast,
description = "Principal cast list.", descriptionRes = Res.string.meta_section_cast_description,
), ),
MetaScreenSectionDefinition( MetaScreenSectionDefinition(
key = MetaScreenSectionKey.COMMENTS, key = MetaScreenSectionKey.COMMENTS,
title = "Comments", titleRes = Res.string.settings_meta_comments,
description = "Trakt comments section.", descriptionRes = Res.string.meta_section_comments_description,
), ),
MetaScreenSectionDefinition( MetaScreenSectionDefinition(
key = MetaScreenSectionKey.TRAILERS, key = MetaScreenSectionKey.TRAILERS,
title = "Trailers", titleRes = Res.string.settings_meta_trailers,
description = "Trailer rail and playback shortcuts.", descriptionRes = Res.string.meta_section_trailers_description,
), ),
MetaScreenSectionDefinition( MetaScreenSectionDefinition(
key = MetaScreenSectionKey.EPISODES, key = MetaScreenSectionKey.EPISODES,
title = "Episodes", titleRes = Res.string.settings_meta_episodes,
description = "Seasons and episode list for series.", descriptionRes = Res.string.meta_section_episodes_description,
), ),
MetaScreenSectionDefinition( MetaScreenSectionDefinition(
key = MetaScreenSectionKey.DETAILS, key = MetaScreenSectionKey.DETAILS,
title = "Details", titleRes = Res.string.meta_section_details_title,
description = "Runtime, status, release, language, and related info.", descriptionRes = Res.string.meta_section_details_description,
), ),
MetaScreenSectionDefinition( MetaScreenSectionDefinition(
key = MetaScreenSectionKey.COLLECTION, key = MetaScreenSectionKey.COLLECTION,
title = "Collection", titleRes = Res.string.meta_section_collection_title,
description = "Related collection or franchise rail.", descriptionRes = Res.string.meta_section_collection_description,
), ),
MetaScreenSectionDefinition( MetaScreenSectionDefinition(
key = MetaScreenSectionKey.MORE_LIKE_THIS, key = MetaScreenSectionKey.MORE_LIKE_THIS,
title = "More Like This", titleRes = Res.string.meta_section_more_like_this_title,
description = "Recommendation rail.", descriptionRes = Res.string.meta_section_more_like_this_description,
), ),
) )
@ -152,6 +159,8 @@ object MetaScreenSettingsRepository {
private var cinematicBackground: Boolean = false private var cinematicBackground: Boolean = false
private var tabLayout: Boolean = false private var tabLayout: Boolean = false
private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal
private var blurUnwatchedEpisodes: Boolean = false
private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) }
fun ensureLoaded() { fun ensureLoaded() {
if (hasLoaded) return if (hasLoaded) return
@ -167,6 +176,7 @@ object MetaScreenSettingsRepository {
tabLayout = parsed.tabLayout tabLayout = parsed.tabLayout
episodeCardStyle = MetaEpisodeCardStyle.parse(parsed.episodeCardStyle) episodeCardStyle = MetaEpisodeCardStyle.parse(parsed.episodeCardStyle)
?: MetaEpisodeCardStyle.Horizontal ?: MetaEpisodeCardStyle.Horizontal
blurUnwatchedEpisodes = parsed.blurUnwatchedEpisodes
preferences = parsed.items.mapNotNull { item -> preferences = parsed.items.mapNotNull { item ->
val key = runCatching { MetaScreenSectionKey.valueOf(item.key) }.getOrNull() ?: return@mapNotNull null val key = runCatching { MetaScreenSectionKey.valueOf(item.key) }.getOrNull() ?: return@mapNotNull null
key to item key to item
@ -185,6 +195,7 @@ object MetaScreenSettingsRepository {
cinematicBackground = false cinematicBackground = false
tabLayout = false tabLayout = false
episodeCardStyle = MetaEpisodeCardStyle.Horizontal episodeCardStyle = MetaEpisodeCardStyle.Horizontal
blurUnwatchedEpisodes = false
_uiState.value = MetaScreenSettingsUiState() _uiState.value = MetaScreenSettingsUiState()
ensureLoaded() ensureLoaded()
} }
@ -210,6 +221,13 @@ object MetaScreenSettingsRepository {
persist() persist()
} }
fun setBlurUnwatchedEpisodes(enabled: Boolean) {
ensureLoaded()
blurUnwatchedEpisodes = enabled
publish()
persist()
}
fun setTabGroup(key: MetaScreenSectionKey, groupId: Int?) { fun setTabGroup(key: MetaScreenSectionKey, groupId: Int?) {
ensureLoaded() ensureLoaded()
if (!key.canBeTabbed) return if (!key.canBeTabbed) return
@ -228,6 +246,8 @@ object MetaScreenSettingsRepository {
preferences.clear() preferences.clear()
cinematicBackground = false cinematicBackground = false
tabLayout = false tabLayout = false
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
blurUnwatchedEpisodes = false
_uiState.value = MetaScreenSettingsUiState() _uiState.value = MetaScreenSettingsUiState()
} }
@ -236,11 +256,13 @@ object MetaScreenSettingsRepository {
cinematicBackground: Boolean, cinematicBackground: Boolean,
tabLayout: Boolean, tabLayout: Boolean,
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal, episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
blurUnwatchedEpisodes: Boolean = false,
) { ) {
ensureLoaded() ensureLoaded()
this.cinematicBackground = cinematicBackground this.cinematicBackground = cinematicBackground
this.tabLayout = tabLayout this.tabLayout = tabLayout
this.episodeCardStyle = episodeCardStyle this.episodeCardStyle = episodeCardStyle
this.blurUnwatchedEpisodes = blurUnwatchedEpisodes
preferences = items.associate { item -> preferences = items.associate { item ->
item.key to StoredMetaScreenSectionPreference( item.key to StoredMetaScreenSectionPreference(
key = item.key.name, key = item.key.name,
@ -266,6 +288,7 @@ object MetaScreenSettingsRepository {
cinematicBackground = false cinematicBackground = false
tabLayout = false tabLayout = false
episodeCardStyle = MetaEpisodeCardStyle.Horizontal episodeCardStyle = MetaEpisodeCardStyle.Horizontal
blurUnwatchedEpisodes = false
normalizePreferences() normalizePreferences()
publish() publish()
persist() persist()
@ -322,8 +345,8 @@ object MetaScreenSettingsRepository {
val preference = preferences[definition.key] val preference = preferences[definition.key]
MetaScreenSectionItem( MetaScreenSectionItem(
key = definition.key, key = definition.key,
title = definition.title, title = localizedString(definition.titleRes),
description = definition.description, description = localizedString(definition.descriptionRes),
enabled = preference?.enabled ?: true, enabled = preference?.enabled ?: true,
order = preference?.order ?: 0, order = preference?.order ?: 0,
tabGroup = preference?.tabGroup, tabGroup = preference?.tabGroup,
@ -332,6 +355,7 @@ object MetaScreenSettingsRepository {
cinematicBackground = cinematicBackground, cinematicBackground = cinematicBackground,
tabLayout = tabLayout, tabLayout = tabLayout,
episodeCardStyle = episodeCardStyle, episodeCardStyle = episodeCardStyle,
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
) )
} }
@ -343,8 +367,9 @@ object MetaScreenSettingsRepository {
cinematicBackground = cinematicBackground, cinematicBackground = cinematicBackground,
tabLayout = tabLayout, tabLayout = tabLayout,
episodeCardStyle = MetaEpisodeCardStyle.persist(episodeCardStyle), 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.AsyncImage
import coil3.compose.LocalPlatformContext import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest import coil3.request.ImageRequest
import com.nuvio.app.core.i18n.localizedShortMonthName
import com.nuvio.app.core.ui.landscapePosterHeightForWidth import com.nuvio.app.core.ui.landscapePosterHeightForWidth
import com.nuvio.app.core.ui.landscapePosterWidth import com.nuvio.app.core.ui.landscapePosterWidth
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState 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.home.MetaPreview
import com.nuvio.app.features.tmdb.TmdbMetadataService import com.nuvio.app.features.tmdb.TmdbMetadataService
import com.nuvio.app.features.watchprogress.CurrentDateProvider 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 { private sealed interface PersonDetailUiState {
data object Loading : PersonDetailUiState data object Loading : PersonDetailUiState
@ -96,7 +100,7 @@ fun PersonDetailScreen(
uiState = if (detail != null) { uiState = if (detail != null) {
PersonDetailUiState.Success(detail) PersonDetailUiState.Success(detail)
} else { } 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( Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack, imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "Back", contentDescription = stringResource(Res.string.action_back),
tint = MaterialTheme.colorScheme.onSurface, tint = MaterialTheme.colorScheme.onSurface,
) )
} }
@ -268,7 +272,7 @@ private fun PersonDetailContent(
if (popularCredits.isNotEmpty()) { if (popularCredits.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
DetailPosterRailSection( DetailPosterRailSection(
title = "Popular", title = stringResource(Res.string.person_popular),
items = popularCredits, items = popularCredits,
watchedKeys = emptySet(), watchedKeys = emptySet(),
headerHorizontalPadding = 20.dp, headerHorizontalPadding = 20.dp,
@ -279,7 +283,7 @@ private fun PersonDetailContent(
if (latestCredits.isNotEmpty()) { if (latestCredits.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
DetailPosterRailSection( DetailPosterRailSection(
title = "Latest", title = stringResource(Res.string.person_latest),
items = latestCredits, items = latestCredits,
watchedKeys = emptySet(), watchedKeys = emptySet(),
headerHorizontalPadding = 20.dp, headerHorizontalPadding = 20.dp,
@ -290,7 +294,7 @@ private fun PersonDetailContent(
if (upcomingCredits.isNotEmpty()) { if (upcomingCredits.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
DetailPosterRailSection( DetailPosterRailSection(
title = "Upcoming", title = stringResource(Res.string.person_upcoming),
items = upcomingCredits, items = upcomingCredits,
watchedKeys = emptySet(), watchedKeys = emptySet(),
headerHorizontalPadding = 20.dp, headerHorizontalPadding = 20.dp,
@ -405,18 +409,23 @@ private fun HeroSection(
val infoItems = buildList { val infoItems = buildList {
person.birthday?.let { bday -> person.birthday?.let { bday ->
val age = calculateAge(bday, person.deathday) 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 bdayDisplay = formatDateForDisplay(bday) ?: bday
val deathDisplay = person.deathday?.let { formatDateForDisplay(it) ?: it } val deathDisplay = person.deathday?.let { formatDateForDisplay(it) ?: it }
val line = if (deathDisplay != null) { 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 { } else {
"Born $bdayDisplay$ageStr" stringResource(Res.string.person_born, bdayDisplay, ageStr)
} }
add(line) add(line)
} }
person.placeOfBirth?.let { add(it) } 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()) { if (infoItems.isNotEmpty()) {
infoItems.forEach { info -> infoItems.forEach { info ->
@ -682,7 +691,7 @@ private fun PersonDetailError(
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text( Text(
text = "Something went wrong", text = stringResource(Res.string.person_something_wrong),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
@ -700,7 +709,7 @@ private fun PersonDetailError(
contentColor = MaterialTheme.colorScheme.onPrimary, 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? { private fun formatDateForDisplay(date: String): String? {
val parts = date.split("-").mapNotNull { it.toIntOrNull() } val parts = date.split("-").mapNotNull { it.toIntOrNull() }
if (parts.size < 3) return null 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 month = parts[1]
val day = parts[2] val day = parts[2]
val year = parts[0] val year = parts[0]
return if (month in 1..12) { return if (month in 1..12) {
"${months[month - 1]} $day, $year" "${localizedShortMonthName(month)} $day, $year"
} else { } else {
null 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 package com.nuvio.app.features.details
import com.nuvio.app.features.watched.WatchedItem 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.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode
import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.WatchingContentRef
@ -85,19 +86,38 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
seasonNumber = seasonNumber, seasonNumber = seasonNumber,
episodeNumber = episodeNumber, episodeNumber = episodeNumber,
) )
val candidates = sortedEpisodes var watchedIndex = sortedEpisodes.indexOfFirst { episode ->
.dropWhile { episode -> buildPlaybackVideoId(
buildPlaybackVideoId( content = WatchingContentRef(type = type, id = id),
content = WatchingContentRef(type = type, id = id), seasonNumber = episode.season,
seasonNumber = episode.season, episodeNumber = episode.episode,
episodeNumber = episode.episode, fallbackVideoId = episode.id,
fallbackVideoId = episode.id, ) == watchedVideoId
) != 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 mainEpisodes = sortedEpisodes.filter { episode -> normalizeSeasonNumber(episode.season) > 0 }
val addonSeasons = mainEpisodes.mapTo(mutableSetOf()) { episode ->
normalizeSeasonNumber(episode.season)
} }
.drop(1) if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) {
val globalIndex = episodeNumber - 1
if (globalIndex in mainEpisodes.indices) {
watchedIndex = sortedEpisodes.indexOf(mainEpisodes[globalIndex])
}
}
}
if (watchedIndex < 0) return null
val watchedEpisodeSeason = sortedEpisodes[watchedIndex].season
val candidates = sortedEpisodes
.drop(watchedIndex + 1)
.filter { episode -> .filter { episode ->
shouldSurfaceNextEpisode( shouldSurfaceNextEpisode(
watchedSeasonNumber = seasonNumber, watchedSeasonNumber = watchedEpisodeSeason,
candidateSeasonNumber = episode.season, candidateSeasonNumber = episode.season,
todayIsoDate = todayIsoDate, todayIsoDate = todayIsoDate,
releasedDate = episode.released, releasedDate = episode.released,
@ -190,7 +210,7 @@ private fun WatchedItem.toDomainWatchedRecord(): WatchingWatchedRecord =
content = WatchingContentRef(type = type, id = id), content = WatchingContentRef(type = type, id = id),
seasonNumber = season, seasonNumber = season,
episodeNumber = episode, episodeNumber = episode,
markedAtEpochMs = markedAtEpochMs, markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs),
) )
private fun WatchingSeriesPrimaryAction.toLegacySeriesPrimaryAction(): SeriesPrimaryAction = 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage 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.landscapePosterHeightForWidth
import com.nuvio.app.core.ui.landscapePosterWidth import com.nuvio.app.core.ui.landscapePosterWidth
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
@ -73,6 +75,7 @@ fun TmdbEntityBrowseScreen(
var uiState by remember(entityKind, entityId) { var uiState by remember(entityKind, entityId) {
mutableStateOf<EntityBrowseUiState>(EntityBrowseUiState.Loading) mutableStateOf<EntityBrowseUiState>(EntityBrowseUiState.Loading)
} }
val loadFailedMessage = stringResource(Res.string.details_browse_load_failed, entityName)
LaunchedEffect(entityKind, entityId) { LaunchedEffect(entityKind, entityId) {
uiState = EntityBrowseUiState.Loading uiState = EntityBrowseUiState.Loading
@ -85,7 +88,7 @@ fun TmdbEntityBrowseScreen(
uiState = if (data != null) { uiState = if (data != null) {
EntityBrowseUiState.Success(data) EntityBrowseUiState.Success(data)
} else { } else {
EntityBrowseUiState.Error("Could not load $entityName") EntityBrowseUiState.Error(loadFailedMessage)
} }
} }
@ -117,7 +120,7 @@ fun TmdbEntityBrowseScreen(
) { ) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack, imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "Back", contentDescription = stringResource(Res.string.action_back),
tint = MaterialTheme.colorScheme.onSurface, tint = MaterialTheme.colorScheme.onSurface,
) )
} }
@ -170,7 +173,7 @@ private fun EntityBrowseContent(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = "No titles found", text = stringResource(Res.string.catalog_empty_title),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -191,18 +194,16 @@ private fun EntityBrowseContent(
) )
data.rails.forEach { rail -> data.rails.forEach { rail ->
val railTitle = remember(rail.mediaType, rail.railType) { val mediaLabel = when (rail.mediaType) {
val mediaLabel = when (rail.mediaType) { TmdbEntityMediaType.MOVIE -> stringResource(Res.string.media_movies)
TmdbEntityMediaType.MOVIE -> "Movies" TmdbEntityMediaType.TV -> stringResource(Res.string.media_series)
TmdbEntityMediaType.TV -> "Series"
}
val railLabel = when (rail.railType) {
TmdbEntityRailType.POPULAR -> "Popular"
TmdbEntityRailType.TOP_RATED -> "Top Rated"
TmdbEntityRailType.RECENT -> "Recent"
}
"$mediaLabel$railLabel"
} }
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( DetailPosterRailSection(
title = railTitle, title = railTitle,
@ -230,8 +231,8 @@ private fun EntityHeroSection(
Column(modifier = modifier.padding(horizontal = 20.dp)) { Column(modifier = modifier.padding(horizontal = 20.dp)) {
Text( Text(
text = when (header.kind) { text = when (header.kind) {
TmdbEntityKind.COMPANY -> "Production Company" TmdbEntityKind.COMPANY -> stringResource(Res.string.details_browse_kind_company)
TmdbEntityKind.NETWORK -> "Network" TmdbEntityKind.NETWORK -> stringResource(Res.string.details_browse_kind_network)
}, },
style = MaterialTheme.typography.labelLarge.copy( style = MaterialTheme.typography.labelLarge.copy(
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
@ -405,7 +406,7 @@ private fun EntityBrowseError(
contentColor = MaterialTheme.colorScheme.onPrimaryContainer, 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 androidx.compose.ui.unit.sp
import com.nuvio.app.core.ui.NuvioModalBottomSheet import com.nuvio.app.core.ui.NuvioModalBottomSheet
import com.nuvio.app.features.trakt.TraktCommentReview import com.nuvio.app.features.trakt.TraktCommentReview
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -114,7 +116,7 @@ fun CommentDetailSheet(
) { ) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowLeft, imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowLeft,
contentDescription = "Previous", contentDescription = stringResource(Res.string.action_previous),
tint = if (canGoBack) MaterialTheme.colorScheme.onSurface tint = if (canGoBack) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
@ -140,7 +142,7 @@ fun CommentDetailSheet(
) { ) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
contentDescription = "Next", contentDescription = stringResource(Res.string.action_next),
tint = if (canGoForward) MaterialTheme.colorScheme.onSurface tint = if (canGoForward) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
@ -153,13 +155,13 @@ fun CommentDetailSheet(
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (comment.review) { if (comment.review) {
CommentDetailChip(text = "Review") CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_review))
} }
if (comment.hasSpoilerContent) { if (comment.hasSpoilerContent) {
CommentDetailChip(text = "Spoiler") CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_spoiler))
} }
comment.rating?.let { rating -> 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(
text = if (comment.hasSpoilerContent) { text = if (comment.hasSpoilerContent) {
"This comment contains spoilers and has been hidden." stringResource(Res.string.detail_comments_spoiler_hidden_sheet)
} else { } else {
comment.comment comment.comment
}, },
@ -189,7 +191,7 @@ fun CommentDetailSheet(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Text( Text(
text = "${comment.likes} likes", text = stringResource(Res.string.detail_comments_likes, comment.likes),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check 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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -28,18 +25,23 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.nuvio.app.core.ui.AppIconResource import com.nuvio.app.core.ui.AppIconResource
import com.nuvio.app.core.ui.appIconPainter 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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun DetailActionButtons( fun DetailActionButtons(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
playLabel: String = "Play", playLabel: String = stringResource(Res.string.action_play),
saveLabel: String = "Save", saveLabel: String = stringResource(Res.string.action_save),
isSaved: Boolean = false, isSaved: Boolean = false,
isTablet: Boolean = false, isTablet: Boolean = false,
onPlayClick: () -> Unit = {}, onPlayClick: () -> Unit = {},
onPlayLongClick: (() -> Unit)? = null, onPlayLongClick: (() -> Unit)? = null,
onSaveClick: () -> Unit = {}, onSaveClick: () -> Unit = {},
onSaveLongClick: (() -> Unit)? = null,
) { ) {
val playPainter = appIconPainter(AppIconResource.PlayerPlay) val playPainter = appIconPainter(AppIconResource.PlayerPlay)
val libraryAddPainter = appIconPainter(AppIconResource.LibraryAddPlus) val libraryAddPainter = appIconPainter(AppIconResource.LibraryAddPlus)
@ -92,35 +94,49 @@ fun DetailActionButtons(
} }
} }
OutlinedButton( Surface(
onClick = onSaveClick,
modifier = rowButtonModifier.height(50.dp), modifier = rowButtonModifier.height(50.dp),
shape = RoundedCornerShape(40.dp), shape = RoundedCornerShape(40.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0f),
contentColor = MaterialTheme.colorScheme.onSurface,
) { ) {
if (isSaved) { Row(
Icon( modifier = Modifier
imageVector = Icons.Default.Check, .fillMaxWidth()
contentDescription = null, .combinedClickable(
modifier = Modifier.size(20.dp), onClick = onSaveClick,
tint = MaterialTheme.colorScheme.onSurface, onLongClick = onSaveLongClick,
) role = Role.Button,
} else { )
Icon( .height(50.dp),
painter = libraryAddPainter, horizontalArrangement = Arrangement.Center,
contentDescription = null, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.size(18.dp), ) {
tint = 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,
)
}
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.core.format.formatReleaseDateForDisplay
import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.formatRuntimeForDisplay import com.nuvio.app.features.details.formatRuntimeForDisplay
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun DetailAdditionalInfoSection( fun DetailAdditionalInfoSection(
@ -27,14 +29,24 @@ fun DetailAdditionalInfoSection(
showHeader: Boolean = true, showHeader: Boolean = true,
) { ) {
val isSeriesLike = meta.type == "series" || meta.videos.any { it.season != null || it.episode != null } 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 { val rows = buildList {
meta.status?.let { add("Status" to it) } meta.status?.let { add(stringResource(Res.string.details_status) to it) }
meta.releaseInfo?.let { add("Release Info" to formatReleaseDateForDisplay(it)) } meta.releaseInfo?.let {
formatRuntimeForDisplay(meta.runtime)?.let { add("Runtime" to it) } add(stringResource(Res.string.details_release_info) to formatReleaseDateForDisplay(it))
meta.ageRating?.let { add("Certification" to it) } }
meta.country?.let { add("Origin Country" to it) } formatRuntimeForDisplay(meta.runtime)?.let {
meta.language?.let { add("Original Language" to it.uppercase()) } 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 if (rows.isEmpty()) return

View file

@ -34,6 +34,8 @@ import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest import coil3.request.ImageRequest
import com.nuvio.app.features.details.MetaPerson import com.nuvio.app.features.details.MetaPerson
import com.nuvio.app.features.details.castAvatarSharedTransitionKey import com.nuvio.app.features.details.castAvatarSharedTransitionKey
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class)
@ -48,7 +50,7 @@ fun DetailCastSection(
if (cast.isEmpty()) return if (cast.isEmpty()) return
DetailSection( DetailSection(
title = "Cast", title = stringResource(Res.string.settings_meta_cast),
modifier = modifier, modifier = modifier,
showHeader = showHeader, 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
import com.nuvio.app.features.trakt.TraktCommentReview import com.nuvio.app.features.trakt.TraktCommentReview
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun DetailCommentsSection( fun DetailCommentsSection(
@ -101,14 +104,14 @@ fun DetailCommentsSection(
contentColor = MaterialTheme.colorScheme.onSurface, contentColor = MaterialTheme.colorScheme.onSurface,
), ),
) { ) {
Text("Retry") Text(stringResource(Res.string.action_retry))
} }
} }
} }
comments.isEmpty() -> { comments.isEmpty() -> {
Text( Text(
text = "No comments yet.", text = stringResource(Res.string.detail_comments_empty),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -120,7 +123,11 @@ fun DetailCommentsSection(
state = listState, state = listState,
horizontalArrangement = Arrangement.spacedBy(12.dp), 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( CommentCard(
review = review, review = review,
onClick = { onCommentClick(review) }, onClick = { onCommentClick(review) },
@ -144,7 +151,7 @@ private fun CommentsHeader() {
val titleSize = if (isTablet) 22.sp else 20.sp val titleSize = if (isTablet) 22.sp else 20.sp
Text( Text(
text = "Trakt Comments", text = stringResource(Res.string.detail_comments_title),
style = MaterialTheme.typography.titleLarge.copy( style = MaterialTheme.typography.titleLarge.copy(
fontSize = titleSize, fontSize = titleSize,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
@ -163,7 +170,7 @@ private fun CommentCard(
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val isAmoled = colorScheme.background == Color.Black && colorScheme.surface == Color(0xFF050505) val isAmoled = colorScheme.background == Color.Black && colorScheme.surface == Color(0xFF050505)
val bodyText = if (review.hasSpoilerContent) { val bodyText = if (review.hasSpoilerContent) {
"This comment contains spoilers." stringResource(Res.string.detail_comments_spoiler_card)
} else { } else {
review.comment review.comment
} }
@ -199,13 +206,13 @@ private fun CommentCard(
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (review.review) { if (review.review) {
CommentChip(text = "Review") CommentChip(text = stringResource(Res.string.detail_comments_badge_review))
} }
if (review.hasSpoilerContent) { if (review.hasSpoilerContent) {
CommentChip(text = "Spoiler") CommentChip(text = stringResource(Res.string.detail_comments_badge_spoiler))
} }
review.rating?.let { rating -> 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(
text = "${review.likes} likes", text = stringResource(Res.string.detail_comments_likes, review.likes),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
maxLines = 1, maxLines = 1,

View file

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

View file

@ -26,6 +26,8 @@ import androidx.compose.ui.graphics.graphicsLayer
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.home.components.homeHeroLayout import com.nuvio.app.features.home.components.homeHeroLayout
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun DetailHero( fun DetailHero(
@ -108,7 +110,7 @@ fun DetailHero(
if (meta.logo != null) { if (meta.logo != null) {
AsyncImage( AsyncImage(
model = meta.logo, model = meta.logo,
contentDescription = "${meta.name} logo", contentDescription = stringResource(Res.string.detail_logo_content_description, meta.name),
modifier = Modifier modifier = Modifier
.fillMaxWidth(if (isTablet) 0.56f else 0.6f) .fillMaxWidth(if (isTablet) 0.56f else 0.6f)
.widthIn(max = contentMaxWidth) .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_TMDB
import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TOMATOES import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TOMATOES
import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TRAKT 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_audience_score
import nuvio.composeapp.generated.resources.rating_imdb import nuvio.composeapp.generated.resources.rating_imdb
import nuvio.composeapp.generated.resources.rating_letterboxd 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_tmdb
import nuvio.composeapp.generated.resources.rating_trakt import nuvio.composeapp.generated.resources.rating_trakt
import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import kotlinx.coroutines.runBlocking
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -114,7 +117,7 @@ fun DetailMetaInfo(
color = ImdbYellow, color = ImdbYellow,
) { ) {
Text( Text(
text = "IMDb", text = stringResource(Res.string.source_imdb),
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelMedium.copy( style = MaterialTheme.typography.labelMedium.copy(
fontSize = 10.sp, fontSize = 10.sp,
@ -148,14 +151,14 @@ fun DetailMetaInfo(
if (meta.director.isNotEmpty()) { if (meta.director.isNotEmpty()) {
MetaLabelValueRow( MetaLabelValueRow(
label = "Director", label = stringResource(Res.string.details_director),
value = meta.director.joinToString(", "), value = meta.director.joinToString(", "),
) )
} }
if (meta.writer.isNotEmpty()) { if (meta.writer.isNotEmpty()) {
MetaLabelValueRow( MetaLabelValueRow(
label = "Writer", label = stringResource(Res.string.details_writer),
value = meta.writer.joinToString(", "), value = meta.writer.joinToString(", "),
) )
} }
@ -182,7 +185,11 @@ fun DetailMetaInfo(
if (canExpand) { if (canExpand) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.clickable { expanded = !expanded }, modifier = Modifier.clickable { expanded = !expanded },
@ -341,7 +348,7 @@ private val ratingVisuals = listOf(
), ),
RatingVisuals( RatingVisuals(
source = PROVIDER_AUDIENCE, source = PROVIDER_AUDIENCE,
displayName = "Audience Score", displayName = runBlocking { getString(Res.string.rating_audience_score) },
logo = Res.drawable.rating_audience_score, logo = Res.drawable.rating_audience_score,
logoWidth = 16.dp, logoWidth = 16.dp,
valueColor = Color(0xFFFA320A), valueColor = Color(0xFFFA320A),

View file

@ -25,6 +25,8 @@ import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.nuvio.app.features.details.MetaCompany import com.nuvio.app.features.details.MetaCompany
import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaDetails
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
@ -54,7 +56,11 @@ fun DetailProductionSection(
if (displayItems.isEmpty()) return if (displayItems.isEmpty()) return
DetailSection( 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, modifier = modifier,
showHeader = showHeader, showHeader = showHeader,
) { ) {

View file

@ -15,12 +15,14 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.layout.width
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -44,6 +47,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -57,6 +61,7 @@ import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.nuvio.app.core.format.formatReleaseDateForDisplay 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.NuvioAnimatedWatchedBadge
import com.nuvio.app.core.ui.NuvioProgressBar import com.nuvio.app.core.ui.NuvioProgressBar
import com.nuvio.app.features.details.MetaDetails 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.WatchProgressEntry
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import com.nuvio.app.features.watching.application.WatchingState 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") private val log = Logger.withTag("SeriesContent")
@ -80,9 +92,12 @@ fun DetailSeriesContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showHeader: Boolean = true, showHeader: Boolean = true,
preferredSeasonNumber: Int? = null, preferredSeasonNumber: Int? = null,
preferredEpisodeNumber: Int? = null,
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal, episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(), progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
watchedKeys: Set<String> = emptySet(), watchedKeys: Set<String> = emptySet(),
episodeRatings: Map<Pair<Int, Int>, Double> = emptyMap(),
blurUnwatchedEpisodes: Boolean = false,
onEpisodeClick: ((MetaVideo) -> Unit)? = null, onEpisodeClick: ((MetaVideo) -> Unit)? = null,
onEpisodeLongPress: ((MetaVideo) -> Unit)? = null, onEpisodeLongPress: ((MetaVideo) -> Unit)? = null,
) { ) {
@ -91,16 +106,16 @@ fun DetailSeriesContent(
if (meta.videos.isEmpty()) { if (meta.videos.isEmpty()) {
DetailSection( DetailSection(
title = "Episodes", title = stringResource(Res.string.settings_meta_episodes),
modifier = modifier, modifier = modifier,
showHeader = showHeader, showHeader = showHeader,
) { ) {
Text( Text(
text = when { text = when {
meta.status.equals("Not yet aired", ignoreCase = true) || meta.hasScheduledVideos -> 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 -> else ->
"This addon did not provide episode metadata for this series." stringResource(Res.string.details_series_no_metadata)
}, },
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
@ -131,12 +146,12 @@ fun DetailSeriesContent(
if (groupedEpisodes.isEmpty()) { if (groupedEpisodes.isEmpty()) {
if (meta.type == "series") { if (meta.type == "series") {
DetailSection( DetailSection(
title = "Episodes", title = stringResource(Res.string.settings_meta_episodes),
modifier = modifier, modifier = modifier,
showHeader = showHeader, showHeader = showHeader,
) { ) {
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -181,7 +196,7 @@ fun DetailSeriesContent(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "Seasons", text = stringResource(Res.string.details_seasons),
style = MaterialTheme.typography.titleLarge.copy( style = MaterialTheme.typography.titleLarge.copy(
fontSize = sizing.seasonHeaderSize, fontSize = sizing.seasonHeaderSize,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
@ -249,7 +264,7 @@ fun DetailSeriesContent(
label = "season_episodes", label = "season_episodes",
) { seasonForContent -> ) { seasonForContent ->
val sectionTitle = if (meta.type != "series" && seasons.size == 1 && seasonForContent <= 0) { val sectionTitle = if (meta.type != "series" && seasons.size == 1 && seasonForContent <= 0) {
"Videos" stringResource(Res.string.details_videos)
} else { } else {
seasonForContent.label() seasonForContent.label()
} }
@ -269,6 +284,9 @@ fun DetailSeriesContent(
watchedKeys = watchedKeys, watchedKeys = watchedKeys,
fallbackImage = meta.background ?: meta.poster, fallbackImage = meta.background ?: meta.poster,
progressByVideoId = progressByVideoId, progressByVideoId = progressByVideoId,
episodeRatings = episodeRatings,
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
preferredEpisodeNumber = preferredEpisodeNumber,
onEpisodeClick = onEpisodeClick, onEpisodeClick = onEpisodeClick,
onEpisodeLongPress = onEpisodeLongPress, onEpisodeLongPress = onEpisodeLongPress,
) )
@ -287,13 +305,15 @@ fun DetailSeriesContent(
video = episode, video = episode,
fallbackImage = meta.background ?: meta.poster, fallbackImage = meta.background ?: meta.poster,
progressEntry = progressByVideoId[episodeVideoId], progressEntry = progressByVideoId[episodeVideoId],
isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true || imdbRating = episode.seasonEpisodeKey()?.let { episodeRatings[it] },
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
WatchingState.isEpisodeWatched( WatchingState.isEpisodeWatched(
watchedKeys = watchedKeys, watchedKeys = watchedKeys,
metaType = meta.type, metaType = meta.type,
metaId = meta.id, metaId = meta.id,
episode = episode, episode = episode,
), ),
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
sizing = sizing, sizing = sizing,
onClick = { onEpisodeClick?.invoke(episode) }, onClick = { onEpisodeClick?.invoke(episode) },
onLongPress = { onEpisodeLongPress?.invoke(episode) }, onLongPress = { onEpisodeLongPress?.invoke(episode) },
@ -334,7 +354,11 @@ private fun SeasonViewModeToggle(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( 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( style = MaterialTheme.typography.labelLarge.copy(
fontSize = sizing.seasonToggleTextSize, fontSize = sizing.seasonToggleTextSize,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
@ -541,17 +565,42 @@ private fun EpisodeHorizontalRow(
watchedKeys: Set<String>, watchedKeys: Set<String>,
fallbackImage: String?, fallbackImage: String?,
progressByVideoId: Map<String, WatchProgressEntry>, progressByVideoId: Map<String, WatchProgressEntry>,
episodeRatings: Map<Pair<Int, Int>, Double>,
blurUnwatchedEpisodes: Boolean,
preferredEpisodeNumber: Int? = null,
onEpisodeClick: ((MetaVideo) -> Unit)?, onEpisodeClick: ((MetaVideo) -> Unit)?,
onEpisodeLongPress: ((MetaVideo) -> Unit)?, onEpisodeLongPress: ((MetaVideo) -> Unit)?,
) { ) {
val rowMetrics = rememberEpisodeHorizontalCardMetrics(maxWidthDp) 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( LazyRow(
state = listState,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = rowMetrics.rowHorizontalPadding, vertical = rowMetrics.rowVerticalPadding), contentPadding = PaddingValues(horizontal = rowMetrics.rowHorizontalPadding, vertical = rowMetrics.rowVerticalPadding),
horizontalArrangement = Arrangement.spacedBy(rowMetrics.itemSpacing), 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( val episodeVideoId = buildPlaybackVideoId(
parentMetaId = parentMetaId, parentMetaId = parentMetaId,
seasonNumber = episode.season, seasonNumber = episode.season,
@ -562,13 +611,15 @@ private fun EpisodeHorizontalRow(
video = episode, video = episode,
fallbackImage = fallbackImage, fallbackImage = fallbackImage,
progressEntry = progressByVideoId[episodeVideoId], progressEntry = progressByVideoId[episodeVideoId],
isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true || imdbRating = episode.seasonEpisodeKey()?.let { episodeRatings[it] },
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
WatchingState.isEpisodeWatched( WatchingState.isEpisodeWatched(
watchedKeys = watchedKeys, watchedKeys = watchedKeys,
metaType = metaType, metaType = metaType,
metaId = parentMetaId, metaId = parentMetaId,
episode = episode, episode = episode,
), ),
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
metrics = rowMetrics, metrics = rowMetrics,
onClick = { onEpisodeClick?.invoke(episode) }, onClick = { onEpisodeClick?.invoke(episode) },
onLongPress = { onEpisodeLongPress?.invoke(episode) }, onLongPress = { onEpisodeLongPress?.invoke(episode) },
@ -583,12 +634,17 @@ private fun EpisodeHorizontalCard(
video: MetaVideo, video: MetaVideo,
fallbackImage: String?, fallbackImage: String?,
progressEntry: WatchProgressEntry?, progressEntry: WatchProgressEntry?,
imdbRating: Double?,
isWatched: Boolean, isWatched: Boolean,
blurUnwatchedEpisodes: Boolean,
metrics: EpisodeHorizontalCardMetrics, metrics: EpisodeHorizontalCardMetrics,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
onLongPress: (() -> Unit)? = null, onLongPress: (() -> Unit)? = null,
) { ) {
val cardShape = RoundedCornerShape(metrics.cornerRadius) 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( Box(
modifier = Modifier modifier = Modifier
.width(metrics.cardWidth) .width(metrics.cardWidth)
@ -607,11 +663,14 @@ private fun EpisodeHorizontalCard(
), ),
) { ) {
val imageUrl = video.thumbnail ?: fallbackImage val imageUrl = video.thumbnail ?: fallbackImage
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
if (imageUrl != null) { if (imageUrl != null) {
AsyncImage( AsyncImage(
model = imageUrl, model = imageUrl,
contentDescription = video.title, contentDescription = video.title,
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
contentScale = ContentScale.Crop, 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( NuvioAnimatedWatchedBadge(
isVisible = isWatched, isVisible = isWatched,
modifier = Modifier modifier = Modifier
@ -674,6 +709,15 @@ private fun EpisodeHorizontalCard(
), ),
verticalArrangement = Arrangement.spacedBy(6.dp), 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(
text = video.title, text = video.title,
style = MaterialTheme.typography.titleMedium.copy( style = MaterialTheme.typography.titleMedium.copy(
@ -699,27 +743,39 @@ private fun EpisodeHorizontalCard(
) )
} }
Row( if (runtimeLabel != null || ratingLabel != null || formattedDate != null) {
modifier = Modifier.fillMaxWidth(), Row(
horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
) { verticalAlignment = Alignment.CenterVertically,
video.runtime?.takeIf { it > 0 }?.let { runtimeMinutes -> ) {
Text( runtimeLabel?.let { runtime ->
text = formatEpisodeRuntime(runtimeMinutes), Text(
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize), text = runtime,
color = Color.White.copy(alpha = 0.78f), style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
maxLines = 1, color = Color.White.copy(alpha = 0.78f),
) maxLines = 1,
} )
video.released?.let { formatReleaseDateForDisplay(it) }?.let { formattedDate -> }
Text( ratingLabel?.let { rating ->
text = formattedDate, ImdbEpisodeRatingBadge(
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize), rating = rating,
color = Color.White.copy(alpha = 0.78f), logoWidth = metrics.imdbLogoWidth,
maxLines = 1, logoHeight = metrics.imdbLogoHeight,
overflow = TextOverflow.Ellipsis, 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 metaTextSize: androidx.compose.ui.unit.TextUnit,
val badgeTextSize: androidx.compose.ui.unit.TextUnit, val badgeTextSize: androidx.compose.ui.unit.TextUnit,
val badgeRadius: Dp, val badgeRadius: Dp,
val badgeHorizontalPadding: Dp,
val badgeVerticalPadding: Dp,
val imdbLogoWidth: Dp,
val imdbLogoHeight: Dp,
) )
@Composable @Composable
@ -780,7 +840,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
overviewMaxLines = 3, overviewMaxLines = 3,
metaTextSize = 12.sp, metaTextSize = 12.sp,
badgeTextSize = 11.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( maxWidthDp >= 1000f -> EpisodeHorizontalCardMetrics(
@ -799,7 +863,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
overviewMaxLines = 3, overviewMaxLines = 3,
metaTextSize = 12.sp, metaTextSize = 12.sp,
badgeTextSize = 10.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( maxWidthDp >= 760f -> EpisodeHorizontalCardMetrics(
@ -818,7 +886,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
overviewMaxLines = 2, overviewMaxLines = 2,
metaTextSize = 11.sp, metaTextSize = 11.sp,
badgeTextSize = 10.sp, badgeTextSize = 10.sp,
badgeRadius = 5.dp, badgeRadius = 6.dp,
badgeHorizontalPadding = 8.dp,
badgeVerticalPadding = 4.dp,
imdbLogoWidth = 24.dp,
imdbLogoHeight = 12.dp,
) )
else -> EpisodeHorizontalCardMetrics( else -> EpisodeHorizontalCardMetrics(
@ -838,6 +910,10 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
metaTextSize = 10.sp, metaTextSize = 10.sp,
badgeTextSize = 9.sp, badgeTextSize = 9.sp,
badgeRadius = 5.dp, 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) 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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun EpisodeListCard( private fun EpisodeListCard(
video: MetaVideo, video: MetaVideo,
fallbackImage: String?, fallbackImage: String?,
progressEntry: WatchProgressEntry?, progressEntry: WatchProgressEntry?,
imdbRating: Double?,
isWatched: Boolean, isWatched: Boolean,
blurUnwatchedEpisodes: Boolean,
sizing: SeriesContentSizing, sizing: SeriesContentSizing,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
onLongPress: (() -> Unit)? = null, onLongPress: (() -> Unit)? = null,
) { ) {
val cardShape = RoundedCornerShape(sizing.cardRadius) 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( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@ -888,11 +1028,14 @@ private fun EpisodeListCard(
.clip(RoundedCornerShape(topStart = sizing.cardRadius, bottomStart = sizing.cardRadius)), .clip(RoundedCornerShape(topStart = sizing.cardRadius, bottomStart = sizing.cardRadius)),
) { ) {
val imageUrl = video.thumbnail ?: fallbackImage val imageUrl = video.thumbnail ?: fallbackImage
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
if (imageUrl != null) { if (imageUrl != null) {
AsyncImage( AsyncImage(
model = imageUrl, model = imageUrl,
contentDescription = video.title, contentDescription = video.title,
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
} else { } 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 modifier = Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.padding(start = 8.dp, top = 8.dp) .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,
)
}
NuvioAnimatedWatchedBadge( NuvioAnimatedWatchedBadge(
isVisible = isWatched, isVisible = isWatched,
@ -956,24 +1084,39 @@ private fun EpisodeListCard(
fontSize = sizing.titleTextSize, fontSize = sizing.titleTextSize,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
lineHeight = sizing.titleLineHeight, lineHeight = sizing.titleLineHeight,
letterSpacing = 0.3.sp, letterSpacing = 0.sp,
), ),
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
maxLines = sizing.titleMaxLines, maxLines = sizing.titleMaxLines,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
video.released?.let { formatReleaseDateForDisplay(it) }?.let { formattedDate -> if (formattedDate != null || ratingLabel != null) {
Text( Row(
text = formattedDate, horizontalArrangement = Arrangement.spacedBy(12.dp),
style = MaterialTheme.typography.labelMedium.copy( verticalAlignment = Alignment.CenterVertically,
fontSize = sizing.metaTextSize, ) {
fontWeight = FontWeight.Medium, formattedDate?.let { date ->
), Text(
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), text = date,
maxLines = 1, style = MaterialTheme.typography.labelMedium.copy(
overflow = TextOverflow.Ellipsis, 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()) { if (!video.overview.isNullOrBlank()) {
@ -1165,14 +1308,27 @@ private fun seriesContentSizing(maxWidthDp: Float): SeriesContentSizing =
private fun Int.label(): String = private fun Int.label(): String =
if (this <= 0) { if (this <= 0) {
"Specials" runBlocking { getString(Res.string.episodes_specials) }
} else { } else {
"Season $this" runBlocking { getString(Res.string.episodes_season, this@label) }
} }
private fun MetaVideo.episodeBadge(): String = private fun MetaVideo.episodeBadge(): String =
when { when {
episode != null -> "E${episode.toString().padStart(2, '0')}" episode != null || season != null ->
season != null -> "S${season.toString().padStart(2, '0')}" localizedSeasonEpisodeCode(seasonNumber = season, episodeNumber = episode).orEmpty()
else -> "FILE" 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.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow 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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
@ -38,6 +38,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.nuvio.app.features.details.MetaTrailer 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 @Composable
fun DetailTrailersSection( fun DetailTrailersSection(
@ -48,10 +53,11 @@ fun DetailTrailersSection(
) { ) {
if (trailers.isEmpty()) return if (trailers.isEmpty()) return
val trailerLabel = stringResource(Res.string.detail_tab_trailer)
val grouped = remember(trailers) { val grouped = remember(trailers) {
linkedMapOf<String, MutableList<MetaTrailer>>().apply { linkedMapOf<String, MutableList<MetaTrailer>>().apply {
trailers.forEach { trailer -> trailers.forEach { trailer ->
val category = trailer.type.ifBlank { "Trailer" } val category = trailer.type.ifBlank { trailerLabel }
getOrPut(category) { mutableListOf() }.add(trailer) getOrPut(category) { mutableListOf() }.add(trailer)
} }
} }
@ -60,7 +66,7 @@ fun DetailTrailersSection(
if (grouped.isEmpty()) return if (grouped.isEmpty()) return
val initialCategory = remember(grouped) { val initialCategory = remember(grouped) {
grouped.keys.firstOrNull { it.equals("Trailer", ignoreCase = true) } grouped.keys.firstOrNull { it.equals(trailerLabel, ignoreCase = true) }
?: grouped.keys.first() ?: grouped.keys.first()
} }
var selectedCategory by remember(grouped) { mutableStateOf(initialCategory) } var selectedCategory by remember(grouped) { mutableStateOf(initialCategory) }
@ -82,7 +88,7 @@ fun DetailTrailersSection(
) { ) {
if (showHeader) { if (showHeader) {
DetailSectionTitle( DetailSectionTitle(
title = "Trailers", title = stringResource(Res.string.detail_trailers_title),
fullWidth = false, fullWidth = false,
) )
} }
@ -131,7 +137,7 @@ fun DetailTrailersSection(
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Text( Text(
text = "$category ($count)", text = stringResource(Res.string.detail_trailer_category_count, category, count),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
}, },
@ -152,10 +158,10 @@ fun DetailTrailersSection(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(sizing.cardSpacing), horizontalArrangement = Arrangement.spacedBy(sizing.cardSpacing),
) { ) {
items( itemsIndexed(
items = selectedTrailers, items = selectedTrailers,
key = { trailer -> "${trailer.type}-${trailer.id}-${trailer.seasonNumber ?: 0}" }, key = { index, trailer -> "${trailer.type}-${trailer.id}-${trailer.seasonNumber ?: 0}#$index" },
) { trailer -> ) { _, trailer ->
TrailerCard( TrailerCard(
trailer = trailer, trailer = trailer,
cardWidth = sizing.cardWidth, 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.NuvioModalBottomSheet
import com.nuvio.app.core.ui.dismissNuvioBottomSheet import com.nuvio.app.core.ui.dismissNuvioBottomSheet
import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.core.ui.nuvioSafeBottomPadding
import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode
import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.details.MetaVideo
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -71,7 +74,11 @@ fun EpisodeWatchedActionSheet(
NuvioBottomSheetDivider() NuvioBottomSheetDivider()
NuvioBottomSheetActionRow( NuvioBottomSheetActionRow(
icon = Icons.Default.CheckCircle, 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 = { onClick = {
onToggleWatched() onToggleWatched()
coroutineScope.launch { coroutineScope.launch {
@ -84,9 +91,9 @@ fun EpisodeWatchedActionSheet(
NuvioBottomSheetActionRow( NuvioBottomSheetActionRow(
icon = Icons.Default.DoneAll, icon = Icons.Default.DoneAll,
title = if (arePreviousEpisodesWatched) { title = if (arePreviousEpisodesWatched) {
"Mark previous as unwatched" stringResource(Res.string.episode_mark_previous_unwatched)
} else { } else {
"Mark previous as watched" stringResource(Res.string.episode_mark_previous_watched)
}, },
onClick = { onClick = {
onTogglePreviousWatched() onTogglePreviousWatched()
@ -100,9 +107,9 @@ fun EpisodeWatchedActionSheet(
NuvioBottomSheetActionRow( NuvioBottomSheetActionRow(
icon = Icons.Default.PlaylistAddCheckCircle, icon = Icons.Default.PlaylistAddCheckCircle,
title = if (isSeasonWatched) { title = if (isSeasonWatched) {
"Mark $seasonLabel as unwatched" stringResource(Res.string.episode_mark_season_unwatched, seasonLabel)
} else { } else {
"Mark $seasonLabel as watched" stringResource(Res.string.episode_mark_season_watched, seasonLabel)
}, },
onClick = { onClick = {
onToggleSeasonWatched() onToggleSeasonWatched()
@ -115,7 +122,7 @@ fun EpisodeWatchedActionSheet(
NuvioBottomSheetDivider() NuvioBottomSheetDivider()
NuvioBottomSheetActionRow( NuvioBottomSheetActionRow(
icon = Icons.Default.PlayArrow, icon = Icons.Default.PlayArrow,
title = "Play manually", title = stringResource(Res.string.play_manually),
onClick = { onClick = {
onPlayManually() onPlayManually()
coroutineScope.launch { coroutineScope.launch {
@ -149,8 +156,11 @@ private fun EpisodeActionSheetHeader(
) )
Text( Text(
text = buildString { text = buildString {
if (episode.season != null && episode.episode != null) { localizedSeasonEpisodeCode(
append("S${episode.season}E${episode.episode}") seasonNumber = episode.season,
episodeNumber = episode.episode,
)?.let {
append(it)
append("") append("")
} }
append(seasonLabel) 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.player.PlayerResizeMode
import com.nuvio.app.features.trailer.TrailerPlaybackSource import com.nuvio.app.features.trailer.TrailerPlaybackSource
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -55,7 +57,7 @@ fun TrailerPlayerPopup(
) { ) {
if (!visible) return if (!visible) return
val headerType = trailerType.trim().ifBlank { "Trailer" } val headerType = trailerType.trim().ifBlank { stringResource(Res.string.detail_tab_trailer) }
val headerSubtitle = buildList { val headerSubtitle = buildList {
if (trailerTitle.isNotBlank() && !trailerTitle.equals(headerType, ignoreCase = true)) { if (trailerTitle.isNotBlank() && !trailerTitle.equals(headerType, ignoreCase = true)) {
add(trailerTitle) add(trailerTitle)
@ -119,7 +121,7 @@ fun TrailerPlayerPopup(
IconButton(onClick = dismissSheet) { IconButton(onClick = dismissSheet) {
Icon( Icon(
imageVector = Icons.Rounded.Close, imageVector = Icons.Rounded.Close,
contentDescription = "Close trailer", contentDescription = stringResource(Res.string.trailer_close),
tint = MaterialTheme.colorScheme.onSurface, tint = MaterialTheme.colorScheme.onSurface,
) )
} }
@ -147,7 +149,7 @@ fun TrailerPlayerPopup(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text( Text(
text = "Unable to play trailer", text = stringResource(Res.string.trailer_unable_to_play),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
@ -160,7 +162,7 @@ fun TrailerPlayerPopup(
) )
if (onRetry != null) { if (onRetry != null) {
TextButton(onClick = onRetry) { TextButton(onClick = onRetry) {
Text("Retry") Text(stringResource(Res.string.action_retry))
} }
} }
} }

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