mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-18 07:51:46 +00:00
Merge branch 'cmp-rewrite' into desktop
This commit is contained in:
commit
fdde8ba1c2
252 changed files with 29587 additions and 3667 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -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
3
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
[submodule "MPVKit"]
|
||||||
|
path = MPVKit
|
||||||
|
url = https://github.com/tapframe/MPVNuvio.git
|
||||||
|
|
@ -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.
BIN
composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar
Normal file
BIN
composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar
Normal file
Binary file not shown.
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
4
composeApp/src/androidMain/res/values-es/strings.xml
Normal file
4
composeApp/src/androidMain/res/values-es/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Nuvio</string>
|
||||||
|
</resources>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
13
composeApp/src/androidMain/res/xml/locale_config.xml
Normal file
13
composeApp/src/androidMain/res/xml/locale_config.xml
Normal 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 |
1245
composeApp/src/commonMain/composeResources/values-cs/strings.xml
Normal file
1245
composeApp/src/commonMain/composeResources/values-cs/strings.xml
Normal file
File diff suppressed because it is too large
Load diff
1199
composeApp/src/commonMain/composeResources/values-de/strings.xml
Normal file
1199
composeApp/src/commonMain/composeResources/values-de/strings.xml
Normal file
File diff suppressed because it is too large
Load diff
1043
composeApp/src/commonMain/composeResources/values-el/strings.xml
Normal file
1043
composeApp/src/commonMain/composeResources/values-el/strings.xml
Normal file
File diff suppressed because it is too large
Load diff
1161
composeApp/src/commonMain/composeResources/values-es/strings.xml
Normal file
1161
composeApp/src/commonMain/composeResources/values-es/strings.xml
Normal file
File diff suppressed because it is too large
Load diff
1195
composeApp/src/commonMain/composeResources/values-fr/strings.xml
Normal file
1195
composeApp/src/commonMain/composeResources/values-fr/strings.xml
Normal file
File diff suppressed because it is too large
Load diff
1173
composeApp/src/commonMain/composeResources/values-it/strings.xml
Normal file
1173
composeApp/src/commonMain/composeResources/values-it/strings.xml
Normal file
File diff suppressed because it is too large
Load diff
1161
composeApp/src/commonMain/composeResources/values-pl/strings.xml
Normal file
1161
composeApp/src/commonMain/composeResources/values-pl/strings.xml
Normal file
File diff suppressed because it is too large
Load diff
1173
composeApp/src/commonMain/composeResources/values-pt/strings.xml
Normal file
1173
composeApp/src/commonMain/composeResources/values-pt/strings.xml
Normal file
File diff suppressed because it is too large
Load diff
1043
composeApp/src/commonMain/composeResources/values-tr/strings.xml
Normal file
1043
composeApp/src/commonMain/composeResources/values-tr/strings.xml
Normal file
File diff suppressed because it is too large
Load diff
1277
composeApp/src/commonMain/composeResources/values/strings.xml
Normal file
1277
composeApp/src/commonMain/composeResources/values/strings.xml
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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?,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package com.nuvio.app.features.collection
|
||||||
|
|
||||||
|
internal expect object CollectionMobileSettingsStorage {
|
||||||
|
fun loadPayload(): String?
|
||||||
|
fun savePayload(payload: String)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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("_")
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue