mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 07:21:58 +00:00
Merge branch 'cmp-rewrite' into patch-25
This commit is contained in:
commit
b481072a80
226 changed files with 25319 additions and 3610 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -25,3 +25,5 @@ keystore/
|
|||
scripts/build-distribution.sh
|
||||
asset
|
||||
scripts/scrape_android_compose_animation_docs.py
|
||||
tools
|
||||
AGENTS.md
|
||||
|
|
|
|||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[submodule "MPVKit"]
|
||||
path = MPVKit
|
||||
url = https://github.com/tapframe/MPVNuvio.git
|
||||
|
|
@ -76,6 +76,20 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|
|||
)
|
||||
}
|
||||
|
||||
outDir.resolve("com/nuvio/app/features/details").apply {
|
||||
mkdirs()
|
||||
resolve("ImdbEpisodeRatingsConfig.kt").writeText(
|
||||
"""
|
||||
|package com.nuvio.app.features.details
|
||||
|
|
||||
|object ImdbEpisodeRatingsConfig {
|
||||
| const val IMDB_RATINGS_API_BASE_URL = "${props.getProperty("IMDB_RATINGS_API_BASE_URL", "")}"
|
||||
| const val IMDB_TAPFRAME_API_BASE_URL = "${props.getProperty("IMDB_TAPFRAME_API_BASE_URL", "")}"
|
||||
|}
|
||||
""".trimMargin()
|
||||
)
|
||||
}
|
||||
|
||||
outDir.resolve("com/nuvio/app/core/build").apply {
|
||||
mkdirs()
|
||||
resolve("AppVersionConfig.kt").writeText(
|
||||
|
|
@ -97,6 +111,7 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|
|||
|package com.nuvio.app.features.settings
|
||||
|
|
||||
|object CommunityConfig {
|
||||
| const val CONTRIBUTIONS_URL = "${props.getProperty("CONTRIBUTIONS_URL", "")}"
|
||||
| const val DONATIONS_BASE_URL = "${props.getProperty("DONATIONS_BASE_URL", "")}"
|
||||
| const val DONATIONS_DONATE_URL = "${props.getProperty("DONATIONS_DONATE_URL", "")}"
|
||||
|}
|
||||
|
|
@ -219,6 +234,7 @@ kotlin {
|
|||
}
|
||||
androidMain.dependencies {
|
||||
implementation(libs.compose.uiToolingPreview)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
implementation(libs.androidx.work.runtime)
|
||||
|
|
@ -270,12 +286,13 @@ kotlin {
|
|||
|
||||
afterEvaluate {
|
||||
dependencies {
|
||||
add("fullImplementation", libs.quickjs.kt)
|
||||
add("fullImplementation", files("libs/quickjs-kt-android-1.0.5-nuvio.aar"))
|
||||
add("fullImplementation", libs.ksoup)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
||||
debugImplementation(libs.compose.uiTooling)
|
||||
}
|
||||
|
||||
|
|
@ -348,6 +365,7 @@ android {
|
|||
}
|
||||
}
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
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:usesCleartextTraffic="true"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:localeConfig="@xml/locale_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Nuvio">
|
||||
<activity
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import androidx.activity.ComponentActivity
|
|||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.nuvio.app.core.auth.AuthStorage
|
||||
import com.nuvio.app.core.deeplink.handleAppUrl
|
||||
|
|
@ -34,6 +35,7 @@ import com.nuvio.app.features.settings.ThemeSettingsStorage
|
|||
import com.nuvio.app.features.trakt.TraktAuthStorage
|
||||
import com.nuvio.app.features.trakt.TraktCommentsStorage
|
||||
import com.nuvio.app.features.trakt.TraktLibraryStorage
|
||||
import com.nuvio.app.features.trakt.TraktSettingsStorage
|
||||
import com.nuvio.app.features.tmdb.TmdbSettingsStorage
|
||||
import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform
|
||||
import com.nuvio.app.core.ui.PosterCardStyleStorage
|
||||
|
|
@ -44,7 +46,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage
|
|||
import com.nuvio.app.features.watchprogress.ResumePromptStorage
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressStorage
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
class MainActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
enableEdgeToEdge(
|
||||
|
|
@ -52,6 +54,7 @@ class MainActivity : ComponentActivity() {
|
|||
scrim = 0xFF020404.toInt(),
|
||||
),
|
||||
)
|
||||
ThemeSettingsStorage.initialize(applicationContext)
|
||||
super.onCreate(savedInstanceState)
|
||||
window.setBackgroundDrawableResource(R.color.nuvio_background)
|
||||
AddonStorage.initialize(applicationContext)
|
||||
|
|
@ -66,13 +69,13 @@ class MainActivity : ComponentActivity() {
|
|||
ProfilePinCacheStorage.initialize(applicationContext)
|
||||
SearchHistoryStorage.initialize(applicationContext)
|
||||
SeasonViewModeStorage.initialize(applicationContext)
|
||||
ThemeSettingsStorage.initialize(applicationContext)
|
||||
PosterCardStyleStorage.initialize(applicationContext)
|
||||
TmdbSettingsStorage.initialize(applicationContext)
|
||||
MdbListSettingsStorage.initialize(applicationContext)
|
||||
TraktAuthStorage.initialize(applicationContext)
|
||||
TraktCommentsStorage.initialize(applicationContext)
|
||||
TraktLibraryStorage.initialize(applicationContext)
|
||||
TraktSettingsStorage.initialize(applicationContext)
|
||||
ContinueWatchingPreferencesStorage.initialize(applicationContext)
|
||||
ResumePromptStorage.initialize(applicationContext)
|
||||
ContinueWatchingEnrichmentStorage.initialize(applicationContext)
|
||||
|
|
|
|||
|
|
@ -16,12 +16,14 @@ internal actual object PlatformLocalAccountDataCleaner {
|
|||
"nuvio_mdblist_settings",
|
||||
"nuvio_trakt_auth",
|
||||
"nuvio_trakt_library",
|
||||
"nuvio_trakt_settings",
|
||||
"nuvio_watched",
|
||||
"nuvio_stream_link_cache",
|
||||
"nuvio_continue_watching_preferences",
|
||||
"nuvio_episode_release_notifications",
|
||||
"nuvio_episode_release_notifications_platform",
|
||||
"nuvio_watch_progress",
|
||||
"nuvio_collections",
|
||||
"nuvio_plugins",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
headers: Map<String, String>,
|
||||
body: String,
|
||||
followRedirects: Boolean,
|
||||
): RawHttpResponse =
|
||||
withContext(Dispatchers.IO) {
|
||||
val normalizedMethod = method.uppercase()
|
||||
|
|
@ -228,7 +229,16 @@ actual suspend fun httpRequestRaw(
|
|||
builder.method(normalizedMethod, null)
|
||||
}.build()
|
||||
|
||||
addonHttpClient.newCall(request).execute().use { response ->
|
||||
val client = if (followRedirects) {
|
||||
addonHttpClient
|
||||
} else {
|
||||
addonHttpClient.newBuilder()
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
RawHttpResponse(
|
||||
status = response.code,
|
||||
statusText = response.message,
|
||||
|
|
|
|||
|
|
@ -12,12 +12,13 @@ import androidx.core.app.NotificationCompat
|
|||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.nuvio.app.core.deeplink.buildDownloadsDeepLinkUrl
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import kotlin.math.abs
|
||||
|
||||
internal actual object DownloadsLiveStatusPlatform {
|
||||
private const val channelId = "downloads_live_status"
|
||||
private const val channelName = "Downloads"
|
||||
private const val channelDescription = "Shows live download progress and controls."
|
||||
private const val notificationsPrefName = "nuvio_download_live_notifications"
|
||||
private const val trackedDownloadIdsKey = "tracked_download_ids"
|
||||
|
||||
|
|
@ -143,7 +144,7 @@ internal actual object DownloadsLiveStatusPlatform {
|
|||
.setProgress(0, 0, false)
|
||||
.addAction(
|
||||
0,
|
||||
"Resume",
|
||||
runBlocking { getString(Res.string.action_resume) },
|
||||
buildActionPendingIntent(
|
||||
context = context,
|
||||
action = DownloadsNotificationActionReceiver.actionResume,
|
||||
|
|
@ -163,15 +164,15 @@ internal actual object DownloadsLiveStatusPlatform {
|
|||
val downloaded = formatBytes(item.downloadedBytes)
|
||||
val total = item.totalBytes?.let(::formatBytes)
|
||||
if (total != null) {
|
||||
"Downloading $detail • $downloaded / $total"
|
||||
runBlocking { getString(Res.string.downloads_live_downloading_with_total, detail, downloaded, total) }
|
||||
} else {
|
||||
"Downloading $detail • $downloaded"
|
||||
runBlocking { getString(Res.string.downloads_live_downloading, detail, downloaded) }
|
||||
}
|
||||
}
|
||||
|
||||
DownloadStatus.Paused -> "Paused $detail"
|
||||
DownloadStatus.Failed -> item.errorMessage?.takeIf { it.isNotBlank() } ?: "Download failed"
|
||||
DownloadStatus.Completed -> "Download completed"
|
||||
DownloadStatus.Paused -> runBlocking { getString(Res.string.downloads_live_paused, detail) }
|
||||
DownloadStatus.Failed -> item.errorMessage?.takeIf { it.isNotBlank() } ?: runBlocking { getString(Res.string.downloads_live_failed) }
|
||||
DownloadStatus.Completed -> runBlocking { getString(Res.string.downloads_live_completed) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -224,8 +225,12 @@ internal actual object DownloadsLiveStatusPlatform {
|
|||
if (manager.getNotificationChannel(channelId) != null) return
|
||||
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = channelDescription
|
||||
NotificationChannel(
|
||||
channelId,
|
||||
runBlocking { getString(Res.string.downloads_channel_name) },
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = runBlocking { getString(Res.string.downloads_channel_description) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,12 @@ import kotlinx.coroutines.Job
|
|||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Call
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.URI
|
||||
|
|
@ -44,7 +47,7 @@ internal actual object DownloadsPlatformDownloader {
|
|||
scope.launch {
|
||||
val context = appContext
|
||||
if (context == null) {
|
||||
onFailure("Download system is not initialized")
|
||||
onFailure(runBlocking { getString(Res.string.downloads_error_not_initialized) })
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
|
@ -69,7 +72,9 @@ internal actual object DownloadsPlatformDownloader {
|
|||
var attemptedRangeRequest = resumeFromBytes > 0L
|
||||
var httpRequest = buildRequest(if (attemptedRangeRequest) resumeFromBytes else null)
|
||||
call = downloadHttpClient.newCall(httpRequest)
|
||||
var response = call?.execute() ?: error("Download request failed")
|
||||
var response = call?.execute() ?: error(
|
||||
runBlocking { getString(Res.string.downloads_error_request_failed) },
|
||||
)
|
||||
|
||||
if (attemptedRangeRequest && response.code == 416) {
|
||||
response.close()
|
||||
|
|
@ -78,12 +83,18 @@ internal actual object DownloadsPlatformDownloader {
|
|||
attemptedRangeRequest = false
|
||||
httpRequest = buildRequest(null)
|
||||
call = downloadHttpClient.newCall(httpRequest)
|
||||
response = call?.execute() ?: error("Download request failed")
|
||||
response = call?.execute() ?: error(
|
||||
runBlocking { getString(Res.string.downloads_error_request_failed) },
|
||||
)
|
||||
}
|
||||
|
||||
response.use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
error("Request failed with HTTP ${response.code}")
|
||||
error(
|
||||
runBlocking {
|
||||
getString(Res.string.downloads_error_http_failed, response.code)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val isPartialResume = attemptedRangeRequest && response.code == 206 && resumeFromBytes > 0L
|
||||
|
|
@ -94,7 +105,9 @@ internal actual object DownloadsPlatformDownloader {
|
|||
tempFile.delete()
|
||||
}
|
||||
|
||||
val body = response.body ?: error("Empty response body")
|
||||
val body = response.body ?: error(
|
||||
runBlocking { getString(Res.string.downloads_error_empty_body) },
|
||||
)
|
||||
val totalBytes = resolveTotalBytes(
|
||||
startingBytes = startingBytes,
|
||||
isPartialResume = isPartialResume,
|
||||
|
|
@ -131,7 +144,7 @@ internal actual object DownloadsPlatformDownloader {
|
|||
onSuccess(destination.toURI().toString(), totalBytes ?: finalSize)
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
onFailure(error.message ?: "Download failed")
|
||||
onFailure(error.message ?: runBlocking { getString(Res.string.download_failed) })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -155,6 +168,24 @@ internal actual object DownloadsPlatformDownloader {
|
|||
if (!tempFile.exists()) return true
|
||||
return runCatching { tempFile.delete() }.getOrDefault(false)
|
||||
}
|
||||
|
||||
actual fun resolveLocalFileUri(localFileUri: String?, destinationFileName: String): String? {
|
||||
localFileUri
|
||||
?.toLocalFileOrNull()
|
||||
?.takeIf { it.exists() }
|
||||
?.let { return it.toURI().toString() }
|
||||
|
||||
val context = appContext ?: return null
|
||||
val fileName = destinationFileName.trim().takeIf { it.isNotBlank() }
|
||||
?: localFileUri
|
||||
?.toLocalFileOrNull()
|
||||
?.name
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: return null
|
||||
val downloadsDir = File(context.filesDir, "downloads")
|
||||
val localFile = File(downloadsDir, fileName)
|
||||
return localFile.takeIf { it.exists() }?.toURI()?.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private class AndroidDownloadsTaskHandle(
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ import androidx.work.WorkManager
|
|||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.engine.android.Android
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.request.get
|
||||
import java.time.LocalDate
|
||||
|
|
@ -285,13 +288,13 @@ internal actual object EpisodeReleaseNotificationPlatform {
|
|||
|
||||
val channel = NotificationChannel(
|
||||
channelId,
|
||||
"Episode Releases",
|
||||
runBlocking { getString(Res.string.notifications_channel_episode_releases_name) },
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description = "Alerts when a saved show's new episode is released."
|
||||
description = runBlocking { getString(Res.string.notifications_channel_episode_releases_description) }
|
||||
}
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun uniqueWorkName(requestId: String): String = "$workTag:$requestId"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.platform.LocalContext
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.media3.common.C
|
||||
|
|
@ -55,7 +58,6 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.withContext
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.Locale
|
||||
|
||||
private const val TAG = "NuvioPlayer"
|
||||
|
||||
|
|
@ -177,6 +179,10 @@ actual fun PlatformPlayerSurface(
|
|||
var currentSubtitleStyle by remember { mutableStateOf(SubtitleStyleState.DEFAULT) }
|
||||
var subtitleSelectionJob by remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
fun syncPlayerViewKeepScreenOn() {
|
||||
playerViewRef?.keepScreenOn = exoPlayer.shouldKeepPlayerScreenOn()
|
||||
}
|
||||
|
||||
DisposableEffect(exoPlayer) {
|
||||
PlayerPictureInPictureManager.registerPausePlaybackCallback {
|
||||
exoPlayer.pause()
|
||||
|
|
@ -184,7 +190,8 @@ actual fun PlatformPlayerSurface(
|
|||
|
||||
val listener = object : Player.Listener {
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
latestOnError.value(error.localizedMessage ?: "Unable to play this stream.")
|
||||
syncPlayerViewKeepScreenOn()
|
||||
latestOnError.value(error.localizedMessage ?: runBlocking { getString(Res.string.player_unable_to_play_stream) })
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
|
|
@ -200,10 +207,12 @@ actual fun PlatformPlayerSurface(
|
|||
latestOnError.value(null)
|
||||
exoPlayer.logCurrentTracks("STATE_READY")
|
||||
}
|
||||
syncPlayerViewKeepScreenOn()
|
||||
latestOnSnapshot.value(exoPlayer.snapshot())
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
syncPlayerViewKeepScreenOn()
|
||||
latestOnSnapshot.value(exoPlayer.snapshot())
|
||||
}
|
||||
|
||||
|
|
@ -233,6 +242,7 @@ actual fun PlatformPlayerSurface(
|
|||
onDispose {
|
||||
PlayerPictureInPictureManager.registerPausePlaybackCallback(null)
|
||||
exoPlayer.removeListener(listener)
|
||||
playerViewRef?.keepScreenOn = false
|
||||
subtitleSelectionJob?.cancel()
|
||||
}
|
||||
}
|
||||
|
|
@ -262,6 +272,7 @@ actual fun PlatformPlayerSurface(
|
|||
|
||||
LaunchedEffect(exoPlayer, playWhenReady) {
|
||||
exoPlayer.playWhenReady = playWhenReady
|
||||
syncPlayerViewKeepScreenOn()
|
||||
latestOnSnapshot.value(exoPlayer.snapshot())
|
||||
}
|
||||
|
||||
|
|
@ -295,10 +306,10 @@ actual fun PlatformPlayerSurface(
|
|||
}
|
||||
|
||||
override fun getAudioTracks(): List<AudioTrack> =
|
||||
exoPlayer.extractAudioTracks()
|
||||
exoPlayer.extractAudioTracks(context)
|
||||
|
||||
override fun getSubtitleTracks(): List<SubtitleTrack> {
|
||||
val tracks = exoPlayer.extractSubtitleTracks()
|
||||
val tracks = exoPlayer.extractSubtitleTracks(context)
|
||||
Log.d(TAG, "getSubtitleTracks: found ${tracks.size} tracks")
|
||||
tracks.forEach { t ->
|
||||
Log.d(TAG, " track idx=${t.index} id=${t.id} label='${t.label}' lang=${t.language} selected=${t.isSelected}")
|
||||
|
|
@ -422,7 +433,7 @@ actual fun PlatformPlayerSurface(
|
|||
useController = useNativeController
|
||||
layoutParams = android.view.ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
|
||||
player = exoPlayer
|
||||
keepScreenOn = true
|
||||
keepScreenOn = exoPlayer.shouldKeepPlayerScreenOn()
|
||||
this.resizeMode = resizeMode.toExoResizeMode()
|
||||
setShutterBackgroundColor(android.graphics.Color.BLACK)
|
||||
playerViewRef = this
|
||||
|
|
@ -439,6 +450,7 @@ actual fun PlatformPlayerSurface(
|
|||
playerView.useController = useNativeController
|
||||
playerView.resizeMode = resizeMode.toExoResizeMode()
|
||||
playerViewRef = playerView
|
||||
syncPlayerViewKeepScreenOn()
|
||||
playerView.syncLibassOverlay(
|
||||
player = exoPlayer,
|
||||
enabled = useLibass,
|
||||
|
|
@ -467,6 +479,11 @@ private fun ExoPlayer.snapshot(): PlayerPlaybackSnapshot =
|
|||
playbackSpeed = playbackParameters.speed,
|
||||
)
|
||||
|
||||
private fun ExoPlayer.shouldKeepPlayerScreenOn(): Boolean =
|
||||
playerError == null &&
|
||||
playWhenReady &&
|
||||
playbackState in setOf(Player.STATE_BUFFERING, Player.STATE_READY)
|
||||
|
||||
private fun PlayerResizeMode.toExoResizeMode(): Int =
|
||||
when (this) {
|
||||
PlayerResizeMode.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
|
|
@ -556,44 +573,20 @@ private fun PlayerView.applySubtitleStyle(style: SubtitleStyleState) {
|
|||
}
|
||||
}
|
||||
|
||||
private fun ExoPlayer.extractAudioTracks(): List<AudioTrack> {
|
||||
private fun ExoPlayer.extractAudioTracks(context: Context): List<AudioTrack> {
|
||||
val tracks = mutableListOf<AudioTrack>()
|
||||
val trackNameProvider = CustomDefaultTrackNameProvider(context.resources)
|
||||
var idx = 0
|
||||
for (group in currentTracks.groups) {
|
||||
if (group.type != C.TRACK_TYPE_AUDIO) continue
|
||||
val format = group.mediaTrackGroup.getFormat(0)
|
||||
val channelLabel = when {
|
||||
format.channelCount == 1 -> "Mono"
|
||||
format.channelCount == 2 -> "Stereo"
|
||||
format.channelCount == 6 -> "5.1"
|
||||
format.channelCount == 8 -> "7.1"
|
||||
format.channelCount > 0 -> "${format.channelCount}ch"
|
||||
else -> null
|
||||
}
|
||||
val mime = format.sampleMimeType?.lowercase()
|
||||
val codecLabel = when {
|
||||
mime == null -> null
|
||||
mime.contains("eac3-joc") -> "Dolby Atmos"
|
||||
mime.contains("truehd") && format.channelCount >= 8 -> "Dolby Atmos"
|
||||
mime.contains("truehd") -> "Dolby TrueHD"
|
||||
mime.contains("eac3") -> "Dolby Digital Plus"
|
||||
mime.contains("ac3") -> "Dolby Digital"
|
||||
mime.contains("opus") -> "Opus"
|
||||
mime.contains("aac") -> "AAC"
|
||||
mime.contains("dts-hd") -> "DTS-HD"
|
||||
mime.contains("dts") -> "DTS"
|
||||
else -> null
|
||||
}
|
||||
val resolvedLanguage = format.language?.let { lang -> Locale(lang).displayLanguage.takeIf { name -> name.isNotBlank() && name != lang } }
|
||||
val baseName = format.label?.takeIf { it.isNotBlank() } ?: resolvedLanguage ?: format.language ?: "Track ${idx + 1}"
|
||||
val suffix = listOfNotNull(channelLabel, codecLabel)
|
||||
.joinToString(" ")
|
||||
.let { if (it.isNotBlank()) " ($it)" else "" }
|
||||
val label = trackNameProvider.getTrackName(format).takeIf { it.isNotBlank() }
|
||||
?: runBlocking { getString(Res.string.compose_player_track_number, idx + 1) }
|
||||
tracks.add(
|
||||
AudioTrack(
|
||||
index = idx,
|
||||
id = format.id ?: idx.toString(),
|
||||
label = "$baseName$suffix",
|
||||
label = label,
|
||||
language = format.language,
|
||||
isSelected = group.isSelected,
|
||||
)
|
||||
|
|
@ -603,8 +596,9 @@ private fun ExoPlayer.extractAudioTracks(): List<AudioTrack> {
|
|||
return tracks
|
||||
}
|
||||
|
||||
private fun ExoPlayer.extractSubtitleTracks(): List<SubtitleTrack> {
|
||||
private fun ExoPlayer.extractSubtitleTracks(context: Context): List<SubtitleTrack> {
|
||||
val tracks = mutableListOf<SubtitleTrack>()
|
||||
val trackNameProvider = CustomDefaultTrackNameProvider(context.resources)
|
||||
var idx = 0
|
||||
for (group in currentTracks.groups) {
|
||||
if (group.type != C.TRACK_TYPE_TEXT) continue
|
||||
|
|
@ -614,7 +608,7 @@ private fun ExoPlayer.extractSubtitleTracks(): List<SubtitleTrack> {
|
|||
SubtitleTrack(
|
||||
index = idx,
|
||||
id = format.id ?: idx.toString(),
|
||||
label = format.label ?: "",
|
||||
label = trackNameProvider.getTrackName(format),
|
||||
language = format.language,
|
||||
isSelected = group.isSelected,
|
||||
isForced = inferForcedSubtitleTrack(
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ actual fun LockPlayerToLandscape() {
|
|||
}
|
||||
|
||||
@Composable
|
||||
actual fun EnterImmersivePlayerMode() {
|
||||
actual fun EnterImmersivePlayerMode(keepScreenAwake: Boolean) {
|
||||
val activity = LocalContext.current.findActivity() ?: return
|
||||
|
||||
DisposableEffect(activity) {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ actual object PlayerSettingsStorage {
|
|||
private const val skipIntroEnabledKey = "skip_intro_enabled"
|
||||
private const val animeSkipEnabledKey = "animeskip_enabled"
|
||||
private const val animeSkipClientIdKey = "animeskip_client_id"
|
||||
private const val introDbApiKeyKey = "introdb_api_key"
|
||||
private const val introSubmitEnabledKey = "intro_submit_enabled"
|
||||
private const val streamAutoPlayNextEpisodeEnabledKey = "stream_auto_play_next_episode_enabled"
|
||||
private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group"
|
||||
private const val nextEpisodeThresholdModeKey = "next_episode_threshold_mode"
|
||||
|
|
@ -480,6 +482,33 @@ actual object PlayerSettingsStorage {
|
|||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadIntroDbApiKey(): String? =
|
||||
preferences?.getString(ProfileScopedKey.of(introDbApiKeyKey), null)
|
||||
|
||||
actual fun saveIntroDbApiKey(apiKey: String) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putString(ProfileScopedKey.of(introDbApiKeyKey), apiKey)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadIntroSubmitEnabled(): Boolean? =
|
||||
preferences?.let { sharedPreferences ->
|
||||
val key = ProfileScopedKey.of(introSubmitEnabledKey)
|
||||
if (sharedPreferences.contains(key)) {
|
||||
sharedPreferences.getBoolean(key, false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual fun saveIntroSubmitEnabled(enabled: Boolean) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putBoolean(ProfileScopedKey.of(introSubmitEnabledKey), enabled)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadStreamAutoPlayNextEpisodeEnabled(): Boolean? =
|
||||
preferences?.let { sharedPreferences ->
|
||||
val key = ProfileScopedKey.of(streamAutoPlayNextEpisodeEnabledKey)
|
||||
|
|
@ -652,6 +681,8 @@ actual object PlayerSettingsStorage {
|
|||
payload.decodeSyncBoolean(skipIntroEnabledKey)?.let(::saveSkipIntroEnabled)
|
||||
payload.decodeSyncBoolean(animeSkipEnabledKey)?.let(::saveAnimeSkipEnabled)
|
||||
payload.decodeSyncString(animeSkipClientIdKey)?.let(::saveAnimeSkipClientId)
|
||||
payload.decodeSyncString(introDbApiKeyKey)?.let(::saveIntroDbApiKey)
|
||||
payload.decodeSyncBoolean(introSubmitEnabledKey)?.let(::saveIntroSubmitEnabled)
|
||||
payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled)
|
||||
payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup)
|
||||
payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package com.nuvio.app.features.settings
|
|||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import com.nuvio.app.core.sync.decodeSyncBoolean
|
||||
import com.nuvio.app.core.sync.decodeSyncString
|
||||
import com.nuvio.app.core.sync.encodeSyncBoolean
|
||||
|
|
@ -15,12 +17,20 @@ actual object ThemeSettingsStorage {
|
|||
private const val preferencesName = "nuvio_theme_settings"
|
||||
private const val selectedThemeKey = "selected_theme"
|
||||
private const val amoledEnabledKey = "amoled_enabled"
|
||||
private val syncKeys = listOf(selectedThemeKey, amoledEnabledKey)
|
||||
private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled"
|
||||
private const val selectedAppLanguageKey = "selected_app_language"
|
||||
private val profileScopedSyncKeys = listOf(
|
||||
selectedThemeKey,
|
||||
amoledEnabledKey,
|
||||
liquidGlassNativeTabBarEnabledKey,
|
||||
)
|
||||
private val globalSyncKeys = listOf(selectedAppLanguageKey)
|
||||
|
||||
private var preferences: SharedPreferences? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
|
||||
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
|
||||
}
|
||||
|
||||
actual fun loadSelectedTheme(): String? =
|
||||
|
|
@ -46,17 +56,57 @@ actual object ThemeSettingsStorage {
|
|||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadLiquidGlassNativeTabBarEnabled(): Boolean? =
|
||||
preferences?.let { prefs ->
|
||||
val key = ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey)
|
||||
if (prefs.contains(key)) prefs.getBoolean(key, false) else null
|
||||
}
|
||||
|
||||
actual fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putBoolean(ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey), enabled)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadSelectedAppLanguage(): String? {
|
||||
val value = preferences?.getString(selectedAppLanguageKey, null)
|
||||
if (value != null) return value
|
||||
val legacy = preferences?.getString(ProfileScopedKey.of(selectedAppLanguageKey), null)
|
||||
if (legacy != null) saveSelectedAppLanguage(legacy)
|
||||
return legacy
|
||||
}
|
||||
|
||||
actual fun saveSelectedAppLanguage(languageCode: String) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putString(selectedAppLanguageKey, languageCode)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun applySelectedAppLanguage(languageCode: String) {
|
||||
AppCompatDelegate.setApplicationLocales(
|
||||
LocaleListCompat.forLanguageTags(languageCode),
|
||||
)
|
||||
}
|
||||
|
||||
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
|
||||
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
|
||||
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
|
||||
loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) }
|
||||
loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) }
|
||||
}
|
||||
|
||||
actual fun replaceFromSyncPayload(payload: JsonObject) {
|
||||
preferences?.edit()?.apply {
|
||||
syncKeys.forEach { remove(ProfileScopedKey.of(it)) }
|
||||
profileScopedSyncKeys.forEach { remove(ProfileScopedKey.of(it)) }
|
||||
globalSyncKeys.forEach { remove(it) }
|
||||
}?.apply()
|
||||
|
||||
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
|
||||
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
|
||||
payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled)
|
||||
payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage)
|
||||
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"?>
|
||||
<resources>
|
||||
<style name="Theme.Nuvio" parent="@android:style/Theme.Material.NoActionBar">
|
||||
<style name="Theme.Nuvio" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="android:windowBackground">@color/nuvio_background</item>
|
||||
</style>
|
||||
|
||||
|
|
@ -9,4 +9,4 @@
|
|||
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash_logo</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.Nuvio</item>
|
||||
</style>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Nuvio" parent="@android:style/Theme.Material.NoActionBar">
|
||||
<style name="Theme.Nuvio" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="android:windowBackground">@color/nuvio_background</item>
|
||||
</style>
|
||||
|
||||
|
|
|
|||
12
composeApp/src/androidMain/res/xml/locale_config.xml
Normal file
12
composeApp/src/androidMain/res/xml/locale_config.xml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<locale android:name="en"/>
|
||||
<locale android:name="fr"/>
|
||||
<locale android:name="es"/>
|
||||
<locale android:name="pt"/>
|
||||
<locale android:name="tr"/>
|
||||
<locale android:name="it"/>
|
||||
<locale android:name="el"/>
|
||||
<locale android:name="pl"/>
|
||||
<locale android:name="de"/>
|
||||
</locale-config>
|
||||
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
1241
composeApp/src/commonMain/composeResources/values/strings.xml
Normal file
1241
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.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -60,6 +61,8 @@ import androidx.lifecycle.Lifecycle
|
|||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDestination.Companion.hasRoute
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
|
|
@ -92,6 +95,10 @@ import com.nuvio.app.core.ui.NuvioToastController
|
|||
import com.nuvio.app.core.ui.NuvioFloatingPrompt
|
||||
import com.nuvio.app.core.ui.TraktListPickerDialog
|
||||
import com.nuvio.app.core.ui.NuvioTheme
|
||||
import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding
|
||||
import com.nuvio.app.core.ui.NativeNavigationTab
|
||||
import com.nuvio.app.core.ui.NativeTabBridge
|
||||
import com.nuvio.app.core.ui.isLiquidGlassNativeTabBarSupported
|
||||
import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle
|
||||
import com.nuvio.app.features.auth.AuthScreen
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
|
|
@ -123,11 +130,13 @@ import com.nuvio.app.features.player.PlayerRoute
|
|||
import com.nuvio.app.features.player.PlayerScreen
|
||||
import com.nuvio.app.features.player.sanitizePlaybackHeaders
|
||||
import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders
|
||||
import com.nuvio.app.features.profiles.AvatarRepository
|
||||
import com.nuvio.app.features.profiles.NuvioProfile
|
||||
import com.nuvio.app.features.profiles.ProfileEditScreen
|
||||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import com.nuvio.app.features.profiles.ProfileSelectionScreen
|
||||
import com.nuvio.app.features.profiles.ProfileSwitcherTab
|
||||
import com.nuvio.app.features.profiles.profileAvatarImageUrl
|
||||
import com.nuvio.app.features.search.SearchScreen
|
||||
import com.nuvio.app.features.settings.SettingsScreen
|
||||
import com.nuvio.app.features.settings.HomescreenSettingsScreen
|
||||
|
|
@ -153,8 +162,6 @@ import com.nuvio.app.features.streams.StreamsRepository
|
|||
import com.nuvio.app.features.streams.StreamsScreen
|
||||
import com.nuvio.app.features.tmdb.TmdbService
|
||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktConnectionMode
|
||||
import com.nuvio.app.features.trakt.TraktListTab
|
||||
import com.nuvio.app.features.updater.AppUpdaterHost
|
||||
import com.nuvio.app.features.updater.rememberAppUpdaterController
|
||||
|
|
@ -263,6 +270,20 @@ enum class AppScreenTab {
|
|||
Settings,
|
||||
}
|
||||
|
||||
private fun AppScreenTab.toNativeNavigationTab(): NativeNavigationTab = when (this) {
|
||||
AppScreenTab.Home -> NativeNavigationTab.Home
|
||||
AppScreenTab.Search -> NativeNavigationTab.Search
|
||||
AppScreenTab.Library -> NativeNavigationTab.Library
|
||||
AppScreenTab.Settings -> NativeNavigationTab.Settings
|
||||
}
|
||||
|
||||
private fun NativeNavigationTab.toAppScreenTab(): AppScreenTab = when (this) {
|
||||
NativeNavigationTab.Home -> AppScreenTab.Home
|
||||
NativeNavigationTab.Search -> AppScreenTab.Search
|
||||
NativeNavigationTab.Library -> AppScreenTab.Library
|
||||
NativeNavigationTab.Settings -> AppScreenTab.Settings
|
||||
}
|
||||
|
||||
private enum class AppGateScreen {
|
||||
Loading,
|
||||
Auth,
|
||||
|
|
@ -296,13 +317,36 @@ fun App() {
|
|||
LaunchedEffect(Unit) {
|
||||
NetworkStatusRepository.ensureStarted()
|
||||
ProfileRepository.loadCachedProfiles()
|
||||
AvatarRepository.fetchAvatars()
|
||||
}
|
||||
|
||||
val authState by AuthRepository.state.collectAsStateWithLifecycle()
|
||||
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
|
||||
val profileAvatars by AvatarRepository.avatars.collectAsStateWithLifecycle()
|
||||
val networkStatusUiState by remember {
|
||||
NetworkStatusRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(
|
||||
profileState.activeProfile?.profileIndex,
|
||||
profileState.activeProfile?.name,
|
||||
profileState.activeProfile?.avatarColorHex,
|
||||
profileState.activeProfile?.avatarId,
|
||||
profileState.activeProfile?.avatarUrl,
|
||||
profileAvatars,
|
||||
) {
|
||||
val activeProfile = profileState.activeProfile
|
||||
val avatarItem = activeProfile?.avatarId?.let { avatarId ->
|
||||
profileAvatars.find { it.id == avatarId }
|
||||
}
|
||||
NativeTabBridge.publishProfileTabIcon(
|
||||
name = activeProfile?.name,
|
||||
avatarColorHex = activeProfile?.avatarColorHex,
|
||||
avatarImageUrl = activeProfile?.let { profileAvatarImageUrl(it, avatarItem) },
|
||||
avatarBackgroundColorHex = avatarItem?.bgColor,
|
||||
)
|
||||
}
|
||||
|
||||
var gateScreen by rememberSaveable { mutableStateOf(AppGateScreen.Loading.name) }
|
||||
var editingProfile by remember { mutableStateOf<NuvioProfile?>(null) }
|
||||
var isNewProfile by remember { mutableStateOf(false) }
|
||||
|
|
@ -452,47 +496,48 @@ fun App() {
|
|||
private fun MainAppContent(
|
||||
onSwitchProfile: () -> Unit = {},
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val appUpdaterController = rememberAppUpdaterController()
|
||||
remember {
|
||||
EpisodeReleaseNotificationsRepository.ensureLoaded()
|
||||
}
|
||||
remember {
|
||||
CollectionSyncService.startObserving()
|
||||
}
|
||||
remember {
|
||||
HomeCatalogSettingsSyncService.startObserving()
|
||||
}
|
||||
remember {
|
||||
ProfileSettingsSync.startObserving()
|
||||
}
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
|
||||
var showExitConfirmation by rememberSaveable { mutableStateOf(false) }
|
||||
var selectedPosterForActions by remember { mutableStateOf<MetaPreview?>(null) }
|
||||
var selectedContinueWatchingForActions by remember { mutableStateOf<ContinueWatchingItem?>(null) }
|
||||
var showLibraryListPicker by remember { mutableStateOf(false) }
|
||||
var pickerItem by remember { mutableStateOf<LibraryItem?>(null) }
|
||||
var pickerTitle by remember { mutableStateOf("") }
|
||||
var pickerTabs by remember { mutableStateOf<List<TraktListTab>>(emptyList()) }
|
||||
var pickerMembership by remember { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
|
||||
var pickerPending by remember { mutableStateOf(false) }
|
||||
var pickerError by remember { mutableStateOf<String?>(null) }
|
||||
val addonsUiState by remember {
|
||||
AddonRepository.initialize()
|
||||
AddonRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val libraryUiState by remember {
|
||||
LibraryRepository.ensureLoaded()
|
||||
LibraryRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val traktAuthUiState by remember {
|
||||
TraktAuthRepository.ensureLoaded()
|
||||
TraktAuthRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val authState by AuthRepository.state.collectAsStateWithLifecycle()
|
||||
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
|
||||
val navController = rememberNavController()
|
||||
val appUpdaterController = rememberAppUpdaterController()
|
||||
remember {
|
||||
EpisodeReleaseNotificationsRepository.ensureLoaded()
|
||||
}
|
||||
remember {
|
||||
CollectionSyncService.startObserving()
|
||||
}
|
||||
remember {
|
||||
HomeCatalogSettingsSyncService.startObserving()
|
||||
}
|
||||
remember {
|
||||
ProfileSettingsSync.startObserving()
|
||||
}
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
|
||||
val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle()
|
||||
val liquidGlassNativeTabBarEnabled by remember {
|
||||
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
|
||||
}.collectAsStateWithLifecycle()
|
||||
val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() }
|
||||
var showExitConfirmation by rememberSaveable { mutableStateOf(false) }
|
||||
var selectedPosterForActions by remember { mutableStateOf<MetaPreview?>(null) }
|
||||
var selectedContinueWatchingForActions by remember { mutableStateOf<ContinueWatchingItem?>(null) }
|
||||
var showLibraryListPicker by remember { mutableStateOf(false) }
|
||||
var pickerItem by remember { mutableStateOf<LibraryItem?>(null) }
|
||||
var pickerTitle by remember { mutableStateOf("") }
|
||||
var pickerTabs by remember { mutableStateOf<List<TraktListTab>>(emptyList()) }
|
||||
var pickerMembership by remember { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
|
||||
var pickerPending by remember { mutableStateOf(false) }
|
||||
var pickerError by remember { mutableStateOf<String?>(null) }
|
||||
val addonsUiState by remember {
|
||||
AddonRepository.initialize()
|
||||
AddonRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val libraryUiState by remember {
|
||||
LibraryRepository.ensureLoaded()
|
||||
LibraryRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val authState by AuthRepository.state.collectAsStateWithLifecycle()
|
||||
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
|
||||
val playerSettingsUiState by remember {
|
||||
PlayerSettingsRepository.ensureLoaded()
|
||||
PlayerSettingsRepository.uiState
|
||||
|
|
@ -509,7 +554,7 @@ private fun MainAppContent(
|
|||
NetworkStatusRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val downloadedProviderLabel = stringResource(Res.string.provider_downloaded)
|
||||
val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED
|
||||
val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT
|
||||
var initialHomeReady by rememberSaveable { mutableStateOf(false) }
|
||||
var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) }
|
||||
var networkToastBaselineReady by rememberSaveable { mutableStateOf(false) }
|
||||
|
|
@ -522,6 +567,42 @@ private fun MainAppContent(
|
|||
.sorted()
|
||||
}
|
||||
|
||||
LaunchedEffect(nativeRequestedTab) {
|
||||
if (liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled) {
|
||||
selectedTab = nativeRequestedTab.toAppScreenTab()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedTab) {
|
||||
NativeTabBridge.publishSelectedTab(selectedTab.toNativeNavigationTab())
|
||||
}
|
||||
|
||||
DisposableEffect(
|
||||
navController,
|
||||
liquidGlassNativeTabBarSupported,
|
||||
liquidGlassNativeTabBarEnabled,
|
||||
initialHomeReady,
|
||||
) {
|
||||
fun publishNativeTabVisibilityForCurrentRoute() {
|
||||
val visible = liquidGlassNativeTabBarSupported &&
|
||||
liquidGlassNativeTabBarEnabled &&
|
||||
initialHomeReady &&
|
||||
navController.currentDestination?.hasRoute<TabsRoute>() == true
|
||||
NativeTabBridge.publishTabBarVisible(visible)
|
||||
}
|
||||
|
||||
val destinationChangedListener = NavController.OnDestinationChangedListener { _, _, _ ->
|
||||
publishNativeTabVisibilityForCurrentRoute()
|
||||
}
|
||||
|
||||
publishNativeTabVisibilityForCurrentRoute()
|
||||
navController.addOnDestinationChangedListener(destinationChangedListener)
|
||||
onDispose {
|
||||
navController.removeOnDestinationChangedListener(destinationChangedListener)
|
||||
NativeTabBridge.publishTabBarVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
NetworkStatusRepository.ensureStarted()
|
||||
EpisodeReleaseNotificationsRepository.refreshAsync()
|
||||
|
|
@ -597,7 +678,9 @@ private fun MainAppContent(
|
|||
NetworkCondition.ServersUnreachable,
|
||||
-> {
|
||||
offlineLaunchRouteHandled = true
|
||||
val hasPlayableDownload = downloadsUiState.completedItems.any { it.isPlayable }
|
||||
val hasPlayableDownload = downloadsUiState.completedItems.any {
|
||||
DownloadsRepository.playableLocalFileUri(it) != null
|
||||
}
|
||||
if (hasPlayableDownload) {
|
||||
selectedTab = AppScreenTab.Settings
|
||||
navController.navigate(DownloadsSettingsRoute) {
|
||||
|
|
@ -656,7 +739,66 @@ private fun MainAppContent(
|
|||
AppDeepLinkRepository.markConsumed(deepLink)
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
fun launchPlaybackWithDownloadPreference(
|
||||
type: String,
|
||||
videoId: String,
|
||||
parentMetaId: String,
|
||||
parentMetaType: String,
|
||||
title: String,
|
||||
logo: String?,
|
||||
poster: String?,
|
||||
background: String?,
|
||||
seasonNumber: Int?,
|
||||
episodeNumber: Int?,
|
||||
episodeTitle: String?,
|
||||
episodeThumbnail: String?,
|
||||
pauseDescription: String?,
|
||||
resumePositionMs: Long?,
|
||||
resumeProgressFraction: Float?,
|
||||
manualSelection: Boolean,
|
||||
startFromBeginning: Boolean,
|
||||
) {
|
||||
val targetResumePositionMs = if (startFromBeginning) 0L else (resumePositionMs ?: 0L)
|
||||
val targetResumeProgressFraction = if (startFromBeginning) null else resumeProgressFraction
|
||||
|
||||
if (!manualSelection) {
|
||||
val downloadedItem = DownloadsRepository.findPlayableDownload(
|
||||
parentMetaId = parentMetaId,
|
||||
seasonNumber = seasonNumber,
|
||||
episodeNumber = episodeNumber,
|
||||
videoId = videoId,
|
||||
)
|
||||
val localSourceUrl = downloadedItem?.let(DownloadsRepository::playableLocalFileUri)
|
||||
if (!localSourceUrl.isNullOrBlank()) {
|
||||
val launchId = PlayerLaunchStore.put(
|
||||
PlayerLaunch(
|
||||
title = title,
|
||||
sourceUrl = localSourceUrl,
|
||||
sourceHeaders = emptyMap(),
|
||||
sourceResponseHeaders = emptyMap(),
|
||||
logo = logo,
|
||||
poster = poster,
|
||||
background = background,
|
||||
seasonNumber = seasonNumber,
|
||||
episodeNumber = episodeNumber,
|
||||
episodeTitle = episodeTitle,
|
||||
episodeThumbnail = episodeThumbnail,
|
||||
streamTitle = downloadedItem.streamTitle.ifBlank { title },
|
||||
streamSubtitle = downloadedItem.streamSubtitle,
|
||||
pauseDescription = pauseDescription,
|
||||
providerName = downloadedItem.providerName.ifBlank { downloadedProviderLabel },
|
||||
providerAddonId = downloadedItem.providerAddonId,
|
||||
contentType = type,
|
||||
videoId = videoId,
|
||||
parentMetaId = parentMetaId,
|
||||
parentMetaType = parentMetaType,
|
||||
initialPositionMs = targetResumePositionMs,
|
||||
initialProgressFraction = targetResumeProgressFraction,
|
||||
),
|
||||
)
|
||||
navController.navigate(PlayerRoute(launchId = launchId))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -723,52 +865,22 @@ private fun MainAppContent(
|
|||
}
|
||||
}
|
||||
|
||||
val streamLaunchId = StreamLaunchStore.put(
|
||||
StreamLaunch(
|
||||
type = type,
|
||||
videoId = videoId,
|
||||
parentMetaId = parentMetaId,
|
||||
parentMetaType = parentMetaType,
|
||||
title = title,
|
||||
logo = logo,
|
||||
poster = poster,
|
||||
background = background,
|
||||
seasonNumber = seasonNumber,
|
||||
episodeNumber = episodeNumber,
|
||||
episodeTitle = episodeTitle,
|
||||
episodeThumbnail = episodeThumbnail,
|
||||
pauseDescription = pauseDescription,
|
||||
resumePositionMs = if (startFromBeginning) 0L else resumePositionMs,
|
||||
resumeProgressFraction = targetResumeProgressFraction,
|
||||
manualSelection = manualSelection,
|
||||
startFromBeginning = startFromBeginning,
|
||||
),
|
||||
)
|
||||
navController.navigate(
|
||||
StreamRoute(launchId = streamLaunchId),
|
||||
)
|
||||
}
|
||||
val librarySectionSubtitle = if (libraryUiState.sourceMode == LibrarySourceMode.TRAKT) {
|
||||
stringResource(Res.string.compose_catalog_subtitle_trakt_library)
|
||||
} else {
|
||||
stringResource(Res.string.compose_catalog_subtitle_library)
|
||||
}
|
||||
|
||||
val onPlay: (String, String, String, String, String, String?, String?, String?, Int?, Int?, String?, String?, String?, Long?) -> Unit =
|
||||
{ type, videoId, parentMetaId, parentMetaType, title, logo, poster, background, seasonNumber, episodeNumber, episodeTitle, episodeThumbnail, pauseDescription, resumePositionMs ->
|
||||
launchPlaybackWithDownloadPreference(
|
||||
type = type,
|
||||
videoId = videoId,
|
||||
parentMetaId = parentMetaId,
|
||||
parentMetaType = parentMetaType,
|
||||
title = title,
|
||||
logo = logo,
|
||||
poster = poster,
|
||||
background = background,
|
||||
seasonNumber = seasonNumber,
|
||||
episodeNumber = episodeNumber,
|
||||
episodeTitle = episodeTitle,
|
||||
episodeThumbnail = episodeThumbnail,
|
||||
pauseDescription = pauseDescription,
|
||||
resumePositionMs = resumePositionMs,
|
||||
resumeProgressFraction = null,
|
||||
manualSelection = false,
|
||||
startFromBeginning = false,
|
||||
val onLibrarySectionViewAllClick: (LibrarySection) -> Unit = { section ->
|
||||
navController.navigate(
|
||||
CatalogRoute(
|
||||
title = section.displayTitle,
|
||||
subtitle = librarySectionSubtitle,
|
||||
manifestUrl = INTERNAL_LIBRARY_MANIFEST_URL,
|
||||
type = section.items.firstOrNull()?.type ?: "movie",
|
||||
catalogId = section.type,
|
||||
supportsPagination = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -891,6 +1003,8 @@ private fun MainAppContent(
|
|||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val isTabletLayout = maxWidth >= 768.dp
|
||||
val useNativeBottomTabs =
|
||||
liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady
|
||||
val onProfileSelected: (NuvioProfile) -> Unit = { profile ->
|
||||
profileSwitchLoading = true
|
||||
selectedTab = AppScreenTab.Home
|
||||
|
|
@ -905,7 +1019,7 @@ private fun MainAppContent(
|
|||
containerColor = Color.Transparent,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
bottomBar = {
|
||||
if (!isTabletLayout) {
|
||||
if (!isTabletLayout && !useNativeBottomTabs) {
|
||||
NuvioNavigationBar {
|
||||
NavItem(
|
||||
selected = selectedTab == AppScreenTab.Home,
|
||||
|
|
@ -941,62 +1055,62 @@ private fun MainAppContent(
|
|||
},
|
||||
) { innerPadding ->
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AppTabHost(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
selectedTab = selectedTab,
|
||||
onCatalogClick = onCatalogClick,
|
||||
onPosterClick = { meta ->
|
||||
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
|
||||
},
|
||||
onPosterLongClick = { meta ->
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
selectedPosterForActions = meta
|
||||
},
|
||||
onLibraryPosterClick = { item ->
|
||||
navController.navigate(DetailRoute(type = item.type, id = item.id))
|
||||
},
|
||||
onLibraryPosterLongClick = { item ->
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
selectedPosterForActions = item.toMetaPreview()
|
||||
},
|
||||
onLibrarySectionViewAllClick = onLibrarySectionViewAllClick,
|
||||
onContinueWatchingClick = onContinueWatchingClick,
|
||||
onContinueWatchingLongPress = onContinueWatchingLongPress,
|
||||
onSwitchProfile = onSwitchProfile,
|
||||
onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) },
|
||||
onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) },
|
||||
onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) },
|
||||
onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) },
|
||||
onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) },
|
||||
onPluginsSettingsClick = {
|
||||
if (AppFeaturePolicy.pluginsEnabled) {
|
||||
navController.navigate(PluginsSettingsRoute)
|
||||
}
|
||||
},
|
||||
onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) },
|
||||
onSupportersContributorsSettingsClick = {
|
||||
navController.navigate(SupportersContributorsSettingsRoute)
|
||||
},
|
||||
onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) {
|
||||
{
|
||||
appUpdaterController.checkForUpdates(
|
||||
force = true,
|
||||
showNoUpdateFeedback = true,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) },
|
||||
onFolderClick = { collectionId, folderId ->
|
||||
navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
|
||||
},
|
||||
onInitialHomeContentRendered = { initialHomeReady = true },
|
||||
)
|
||||
CompositionLocalProvider(
|
||||
LocalNuvioBottomNavigationOverlayPadding provides if (useNativeBottomTabs) 49.dp else 0.dp,
|
||||
) {
|
||||
AppTabHost(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
selectedTab = selectedTab,
|
||||
onCatalogClick = onCatalogClick,
|
||||
onPosterClick = { meta ->
|
||||
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
|
||||
},
|
||||
onPosterLongClick = { meta ->
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
selectedPosterForActions = meta
|
||||
},
|
||||
onLibraryPosterClick = { item ->
|
||||
navController.navigate(DetailRoute(type = item.type, id = item.id))
|
||||
},
|
||||
onLibrarySectionViewAllClick = onLibrarySectionViewAllClick,
|
||||
onContinueWatchingClick = onContinueWatchingClick,
|
||||
onContinueWatchingLongPress = onContinueWatchingLongPress,
|
||||
onSwitchProfile = onSwitchProfile,
|
||||
onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) },
|
||||
onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) },
|
||||
onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) },
|
||||
onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) },
|
||||
onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) },
|
||||
onPluginsSettingsClick = {
|
||||
if (AppFeaturePolicy.pluginsEnabled) {
|
||||
navController.navigate(PluginsSettingsRoute)
|
||||
}
|
||||
},
|
||||
onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) },
|
||||
onSupportersContributorsSettingsClick = {
|
||||
navController.navigate(SupportersContributorsSettingsRoute)
|
||||
},
|
||||
onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) {
|
||||
{
|
||||
appUpdaterController.checkForUpdates(
|
||||
force = true,
|
||||
showNoUpdateFeedback = true,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) },
|
||||
onFolderClick = { collectionId, folderId ->
|
||||
navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
|
||||
},
|
||||
onInitialHomeContentRendered = { initialHomeReady = true },
|
||||
)
|
||||
}
|
||||
|
||||
if (isTabletLayout) {
|
||||
if (isTabletLayout && !useNativeBottomTabs) {
|
||||
TabletFloatingTopBar(
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = { selectedTab = it },
|
||||
|
|
@ -1053,11 +1167,11 @@ private fun MainAppContent(
|
|||
castAvatarTransitionKey = avatarTransitionKey,
|
||||
preferCrew = person.role?.let {
|
||||
it.equals("Director", ignoreCase = true) ||
|
||||
it.equals(directorRole, ignoreCase = true) ||
|
||||
it.equals("Writer", ignoreCase = true) ||
|
||||
it.equals(writerRole, ignoreCase = true) ||
|
||||
it.equals("Creator", ignoreCase = true)
|
||||
|| it.equals(creatorRole, ignoreCase = true)
|
||||
it.equals(directorRole, ignoreCase = true) ||
|
||||
it.equals("Writer", ignoreCase = true) ||
|
||||
it.equals(writerRole, ignoreCase = true) ||
|
||||
it.equals("Creator", ignoreCase = true)
|
||||
|| it.equals(creatorRole, ignoreCase = true)
|
||||
} ?: false,
|
||||
),
|
||||
)
|
||||
|
|
@ -1332,6 +1446,7 @@ private fun MainAppContent(
|
|||
)
|
||||
)
|
||||
StreamsRepository.consumeAutoPlay()
|
||||
StreamsRepository.cancelLoading()
|
||||
navController.navigate(PlayerRoute(launchId = launchId)) {
|
||||
popUpTo<StreamRoute> { inclusive = true }
|
||||
}
|
||||
|
|
@ -1410,6 +1525,7 @@ private fun MainAppContent(
|
|||
initialProgressFraction = resolvedResumeProgressFraction,
|
||||
)
|
||||
)
|
||||
StreamsRepository.cancelLoading()
|
||||
navController.navigate(
|
||||
PlayerRoute(launchId = launchId)
|
||||
)
|
||||
|
|
@ -1536,7 +1652,7 @@ private fun MainAppContent(
|
|||
DownloadsScreen(
|
||||
onBack = onBack,
|
||||
onOpenDownload = { item ->
|
||||
val sourceUrl = item.localFileUri ?: return@DownloadsScreen
|
||||
val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return@DownloadsScreen
|
||||
val resumeEntry = item.videoId
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let(WatchProgressRepository::progressForVideo)
|
||||
|
|
@ -1650,38 +1766,41 @@ private fun MainAppContent(
|
|||
}
|
||||
}
|
||||
|
||||
NuvioPosterActionSheet(
|
||||
item = selectedPosterForActions,
|
||||
isSaved = selectedPosterForActions?.let { preview ->
|
||||
LibraryRepository.isSaved(preview.id, preview.type)
|
||||
} == true,
|
||||
isWatched = selectedPosterForActions?.let { preview ->
|
||||
WatchingState.isPosterWatched(
|
||||
watchedKeys = watchedUiState.watchedKeys,
|
||||
item = preview,
|
||||
)
|
||||
} == true,
|
||||
onDismiss = { selectedPosterForActions = null },
|
||||
onToggleLibrary = {
|
||||
selectedPosterForActions?.let { preview ->
|
||||
val libraryItem = preview.toLibraryItem(savedAtEpochMs = 0L)
|
||||
if (!isTraktConnected) {
|
||||
LibraryRepository.toggleSaved(libraryItem)
|
||||
} else {
|
||||
pickerItem = libraryItem
|
||||
pickerTitle = preview.name
|
||||
pickerTabs = LibraryRepository.traktListTabs()
|
||||
pickerMembership = pickerTabs.associate { it.key to false }
|
||||
pickerPending = true
|
||||
pickerError = null
|
||||
showLibraryListPicker = true
|
||||
coroutineScope.launch {
|
||||
runCatching {
|
||||
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
|
||||
val tabs = LibraryRepository.traktListTabs()
|
||||
pickerTabs = tabs
|
||||
pickerMembership = tabs.associate { tab ->
|
||||
tab.key to (snapshot[tab.key] == true)
|
||||
NuvioPosterActionSheet(
|
||||
item = selectedPosterForActions,
|
||||
isSaved = selectedPosterForActions?.let { preview ->
|
||||
LibraryRepository.isSaved(preview.id, preview.type)
|
||||
} == true,
|
||||
isWatched = selectedPosterForActions?.let { preview ->
|
||||
WatchingState.isPosterWatched(
|
||||
watchedKeys = watchedUiState.watchedKeys,
|
||||
item = preview,
|
||||
)
|
||||
} == true,
|
||||
onDismiss = { selectedPosterForActions = null },
|
||||
onToggleLibrary = {
|
||||
selectedPosterForActions?.let { preview ->
|
||||
val libraryItem = preview.toLibraryItem(savedAtEpochMs = 0L)
|
||||
if (!isTraktLibrarySource) {
|
||||
LibraryRepository.toggleSaved(libraryItem)
|
||||
} else {
|
||||
pickerItem = libraryItem
|
||||
pickerTitle = preview.name
|
||||
pickerTabs = LibraryRepository.libraryListTabs()
|
||||
pickerMembership = pickerTabs.associate { it.key to false }
|
||||
pickerPending = true
|
||||
pickerError = null
|
||||
showLibraryListPicker = true
|
||||
coroutineScope.launch {
|
||||
runCatching {
|
||||
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
|
||||
val tabs = LibraryRepository.libraryListTabs()
|
||||
pickerTabs = tabs
|
||||
pickerMembership = tabs.associate { tab ->
|
||||
tab.key to (snapshot[tab.key] == true)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed)
|
||||
|
|
@ -1772,25 +1891,43 @@ private fun MainAppContent(
|
|||
}.onFailure { error ->
|
||||
pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed)
|
||||
}
|
||||
pickerPending = false
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
onSave = {
|
||||
val item = pickerItem ?: return@TraktListPickerDialog
|
||||
coroutineScope.launch {
|
||||
pickerPending = true
|
||||
pickerError = null
|
||||
runCatching {
|
||||
LibraryRepository.applyMembershipChanges(
|
||||
item = item,
|
||||
desiredMembership = pickerMembership,
|
||||
)
|
||||
}.onSuccess {
|
||||
showLibraryListPicker = false
|
||||
pickerItem = null
|
||||
pickerError = null
|
||||
}.onFailure { error ->
|
||||
pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed)
|
||||
}
|
||||
pickerPending = false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
NuvioStatusModal(
|
||||
title = stringResource(Res.string.app_exit_title),
|
||||
message = stringResource(Res.string.app_exit_message),
|
||||
isVisible = showExitConfirmation,
|
||||
confirmText = stringResource(Res.string.action_yes),
|
||||
dismissText = stringResource(Res.string.action_no),
|
||||
onConfirm = {
|
||||
showExitConfirmation = false
|
||||
platformExitApp()
|
||||
},
|
||||
onDismiss = {
|
||||
showExitConfirmation = false
|
||||
},
|
||||
)
|
||||
NuvioStatusModal(
|
||||
title = stringResource(Res.string.app_exit_title),
|
||||
message = stringResource(Res.string.app_exit_message),
|
||||
isVisible = showExitConfirmation,
|
||||
confirmText = stringResource(Res.string.action_yes),
|
||||
dismissText = stringResource(Res.string.action_no),
|
||||
onConfirm = {
|
||||
showExitConfirmation = false
|
||||
platformExitApp()
|
||||
},
|
||||
onDismiss = {
|
||||
showExitConfirmation = false
|
||||
},
|
||||
)
|
||||
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = !initialHomeReady || profileSwitchLoading,
|
||||
|
|
@ -1809,23 +1946,23 @@ private fun MainAppContent(
|
|||
}
|
||||
}
|
||||
|
||||
NuvioFloatingPrompt(
|
||||
visible = resumePromptItem != null,
|
||||
imageUrl = resumePromptItem?.poster ?: resumePromptItem?.imageUrl,
|
||||
title = resumePromptItem?.title.orEmpty(),
|
||||
subtitle = resumePromptItem?.let { localizedContinueWatchingSubtitle(it) }.orEmpty(),
|
||||
progressFraction = resumePromptItem?.progressFraction ?: 0f,
|
||||
actionLabel = stringResource(Res.string.resume_prompt_action),
|
||||
onAction = {
|
||||
val item = resumePromptItem ?: return@NuvioFloatingPrompt
|
||||
resumePromptItem = null
|
||||
openContinueWatching(item, false, false)
|
||||
},
|
||||
onDismiss = { resumePromptItem = null },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.zIndex(15f),
|
||||
)
|
||||
NuvioFloatingPrompt(
|
||||
visible = resumePromptItem != null,
|
||||
imageUrl = resumePromptItem?.poster ?: resumePromptItem?.imageUrl,
|
||||
title = resumePromptItem?.title.orEmpty(),
|
||||
subtitle = resumePromptItem?.let { localizedContinueWatchingSubtitle(it) }.orEmpty(),
|
||||
progressFraction = resumePromptItem?.progressFraction ?: 0f,
|
||||
actionLabel = stringResource(Res.string.resume_prompt_action),
|
||||
onAction = {
|
||||
val item = resumePromptItem ?: return@NuvioFloatingPrompt
|
||||
resumePromptItem = null
|
||||
openContinueWatching(item, false, false)
|
||||
},
|
||||
onDismiss = { resumePromptItem = null },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.zIndex(15f),
|
||||
)
|
||||
|
||||
NuvioToastHost(
|
||||
modifier = Modifier
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
object AuthRepository {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
|
@ -89,7 +91,7 @@ object AuthRepository {
|
|||
Unit
|
||||
}.onFailure { e ->
|
||||
log.e(e) { "Email sign-up failed" }
|
||||
_error.value = e.message ?: "Sign-up failed"
|
||||
_error.value = e.message ?: getString(Res.string.auth_sign_up_failed)
|
||||
}
|
||||
|
||||
suspend fun signInWithEmail(email: String, password: String): Result<Unit> = runCatching {
|
||||
|
|
@ -100,7 +102,7 @@ object AuthRepository {
|
|||
}
|
||||
}.onFailure { e ->
|
||||
log.e(e) { "Email sign-in failed" }
|
||||
_error.value = e.message ?: "Sign-in failed"
|
||||
_error.value = e.message ?: getString(Res.string.auth_sign_in_failed)
|
||||
}
|
||||
|
||||
suspend fun signOut(): Result<Unit> = runCatching {
|
||||
|
|
@ -114,7 +116,7 @@ object AuthRepository {
|
|||
LocalAccountDataCleaner.wipe()
|
||||
}.onFailure { e ->
|
||||
log.e(e) { "Sign-out failed" }
|
||||
_error.value = e.message ?: "Sign-out failed"
|
||||
_error.value = e.message ?: getString(Res.string.auth_sign_out_failed)
|
||||
}
|
||||
|
||||
suspend fun deleteAccount(): Result<Unit> = runCatching {
|
||||
|
|
@ -124,7 +126,7 @@ object AuthRepository {
|
|||
LocalAccountDataCleaner.wipe()
|
||||
}.onFailure { e ->
|
||||
log.e(e) { "Account deletion failed" }
|
||||
_error.value = e.message ?: "Account deletion failed"
|
||||
_error.value = e.message ?: getString(Res.string.auth_account_deletion_failed)
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,6 @@
|
|||
package com.nuvio.app.core.format
|
||||
|
||||
private val MONTH_NAMES = listOf(
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
)
|
||||
import com.nuvio.app.core.i18n.localizedMonthName
|
||||
|
||||
/**
|
||||
* Formats ISO calendar dates (yyyy-MM-dd or yyyy-MM-ddTHH:mm:ss…) for UI as "2025 February 1".
|
||||
|
|
@ -28,7 +15,7 @@ fun formatReleaseDateForDisplay(raw: String): String {
|
|||
val year = parts[0].toIntOrNull() ?: return raw
|
||||
val month = parts[1].toIntOrNull()?.takeIf { it in 1..12 } ?: return raw
|
||||
val day = parts[2].toIntOrNull()?.takeIf { it in 1..31 } ?: return raw
|
||||
return "$year ${MONTH_NAMES[month - 1]} $day"
|
||||
return "$year ${localizedMonthName(month)} $day"
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -21,6 +21,7 @@ import com.nuvio.app.features.streams.StreamContextStore
|
|||
import com.nuvio.app.features.streams.StreamLaunchStore
|
||||
import com.nuvio.app.features.streams.StreamsRepository
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||
import com.nuvio.app.core.ui.PosterCardStyleRepository
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||
|
|
@ -47,6 +48,7 @@ internal object LocalAccountDataCleaner {
|
|||
ThemeSettingsRepository.clearLocalState()
|
||||
PosterCardStyleRepository.clearLocalState()
|
||||
TraktAuthRepository.clearLocalState()
|
||||
TraktSettingsRepository.clearLocalState()
|
||||
PlayerSettingsRepository.clearLocalState()
|
||||
CatalogRepository.clear()
|
||||
StreamsRepository.clear()
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import com.nuvio.app.features.tmdb.TmdbSettingsStorage
|
|||
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
||||
import com.nuvio.app.features.trakt.TraktCommentsStorage
|
||||
import com.nuvio.app.features.trakt.TraktCommentsSettings
|
||||
import com.nuvio.app.features.trakt.TraktSettingsStorage
|
||||
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||
import io.github.jan.supabase.postgrest.postgrest
|
||||
|
|
@ -150,12 +152,14 @@ object ProfileSettingsSync {
|
|||
val signatureFlows = listOf(
|
||||
ThemeSettingsRepository.selectedTheme.map { "theme" },
|
||||
ThemeSettingsRepository.amoledEnabled.map { "amoled" },
|
||||
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" },
|
||||
PosterCardStyleRepository.uiState.map { "poster_card_style" },
|
||||
PlayerSettingsRepository.uiState.map { "player" },
|
||||
TmdbSettingsRepository.uiState.map { "tmdb" },
|
||||
MdbListSettingsRepository.uiState.map { "mdblist" },
|
||||
MetaScreenSettingsRepository.uiState.map { "meta" },
|
||||
ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" },
|
||||
TraktSettingsRepository.uiState.map { "trakt_settings" },
|
||||
TraktCommentsSettings.enabled.map { "trakt_comments" },
|
||||
EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" },
|
||||
)
|
||||
|
|
@ -199,6 +203,7 @@ object ProfileSettingsSync {
|
|||
mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(),
|
||||
metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(),
|
||||
continueWatchingSettingsPayload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim(),
|
||||
traktSettingsPayload = TraktSettingsStorage.loadPayload().orEmpty().trim(),
|
||||
traktCommentsSettings = TraktCommentsStorage.exportToSyncPayload(),
|
||||
notificationsSettings = NotificationsSettingsPayload(
|
||||
episodeReleaseAlertsEnabled = EpisodeReleaseNotificationsRepository.uiState.value.isEnabled,
|
||||
|
|
@ -230,6 +235,9 @@ object ProfileSettingsSync {
|
|||
ContinueWatchingPreferencesStorage.savePayload(blob.features.continueWatchingSettingsPayload)
|
||||
ContinueWatchingPreferencesRepository.onProfileChanged()
|
||||
|
||||
TraktSettingsStorage.savePayload(blob.features.traktSettingsPayload)
|
||||
TraktSettingsRepository.onProfileChanged()
|
||||
|
||||
TraktCommentsStorage.replaceFromSyncPayload(blob.features.traktCommentsSettings)
|
||||
TraktCommentsSettings.onProfileChanged()
|
||||
|
||||
|
|
@ -244,6 +252,7 @@ object ProfileSettingsSync {
|
|||
MdbListSettingsRepository.ensureLoaded()
|
||||
MetaScreenSettingsRepository.ensureLoaded()
|
||||
ContinueWatchingPreferencesRepository.ensureLoaded()
|
||||
TraktSettingsRepository.ensureLoaded()
|
||||
TraktCommentsSettings.ensureLoaded()
|
||||
EpisodeReleaseNotificationsRepository.ensureLoaded()
|
||||
}
|
||||
|
|
@ -257,12 +266,14 @@ object ProfileSettingsSync {
|
|||
private fun currentObservedStateSignature(): String = listOf(
|
||||
"theme=${ThemeSettingsRepository.selectedTheme.value.name}",
|
||||
"amoled=${ThemeSettingsRepository.amoledEnabled.value}",
|
||||
"liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}",
|
||||
"poster_card_style=${PosterCardStyleRepository.uiState.value}",
|
||||
"player=${PlayerSettingsRepository.uiState.value}",
|
||||
"tmdb=${TmdbSettingsRepository.uiState.value}",
|
||||
"mdblist=${MdbListSettingsRepository.uiState.value}",
|
||||
"meta=${MetaScreenSettingsRepository.uiState.value}",
|
||||
"continue=${ContinueWatchingPreferencesRepository.uiState.value}",
|
||||
"trakt_settings=${TraktSettingsRepository.uiState.value}",
|
||||
"trakt_comments=${TraktCommentsSettings.enabled.value}",
|
||||
"episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}",
|
||||
).joinToString(separator = "||")
|
||||
|
|
@ -283,6 +294,7 @@ private data class MobileProfileSettingsFeatures(
|
|||
@SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()),
|
||||
@SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "",
|
||||
@SerialName("continue_watching_settings_payload") val continueWatchingSettingsPayload: String = "",
|
||||
@SerialName("trakt_settings_payload") val traktSettingsPayload: String = "",
|
||||
@SerialName("trakt_comments_settings") val traktCommentsSettings: JsonObject = JsonObject(emptyMap()),
|
||||
@SerialName("notifications_settings") val notificationsSettings: NotificationsSettingsPayload = NotificationsSettingsPayload(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,32 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
enum class AppTheme(val displayName: String) {
|
||||
CRIMSON("Crimson"),
|
||||
OCEAN("Ocean"),
|
||||
VIOLET("Violet"),
|
||||
EMERALD("Emerald"),
|
||||
AMBER("Amber"),
|
||||
ROSE("Rose"),
|
||||
WHITE("White"),
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.theme_amber
|
||||
import nuvio.composeapp.generated.resources.theme_crimson
|
||||
import nuvio.composeapp.generated.resources.theme_emerald
|
||||
import nuvio.composeapp.generated.resources.theme_ocean
|
||||
import nuvio.composeapp.generated.resources.theme_rose
|
||||
import nuvio.composeapp.generated.resources.theme_violet
|
||||
import nuvio.composeapp.generated.resources.theme_white
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
|
||||
enum class AppTheme {
|
||||
CRIMSON,
|
||||
OCEAN,
|
||||
VIOLET,
|
||||
EMERALD,
|
||||
AMBER,
|
||||
ROSE,
|
||||
WHITE,
|
||||
}
|
||||
|
||||
val AppTheme.labelRes: StringResource
|
||||
get() = when (this) {
|
||||
AppTheme.CRIMSON -> Res.string.theme_crimson
|
||||
AppTheme.OCEAN -> Res.string.theme_ocean
|
||||
AppTheme.VIOLET -> Res.string.theme_violet
|
||||
AppTheme.EMERALD -> Res.string.theme_emerald
|
||||
AppTheme.AMBER -> Res.string.theme_amber
|
||||
AppTheme.ROSE -> Res.string.theme_rose
|
||||
AppTheme.WHITE -> Res.string.theme_white
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.action_back
|
||||
import nuvio.composeapp.generated.resources.action_ok
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
|
@ -141,7 +145,7 @@ fun NuvioScreenHeader(
|
|||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
contentDescription = stringResource(Res.string.action_back),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
|
|
@ -233,7 +237,7 @@ fun NuvioBackButton(
|
|||
contentColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||
buttonSize: Dp = 40.dp,
|
||||
iconSize: Dp = 22.dp,
|
||||
contentDescription: String = "Back",
|
||||
contentDescription: String = stringResource(Res.string.action_back),
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
|
|
@ -375,7 +379,7 @@ fun NuvioStatusModal(
|
|||
modifier: Modifier = Modifier,
|
||||
isVisible: Boolean,
|
||||
isBusy: Boolean = false,
|
||||
confirmText: String = "OK",
|
||||
confirmText: String = stringResource(Res.string.action_ok),
|
||||
dismissText: String? = null,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ import androidx.compose.ui.unit.dp
|
|||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.cw_action_go_to_details
|
||||
import nuvio.composeapp.generated.resources.cw_action_remove
|
||||
import nuvio.composeapp.generated.resources.cw_action_start_from_beginning
|
||||
import nuvio.composeapp.generated.resources.play_manually
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
@ -70,14 +76,14 @@ fun NuvioContinueWatchingActionSheet(
|
|||
NuvioBottomSheetDivider()
|
||||
NuvioBottomSheetActionRow(
|
||||
icon = Icons.Default.Info,
|
||||
title = "Go to details",
|
||||
title = stringResource(Res.string.cw_action_go_to_details),
|
||||
onClick = { dismissAfter(onOpenDetails) },
|
||||
)
|
||||
if (showManualPlayOption && onPlayManually != null) {
|
||||
NuvioBottomSheetDivider()
|
||||
NuvioBottomSheetActionRow(
|
||||
icon = Icons.Default.PlayArrow,
|
||||
title = "Play manually",
|
||||
title = stringResource(Res.string.play_manually),
|
||||
onClick = { dismissAfter(onPlayManually) },
|
||||
)
|
||||
}
|
||||
|
|
@ -85,14 +91,14 @@ fun NuvioContinueWatchingActionSheet(
|
|||
NuvioBottomSheetDivider()
|
||||
NuvioBottomSheetActionRow(
|
||||
icon = Icons.Default.Replay,
|
||||
title = "Start from beginning",
|
||||
title = stringResource(Res.string.cw_action_start_from_beginning),
|
||||
onClick = { dismissAfter(onStartFromBeginning) },
|
||||
)
|
||||
}
|
||||
NuvioBottomSheetDivider()
|
||||
NuvioBottomSheetActionRow(
|
||||
icon = Icons.Default.DeleteOutline,
|
||||
title = "Remove",
|
||||
title = stringResource(Res.string.cw_action_remove),
|
||||
onClick = { dismissAfter(onRemove) },
|
||||
)
|
||||
}
|
||||
|
|
@ -152,7 +158,7 @@ private fun ContinueWatchingSheetHeader(
|
|||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = item.subtitle,
|
||||
text = localizedContinueWatchingSubtitle(item),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
|
|
@ -160,4 +166,4 @@ private fun ContinueWatchingSheetHeader(
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ import androidx.compose.ui.unit.dp
|
|||
import coil3.compose.AsyncImage
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.floating_prompt_continue_where_left_off
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val AutoDismissDelayMs = 15_000L
|
||||
|
|
@ -202,7 +205,7 @@ fun NuvioFloatingPrompt(
|
|||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Continue where you left off",
|
||||
text = stringResource(Res.string.floating_prompt_continue_where_left_off),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ import androidx.compose.ui.unit.dp
|
|||
import com.nuvio.app.core.network.NetworkCondition
|
||||
import com.nuvio.app.core.network.messageForEmptyState
|
||||
import com.nuvio.app.core.network.titleForEmptyState
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.action_retry
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun NuvioNetworkOfflineCard(
|
||||
|
|
@ -32,9 +35,9 @@ fun NuvioNetworkOfflineCard(
|
|||
if (onRetry != null) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
NuvioPrimaryButton(
|
||||
text = "Retry",
|
||||
text = stringResource(Res.string.action_retry),
|
||||
onClick = onRetry,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.core.ui
|
|||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
|
|
@ -12,10 +13,14 @@ internal expect val nuvioBottomNavigationExtraVerticalPadding: Dp
|
|||
@Composable
|
||||
internal expect fun nuvioBottomNavigationBarInsets(): WindowInsets
|
||||
|
||||
internal val LocalNuvioBottomNavigationOverlayPadding = staticCompositionLocalOf { 0.dp }
|
||||
|
||||
@Composable
|
||||
internal fun nuvioSafeBottomPadding(extra: Dp = 0.dp): Dp {
|
||||
val navigationBarBottom = nuvioBottomNavigationBarInsets()
|
||||
.asPaddingValues()
|
||||
.calculateBottomPadding()
|
||||
return navigationBarBottom.coerceAtLeast(nuvioPlatformExtraBottomPadding) + extra
|
||||
return navigationBarBottom.coerceAtLeast(nuvioPlatformExtraBottomPadding) +
|
||||
LocalNuvioBottomNavigationOverlayPadding.current +
|
||||
extra
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,13 @@ import coil3.compose.AsyncImage
|
|||
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.episodes_cd_watched
|
||||
import nuvio.composeapp.generated.resources.hero_add_to_library
|
||||
import nuvio.composeapp.generated.resources.hero_mark_unwatched
|
||||
import nuvio.composeapp.generated.resources.hero_mark_watched
|
||||
import nuvio.composeapp.generated.resources.hero_remove_from_library
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
@ -72,7 +79,11 @@ fun NuvioPosterActionSheet(
|
|||
NuvioBottomSheetDivider()
|
||||
NuvioBottomSheetActionRow(
|
||||
icon = if (isSaved) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
|
||||
title = if (isSaved) "Remove from Library" else "Add to Library",
|
||||
title = if (isSaved) {
|
||||
stringResource(Res.string.hero_remove_from_library)
|
||||
} else {
|
||||
stringResource(Res.string.hero_add_to_library)
|
||||
},
|
||||
onClick = {
|
||||
onToggleLibrary()
|
||||
coroutineScope.launch {
|
||||
|
|
@ -86,7 +97,11 @@ fun NuvioPosterActionSheet(
|
|||
NuvioBottomSheetDivider()
|
||||
NuvioBottomSheetActionRow(
|
||||
icon = if (isWatched) Icons.Default.CheckCircle else Icons.Default.CheckCircleOutline,
|
||||
title = if (isWatched) "Mark as Unwatched" else "Mark as Watched",
|
||||
title = if (isWatched) {
|
||||
stringResource(Res.string.hero_mark_unwatched)
|
||||
} else {
|
||||
stringResource(Res.string.hero_mark_watched)
|
||||
},
|
||||
onClick = {
|
||||
onToggleWatched()
|
||||
coroutineScope.launch {
|
||||
|
|
@ -114,7 +129,7 @@ fun NuvioWatchedBadge(
|
|||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "Watched",
|
||||
contentDescription = stringResource(Res.string.episodes_cd_watched),
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(12.dp),
|
||||
)
|
||||
|
|
@ -200,4 +215,3 @@ private fun PosterSheetHeader(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.home_view_all
|
||||
import nuvio.composeapp.generated.resources.poster_logo_content_description
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
enum class NuvioPosterShape {
|
||||
Poster,
|
||||
|
|
@ -78,10 +82,10 @@ fun <T> NuvioShelfSection(
|
|||
) {
|
||||
if (key != null) {
|
||||
items(
|
||||
items = entries,
|
||||
key = key,
|
||||
) { entry ->
|
||||
itemContent(entry)
|
||||
items = entries.withDuplicateSafeLazyKeys(key),
|
||||
key = { entry -> entry.lazyKey },
|
||||
) { keyedEntry ->
|
||||
itemContent(keyedEntry.value)
|
||||
}
|
||||
} else {
|
||||
items(entries) { entry ->
|
||||
|
|
@ -156,7 +160,7 @@ fun NuvioPosterCard(
|
|||
if (!bottomLeftLogoUrl.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = bottomLeftLogoUrl,
|
||||
contentDescription = "$title logo",
|
||||
contentDescription = stringResource(Res.string.poster_logo_content_description, title),
|
||||
modifier = Modifier
|
||||
.width(catalogLogoOverlaySize.width)
|
||||
.height(catalogLogoOverlaySize.height),
|
||||
|
|
@ -280,7 +284,7 @@ private fun NuvioViewAllPill(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "View All",
|
||||
text = stringResource(Res.string.home_view_all),
|
||||
style = textStyle,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.features.trakt.TraktListTab
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.action_cancel
|
||||
import nuvio.composeapp.generated.resources.action_save
|
||||
import nuvio.composeapp.generated.resources.compose_trakt_list_picker_loading
|
||||
import nuvio.composeapp.generated.resources.compose_trakt_list_picker_subtitle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
@ -62,7 +68,7 @@ fun TraktListPickerDialog(
|
|||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = "Choose where to save this title on Trakt",
|
||||
text = stringResource(Res.string.compose_trakt_list_picker_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -91,7 +97,7 @@ fun TraktListPickerDialog(
|
|||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Text(
|
||||
text = "Loading your Trakt lists…",
|
||||
text = stringResource(Res.string.compose_trakt_list_picker_loading),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -151,7 +157,7 @@ fun TraktListPickerDialog(
|
|||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) {
|
||||
Text("Cancel")
|
||||
Text(stringResource(Res.string.action_cancel))
|
||||
}
|
||||
Button(
|
||||
onClick = onSave,
|
||||
|
|
@ -164,11 +170,11 @@ fun TraktListPickerDialog(
|
|||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
} else {
|
||||
Text("Save")
|
||||
Text(stringResource(Res.string.action_save))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
package com.nuvio.app.features.addons
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.generic_addon
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
data class AddonManifest(
|
||||
val id: String,
|
||||
val name: String,
|
||||
|
|
@ -54,7 +59,9 @@ data class ManagedAddon(
|
|||
val displayTitle: String
|
||||
get() = userSetName?.takeIf { it.isNotBlank() && it != manifest?.name }
|
||||
?: manifest?.name
|
||||
?: manifestUrl.substringBefore("?").substringAfterLast("/").ifBlank { "Addon" }
|
||||
?: manifestUrl.substringBefore("?").substringAfterLast("/").ifBlank {
|
||||
runBlocking { getString(Res.string.generic_addon) }
|
||||
}
|
||||
}
|
||||
|
||||
data class AddonsUiState(
|
||||
|
|
|
|||
|
|
@ -33,4 +33,5 @@ expect suspend fun httpRequestRaw(
|
|||
url: String,
|
||||
headers: Map<String, String>,
|
||||
body: String,
|
||||
followRedirects: Boolean = true,
|
||||
): RawHttpResponse
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import kotlinx.serialization.json.Json
|
|||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import kotlinx.serialization.json.put
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
@Serializable
|
||||
private data class AddonRow(
|
||||
|
|
@ -198,17 +200,17 @@ object AddonRepository {
|
|||
|
||||
suspend fun addAddon(rawUrl: String): AddAddonResult {
|
||||
if (isUsingPrimaryAddonsFromSecondaryProfile()) {
|
||||
return AddAddonResult.Error("This profile uses primary addons.")
|
||||
return AddAddonResult.Error(getString(Res.string.profile_primary_addons_required))
|
||||
}
|
||||
log.i { "addAddon() — rawUrl=$rawUrl" }
|
||||
val manifestUrl = try {
|
||||
normalizeManifestUrl(rawUrl)
|
||||
} catch (error: IllegalArgumentException) {
|
||||
return AddAddonResult.Error(error.message ?: "Enter a valid addon URL")
|
||||
return AddAddonResult.Error(error.message ?: getString(Res.string.addon_invalid_url))
|
||||
}
|
||||
|
||||
if (_uiState.value.addons.any { it.manifestUrl == manifestUrl }) {
|
||||
return AddAddonResult.Error("That addon is already installed.")
|
||||
return AddAddonResult.Error(getString(Res.string.addon_already_installed))
|
||||
}
|
||||
|
||||
val manifest = try {
|
||||
|
|
@ -220,7 +222,7 @@ object AddonRepository {
|
|||
)
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
return AddAddonResult.Error(error.message ?: "Unable to load manifest")
|
||||
return AddAddonResult.Error(error.message ?: getString(Res.string.addon_load_manifest_failed))
|
||||
}
|
||||
|
||||
_uiState.update { current ->
|
||||
|
|
@ -250,6 +252,27 @@ object AddonRepository {
|
|||
pushToServer()
|
||||
}
|
||||
|
||||
fun moveAddon(fromIndex: Int, toIndex: Int) {
|
||||
if (isUsingPrimaryAddonsFromSecondaryProfile()) return
|
||||
_uiState.update { current ->
|
||||
val addons = current.addons
|
||||
if (
|
||||
fromIndex !in addons.indices ||
|
||||
toIndex !in addons.indices ||
|
||||
fromIndex == toIndex
|
||||
) {
|
||||
return@update current
|
||||
}
|
||||
|
||||
val reordered = addons.toMutableList()
|
||||
val movingAddon = reordered.removeAt(fromIndex)
|
||||
reordered.add(toIndex, movingAddon)
|
||||
current.copy(addons = reordered)
|
||||
}
|
||||
persist()
|
||||
pushToServer()
|
||||
}
|
||||
|
||||
fun refreshAll() {
|
||||
_uiState.value.addons.distinctBy { it.manifestUrl }.forEach { addon ->
|
||||
refreshAddon(addon.manifestUrl)
|
||||
|
|
@ -289,7 +312,7 @@ object AddonRepository {
|
|||
onFailure = { error ->
|
||||
addon.copy(
|
||||
isRefreshing = false,
|
||||
errorMessage = error.message ?: "Unable to load manifest",
|
||||
errorMessage = error.message ?: getString(Res.string.addon_load_manifest_failed),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,12 +12,14 @@ import androidx.compose.foundation.layout.height
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.ArrowDownward
|
||||
import androidx.compose.material.icons.rounded.ArrowUpward
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.Extension
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
|
@ -36,6 +38,7 @@ import androidx.compose.ui.draw.clip
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
|
|
@ -50,17 +53,19 @@ import com.nuvio.app.core.ui.NuvioSectionLabel
|
|||
import com.nuvio.app.core.ui.NuvioStatusModal
|
||||
import com.nuvio.app.core.ui.NuvioSurfaceCard
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun AddonsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String = "Addons",
|
||||
title: String? = null,
|
||||
onBack: (() -> Unit)? = null,
|
||||
) {
|
||||
NuvioScreen(modifier = modifier) {
|
||||
stickyHeader {
|
||||
NuvioScreenHeader(
|
||||
title = title,
|
||||
title = title ?: stringResource(Res.string.addon_title),
|
||||
onBack = onBack,
|
||||
) {
|
||||
}
|
||||
|
|
@ -80,10 +85,12 @@ internal fun AddonsSettingsPageContent(
|
|||
}
|
||||
|
||||
val uiState by AddonRepository.uiState.collectAsStateWithLifecycle()
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var addonUrl by rememberSaveable { mutableStateOf("") }
|
||||
var formMessage by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var installModalState by remember { mutableStateOf<AddonInstallModalState?>(null) }
|
||||
val enterAddonUrlMessage = stringResource(Res.string.addons_error_enter_url)
|
||||
|
||||
val overview = remember(uiState.addons) { uiState.addons.toOverview() }
|
||||
|
||||
|
|
@ -91,10 +98,10 @@ internal fun AddonsSettingsPageContent(
|
|||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
SectionHeader("OVERVIEW")
|
||||
SectionHeader(stringResource(Res.string.addons_section_overview))
|
||||
OverviewCard(overview = overview)
|
||||
|
||||
SectionHeader("ADD ADDON")
|
||||
SectionHeader(stringResource(Res.string.addons_section_add_addon))
|
||||
AddAddonCard(
|
||||
addonUrl = addonUrl,
|
||||
formMessage = formMessage,
|
||||
|
|
@ -105,7 +112,7 @@ internal fun AddonsSettingsPageContent(
|
|||
onAddClick = {
|
||||
val requestedUrl = addonUrl.trim()
|
||||
if (requestedUrl.isBlank()) {
|
||||
formMessage = "Enter an addon URL."
|
||||
formMessage = enterAddonUrlMessage
|
||||
return@AddAddonCard
|
||||
}
|
||||
|
||||
|
|
@ -127,14 +134,38 @@ internal fun AddonsSettingsPageContent(
|
|||
},
|
||||
)
|
||||
|
||||
SectionHeader("INSTALLED ADDONS")
|
||||
SectionHeader(stringResource(Res.string.addons_section_installed))
|
||||
if (uiState.addons.isEmpty()) {
|
||||
EmptyStateCard()
|
||||
} else {
|
||||
uiState.addons.forEach { addon ->
|
||||
val lastIndex = uiState.addons.lastIndex
|
||||
uiState.addons.forEachIndexed { index, addon ->
|
||||
val manifest = addon.manifest
|
||||
val behaviorHints = manifest?.behaviorHints
|
||||
val showConfigureAction = behaviorHints?.configurable == true || behaviorHints?.configurationRequired == true
|
||||
val configureUrl = addon.manifestUrl.toConfigureUrl()
|
||||
InstalledAddonCard(
|
||||
addon = addon,
|
||||
onMoveUpClick = if (index > 0) {
|
||||
{ AddonRepository.moveAddon(index, index - 1) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onMoveDownClick = if (index < lastIndex) {
|
||||
{ AddonRepository.moveAddon(index, index + 1) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onRefreshClick = { AddonRepository.refreshAddon(addon.manifestUrl) },
|
||||
onConfigureClick = if (showConfigureAction && !configureUrl.isNullOrBlank()) {
|
||||
{
|
||||
runCatching {
|
||||
uriHandler.openUri(configureUrl)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onDeleteClick = { AddonRepository.removeAddon(addon.manifestUrl) },
|
||||
)
|
||||
}
|
||||
|
|
@ -143,12 +174,30 @@ internal fun AddonsSettingsPageContent(
|
|||
|
||||
val modalState = installModalState
|
||||
if (modalState != null) {
|
||||
val modalTitle = when (modalState) {
|
||||
AddonInstallModalState.Checking -> stringResource(Res.string.addons_modal_checking_title)
|
||||
is AddonInstallModalState.Success -> stringResource(Res.string.addons_modal_success_title)
|
||||
is AddonInstallModalState.Error -> stringResource(Res.string.addons_modal_failure_title)
|
||||
}
|
||||
val modalMessage = when (modalState) {
|
||||
AddonInstallModalState.Checking -> stringResource(Res.string.addons_modal_checking_message)
|
||||
is AddonInstallModalState.Success -> stringResource(
|
||||
Res.string.addons_modal_success_message,
|
||||
modalState.addonName,
|
||||
)
|
||||
is AddonInstallModalState.Error -> modalState.reason
|
||||
}
|
||||
val modalConfirmText = when (modalState) {
|
||||
AddonInstallModalState.Checking -> stringResource(Res.string.addon_installing)
|
||||
is AddonInstallModalState.Success -> stringResource(Res.string.action_done)
|
||||
is AddonInstallModalState.Error -> stringResource(Res.string.action_close)
|
||||
}
|
||||
NuvioStatusModal(
|
||||
title = modalState.title,
|
||||
message = modalState.message,
|
||||
title = modalTitle,
|
||||
message = modalMessage,
|
||||
isVisible = true,
|
||||
isBusy = modalState.isBusy,
|
||||
confirmText = modalState.confirmText,
|
||||
confirmText = modalConfirmText,
|
||||
onConfirm = {
|
||||
if (!modalState.isBusy) {
|
||||
installModalState = null
|
||||
|
|
@ -172,19 +221,19 @@ private fun OverviewCard(overview: AddonOverview) {
|
|||
) {
|
||||
OverviewStat(
|
||||
value = overview.totalAddons.toString(),
|
||||
label = "Addons",
|
||||
label = stringResource(Res.string.addons_overview_addons),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
VerticalSeparator()
|
||||
OverviewStat(
|
||||
value = overview.activeAddons.toString(),
|
||||
label = "Active",
|
||||
label = stringResource(Res.string.addons_overview_active),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
VerticalSeparator()
|
||||
OverviewStat(
|
||||
value = overview.totalCatalogs.toString(),
|
||||
label = "Catalogs",
|
||||
label = stringResource(Res.string.addons_overview_catalogs),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
|
@ -236,11 +285,11 @@ private fun AddAddonCard(
|
|||
NuvioInputField(
|
||||
value = addonUrl,
|
||||
onValueChange = onAddonUrlChange,
|
||||
placeholder = "Addon URL",
|
||||
placeholder = stringResource(Res.string.addons_input_placeholder),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
NuvioPrimaryButton(
|
||||
text = "Install Addon",
|
||||
text = stringResource(Res.string.addons_install_button),
|
||||
enabled = addonUrl.isNotBlank(),
|
||||
onClick = onAddClick,
|
||||
)
|
||||
|
|
@ -256,33 +305,21 @@ private fun AddAddonCard(
|
|||
}
|
||||
|
||||
private sealed interface AddonInstallModalState {
|
||||
val title: String
|
||||
val message: String
|
||||
val confirmText: String
|
||||
val isBusy: Boolean
|
||||
|
||||
data object Checking : AddonInstallModalState {
|
||||
override val title: String = "Checking Addon"
|
||||
override val message: String = "Validating the manifest URL and loading addon details before install."
|
||||
override val confirmText: String = "Installing"
|
||||
override val isBusy: Boolean = true
|
||||
}
|
||||
|
||||
data class Success(
|
||||
private val addonName: String,
|
||||
val addonName: String,
|
||||
) : AddonInstallModalState {
|
||||
override val title: String = "Addon Installed"
|
||||
override val message: String = "$addonName was validated and added successfully."
|
||||
override val confirmText: String = "Done"
|
||||
override val isBusy: Boolean = false
|
||||
}
|
||||
|
||||
data class Error(
|
||||
private val reason: String,
|
||||
val reason: String,
|
||||
) : AddonInstallModalState {
|
||||
override val title: String = "Install Failed"
|
||||
override val message: String = reason
|
||||
override val confirmText: String = "Close"
|
||||
override val isBusy: Boolean = false
|
||||
}
|
||||
}
|
||||
|
|
@ -291,13 +328,13 @@ private sealed interface AddonInstallModalState {
|
|||
private fun EmptyStateCard() {
|
||||
NuvioSurfaceCard {
|
||||
Text(
|
||||
text = "No addons installed yet.",
|
||||
text = stringResource(Res.string.addons_empty_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Add a manifest URL to start loading catalogs, metadata, streams or subtitles into Nuvio.",
|
||||
text = stringResource(Res.string.addons_empty_subtitle),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -307,7 +344,10 @@ private fun EmptyStateCard() {
|
|||
@Composable
|
||||
private fun InstalledAddonCard(
|
||||
addon: ManagedAddon,
|
||||
onMoveUpClick: (() -> Unit)?,
|
||||
onMoveDownClick: (() -> Unit)?,
|
||||
onRefreshClick: () -> Unit,
|
||||
onConfigureClick: (() -> Unit)?,
|
||||
onDeleteClick: () -> Unit,
|
||||
) {
|
||||
val manifest = addon.manifest
|
||||
|
|
@ -315,54 +355,79 @@ private fun InstalledAddonCard(
|
|||
NuvioSurfaceCard {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
AddonIconBadge(
|
||||
imageUrl = manifest?.logoUrl,
|
||||
icon = Icons.Rounded.Extension,
|
||||
tint = if (manifest != null) Color(0xFF71BDE8) else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
AddonIconBadge(
|
||||
imageUrl = manifest?.logoUrl,
|
||||
icon = Icons.Rounded.Extension,
|
||||
tint = if (manifest != null) Color(0xFF71BDE8) else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = addon.displayTitle,
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
manifest?.version?.let { version ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = addon.displayTitle,
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
text = stringResource(Res.string.addons_version_format, version),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
manifest?.version?.let { version ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Version $version",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
NuvioIconActionButton(
|
||||
icon = Icons.Rounded.Refresh,
|
||||
contentDescription = "Refresh addon",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
onClick = onRefreshClick,
|
||||
)
|
||||
NuvioIconActionButton(
|
||||
icon = Icons.Rounded.Delete,
|
||||
contentDescription = "Delete addon",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
onClick = onDeleteClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
onMoveUpClick?.let { onMoveUp ->
|
||||
NuvioIconActionButton(
|
||||
icon = Icons.Rounded.ArrowUpward,
|
||||
contentDescription = stringResource(Res.string.addons_move_up),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
onClick = onMoveUp,
|
||||
)
|
||||
}
|
||||
onMoveDownClick?.let { onMoveDown ->
|
||||
NuvioIconActionButton(
|
||||
icon = Icons.Rounded.ArrowDownward,
|
||||
contentDescription = stringResource(Res.string.addons_move_down),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
onClick = onMoveDown,
|
||||
)
|
||||
}
|
||||
NuvioIconActionButton(
|
||||
icon = Icons.Rounded.Refresh,
|
||||
contentDescription = stringResource(Res.string.addons_refresh),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
onClick = onRefreshClick,
|
||||
)
|
||||
onConfigureClick?.let { onConfigure ->
|
||||
NuvioIconActionButton(
|
||||
icon = Icons.Rounded.Settings,
|
||||
contentDescription = stringResource(Res.string.addons_configure),
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
onClick = onConfigure,
|
||||
)
|
||||
}
|
||||
NuvioIconActionButton(
|
||||
icon = Icons.Rounded.Delete,
|
||||
contentDescription = stringResource(Res.string.addons_delete),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
onClick = onDeleteClick,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
|
||||
|
|
@ -373,16 +438,16 @@ private fun InstalledAddonCard(
|
|||
) {
|
||||
NuvioInfoBadge(
|
||||
text = when {
|
||||
addon.isRefreshing -> "Refreshing"
|
||||
manifest != null -> "Active"
|
||||
else -> "Unavailable"
|
||||
addon.isRefreshing -> stringResource(Res.string.addons_badge_refreshing)
|
||||
manifest != null -> stringResource(Res.string.addons_badge_active)
|
||||
else -> stringResource(Res.string.addons_badge_unavailable)
|
||||
},
|
||||
)
|
||||
manifest?.let {
|
||||
NuvioInfoBadge(text = "${it.resources.size} resources")
|
||||
NuvioInfoBadge(text = "${it.catalogs.size} catalogs")
|
||||
NuvioInfoBadge(text = stringResource(Res.string.addons_badge_resources, it.resources.size))
|
||||
NuvioInfoBadge(text = stringResource(Res.string.addons_badge_catalogs, it.catalogs.size))
|
||||
if (it.behaviorHints.configurable) {
|
||||
NuvioInfoBadge(text = "Configurable")
|
||||
NuvioInfoBadge(text = stringResource(Res.string.addons_badge_configurable))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -391,7 +456,7 @@ private fun InstalledAddonCard(
|
|||
addon.isRefreshing -> {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Loading manifest details...",
|
||||
text = stringResource(Res.string.addons_loading_manifest_details),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -468,6 +533,7 @@ private fun AddonIconBadge(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun manifestSummary(manifest: AddonManifest): String {
|
||||
val resources = manifest.resources.joinToString(separator = ", ") { it.name }
|
||||
val types = manifest.types.joinToString(separator = " / ") { it.replaceFirstChar(Char::uppercase) }
|
||||
|
|
@ -477,10 +543,19 @@ private fun manifestSummary(manifest: AddonManifest): String {
|
|||
append(resources)
|
||||
if (manifest.idPrefixes.isNotEmpty()) {
|
||||
append(" • ")
|
||||
append("${manifest.idPrefixes.size} id rules")
|
||||
append(stringResource(Res.string.addons_summary_id_rules, manifest.idPrefixes.size))
|
||||
}
|
||||
if (manifest.behaviorHints.p2p) {
|
||||
append(" • P2P")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toConfigureUrl(): String {
|
||||
val base = substringBefore("?").trimEnd('/')
|
||||
return if (base.endsWith("/manifest.json")) {
|
||||
base.removeSuffix("/manifest.json") + "/configure"
|
||||
} else {
|
||||
"$base/configure"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,22 @@ import com.nuvio.app.core.ui.NuvioSurfaceCard
|
|||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.app_logo_wordmark
|
||||
import nuvio.composeapp.generated.resources.compose_auth_already_have_account
|
||||
import nuvio.composeapp.generated.resources.compose_auth_continue_without_account
|
||||
import nuvio.composeapp.generated.resources.compose_auth_create_account
|
||||
import nuvio.composeapp.generated.resources.compose_auth_dont_have_account
|
||||
import nuvio.composeapp.generated.resources.compose_auth_email
|
||||
import nuvio.composeapp.generated.resources.compose_auth_or_separator
|
||||
import nuvio.composeapp.generated.resources.compose_auth_password
|
||||
import nuvio.composeapp.generated.resources.compose_auth_sign_in
|
||||
import nuvio.composeapp.generated.resources.compose_auth_sign_in_subtitle
|
||||
import nuvio.composeapp.generated.resources.compose_auth_sign_up
|
||||
import nuvio.composeapp.generated.resources.compose_auth_sign_up_subtitle
|
||||
import nuvio.composeapp.generated.resources.compose_auth_store_locally
|
||||
import nuvio.composeapp.generated.resources.compose_auth_tagline
|
||||
import nuvio.composeapp.generated.resources.compose_auth_welcome_back
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun AuthScreen(
|
||||
|
|
@ -97,7 +112,7 @@ fun AuthScreen(
|
|||
) {
|
||||
Image(
|
||||
painter = painterResource(Res.drawable.app_logo_wordmark),
|
||||
contentDescription = "Nuvio",
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.6f)
|
||||
.height(48.dp),
|
||||
|
|
@ -105,7 +120,7 @@ fun AuthScreen(
|
|||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Stream everything, everywhere",
|
||||
text = stringResource(Res.string.compose_auth_tagline),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -119,7 +134,8 @@ fun AuthScreen(
|
|||
label = "heading",
|
||||
) { signUp ->
|
||||
Text(
|
||||
text = if (signUp) "Create Account" else "Welcome Back",
|
||||
text = if (signUp) stringResource(Res.string.compose_auth_create_account)
|
||||
else stringResource(Res.string.compose_auth_welcome_back),
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
|
@ -131,8 +147,8 @@ fun AuthScreen(
|
|||
label = "subtitle",
|
||||
) { signUp ->
|
||||
Text(
|
||||
text = if (signUp) "Sign up to sync your data across devices"
|
||||
else "Sign in to access your library and progress",
|
||||
text = if (signUp) stringResource(Res.string.compose_auth_sign_up_subtitle)
|
||||
else stringResource(Res.string.compose_auth_sign_in_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -150,7 +166,7 @@ fun AuthScreen(
|
|||
singleLine = true,
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "Email",
|
||||
text = stringResource(Res.string.compose_auth_email),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
|
|
@ -183,7 +199,7 @@ fun AuthScreen(
|
|||
singleLine = true,
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "Password",
|
||||
text = stringResource(Res.string.compose_auth_password),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
|
|
@ -240,7 +256,13 @@ fun AuthScreen(
|
|||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
NuvioPrimaryButton(
|
||||
text = if (isLoading) "" else if (isSignUp) "Create Account" else "Sign In",
|
||||
text = if (isLoading) {
|
||||
""
|
||||
} else if (isSignUp) {
|
||||
stringResource(Res.string.compose_auth_create_account)
|
||||
} else {
|
||||
stringResource(Res.string.compose_auth_sign_in)
|
||||
},
|
||||
enabled = email.isNotBlank() && password.length >= 6 && !isLoading,
|
||||
onClick = {
|
||||
isLoading = true
|
||||
|
|
@ -279,7 +301,8 @@ fun AuthScreen(
|
|||
label = "togglePrompt",
|
||||
) { signUp ->
|
||||
Text(
|
||||
text = if (signUp) "Already have an account? " else "Don't have an account? ",
|
||||
text = if (signUp) stringResource(Res.string.compose_auth_already_have_account)
|
||||
else stringResource(Res.string.compose_auth_dont_have_account),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -290,7 +313,8 @@ fun AuthScreen(
|
|||
label = "toggleAction",
|
||||
) { signUp ->
|
||||
Text(
|
||||
text = if (signUp) "Sign In" else "Sign Up",
|
||||
text = if (signUp) stringResource(Res.string.compose_auth_sign_in)
|
||||
else stringResource(Res.string.compose_auth_sign_up),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
|
|
@ -317,7 +341,7 @@ fun AuthScreen(
|
|||
.background(MaterialTheme.colorScheme.outline),
|
||||
)
|
||||
Text(
|
||||
text = " or ",
|
||||
text = stringResource(Res.string.compose_auth_or_separator),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -346,7 +370,7 @@ fun AuthScreen(
|
|||
),
|
||||
) {
|
||||
Text(
|
||||
text = "Continue Without Account",
|
||||
text = stringResource(Res.string.compose_auth_continue_without_account),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
|
@ -354,7 +378,7 @@ fun AuthScreen(
|
|||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "Your data will only be stored locally",
|
||||
text = stringResource(Res.string.compose_auth_store_locally),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ package com.nuvio.app.features.catalog
|
|||
|
||||
import com.nuvio.app.features.library.LibraryRepository
|
||||
import com.nuvio.app.features.library.toMetaPreview
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
import com.nuvio.app.features.home.filterReleasedItems
|
||||
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
|
|
@ -10,6 +13,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
const val INTERNAL_LIBRARY_MANIFEST_URL = "nuvio://library"
|
||||
|
||||
|
|
@ -92,7 +97,7 @@ object CatalogRepository {
|
|||
items = emptyList(),
|
||||
isLoading = false,
|
||||
nextSkip = null,
|
||||
errorMessage = error.message ?: "Unable to load catalog items.",
|
||||
errorMessage = error.message ?: getString(Res.string.catalog_load_failed),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
@ -122,7 +127,7 @@ object CatalogRepository {
|
|||
catalogId = request.catalogId,
|
||||
genre = request.genre,
|
||||
skip = requestedSkip.takeIf { it > 0 },
|
||||
)
|
||||
).withUnreleasedFilter()
|
||||
}.fold(
|
||||
onSuccess = { page ->
|
||||
if (activeRequest != request) return@fold
|
||||
|
|
@ -148,7 +153,7 @@ object CatalogRepository {
|
|||
items = if (reset) emptyList() else current.items,
|
||||
isLoading = false,
|
||||
nextSkip = null,
|
||||
errorMessage = error.message ?: "Unable to load catalog items.",
|
||||
errorMessage = error.message ?: getString(Res.string.catalog_load_failed),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
@ -156,6 +161,12 @@ object CatalogRepository {
|
|||
}
|
||||
}
|
||||
|
||||
private fun CatalogPage.withUnreleasedFilter(): CatalogPage {
|
||||
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
|
||||
val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate())
|
||||
return if (filteredItems.size == items.size) this else copy(items = filteredItems)
|
||||
}
|
||||
|
||||
private data class CatalogRequest(
|
||||
val manifestUrl: String,
|
||||
val type: String,
|
||||
|
|
|
|||
|
|
@ -50,12 +50,16 @@ import com.nuvio.app.core.ui.NuvioBackButton
|
|||
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||
import com.nuvio.app.core.ui.posterCardClickable
|
||||
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
||||
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
import com.nuvio.app.features.home.PosterShape
|
||||
import com.nuvio.app.features.home.stableKey
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun CatalogScreen(
|
||||
|
|
@ -71,20 +75,21 @@ fun CatalogScreen(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val uiState by CatalogRepository.uiState.collectAsStateWithLifecycle()
|
||||
val homeCatalogSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle()
|
||||
val posterCardStyle = rememberPosterCardStyleUiState()
|
||||
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
||||
val gridState = rememberLazyGridState()
|
||||
var headerHeightPx by remember { mutableIntStateOf(0) }
|
||||
var observedOfflineState by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination) {
|
||||
LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination, homeCatalogSettingsUiState.hideUnreleasedContent) {
|
||||
CatalogRepository.load(
|
||||
manifestUrl = manifestUrl,
|
||||
type = type,
|
||||
catalogId = catalogId,
|
||||
genre = genre,
|
||||
supportsPagination = supportsPagination,
|
||||
force = false,
|
||||
force = true,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -173,9 +178,10 @@ fun CatalogScreen(
|
|||
}
|
||||
} else {
|
||||
items(
|
||||
items = uiState.items,
|
||||
key = { item -> item.stableKey() },
|
||||
) { item ->
|
||||
items = uiState.items.withDuplicateSafeLazyKeys { item -> item.stableKey() },
|
||||
key = { item -> item.lazyKey },
|
||||
) { keyedItem ->
|
||||
val item = keyedItem.value
|
||||
CatalogPosterTile(
|
||||
item = item,
|
||||
cornerRadiusDp = posterCardStyle.cornerRadiusDp,
|
||||
|
|
@ -329,12 +335,12 @@ private fun CatalogEmptyState(
|
|||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "No titles found",
|
||||
text = stringResource(Res.string.catalog_empty_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Text(
|
||||
text = errorMessage ?: "This catalog did not return any items.",
|
||||
text = errorMessage ?: stringResource(Res.string.catalog_empty_message),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 com.nuvio.app.features.home.PosterShape
|
||||
import com.nuvio.app.features.trakt.TraktPublicListSearchResult
|
||||
import com.nuvio.app.features.trakt.TraktPublicListSourceResolver
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
|
|
@ -22,11 +28,46 @@ data class CollectionEditorUiState(
|
|||
val editingFolder: CollectionFolder? = null,
|
||||
val showFolderEditor: Boolean = false,
|
||||
val showCatalogPicker: Boolean = false,
|
||||
val showTmdbSourcePicker: Boolean = false,
|
||||
val showTraktSourcePicker: Boolean = false,
|
||||
val editingTraktSourceIndex: Int? = null,
|
||||
val genrePickerSourceIndex: Int? = null,
|
||||
val tmdbBuilderMode: TmdbBuilderMode = TmdbBuilderMode.PRESETS,
|
||||
val tmdbInput: String = "",
|
||||
val tmdbTitleInput: String = "",
|
||||
val tmdbMediaType: TmdbCollectionMediaType = TmdbCollectionMediaType.MOVIE,
|
||||
val tmdbMediaBoth: Boolean = false,
|
||||
val tmdbSortBy: String = TmdbCollectionSort.POPULAR_DESC.value,
|
||||
val tmdbFilters: TmdbCollectionFilters = TmdbCollectionFilters(),
|
||||
val tmdbCompanyResults: List<TmdbCompanySearchResult> = emptyList(),
|
||||
val tmdbCollectionResults: List<TmdbCollectionSearchResult> = emptyList(),
|
||||
val tmdbSearchError: String? = null,
|
||||
val traktInput: String = "",
|
||||
val traktTitleInput: String = "",
|
||||
val traktMediaType: TmdbCollectionMediaType = TmdbCollectionMediaType.MOVIE,
|
||||
val traktMediaBoth: Boolean = true,
|
||||
val traktSortBy: String = TraktListSort.RANK.value,
|
||||
val traktSortHow: String = TraktSortHow.ASC.value,
|
||||
val traktSearchResults: List<TraktPublicListSearchResult> = emptyList(),
|
||||
val traktTrendingResults: List<TraktPublicListSearchResult> = emptyList(),
|
||||
val traktPopularResults: List<TraktPublicListSearchResult> = emptyList(),
|
||||
val traktSearchError: String? = null,
|
||||
)
|
||||
|
||||
enum class TmdbBuilderMode {
|
||||
PRESETS,
|
||||
LIST,
|
||||
PRODUCTION,
|
||||
NETWORK,
|
||||
COLLECTION,
|
||||
PERSON,
|
||||
DIRECTOR,
|
||||
DISCOVER,
|
||||
}
|
||||
|
||||
object CollectionEditorRepository {
|
||||
private val log = Logger.withTag("CollectionEditorRepository")
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
private val _uiState = MutableStateFlow(CollectionEditorUiState())
|
||||
val uiState: StateFlow<CollectionEditorUiState> = _uiState.asStateFlow()
|
||||
|
|
@ -93,10 +134,10 @@ object CollectionEditorRepository {
|
|||
}
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
fun addFolder() {
|
||||
fun addFolder(defaultTitle: String) {
|
||||
val newFolder = CollectionFolder(
|
||||
id = Uuid.random().toString(),
|
||||
title = "New Folder",
|
||||
title = defaultTitle,
|
||||
)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
editingFolder = newFolder,
|
||||
|
|
@ -177,13 +218,8 @@ object CollectionEditorRepository {
|
|||
|
||||
fun updateFolderTileShape(shape: PosterShape) {
|
||||
val folder = _uiState.value.editingFolder ?: return
|
||||
val shapeStr = when (shape) {
|
||||
PosterShape.Poster -> "Poster"
|
||||
PosterShape.Landscape -> "Landscape"
|
||||
PosterShape.Square -> "Square"
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(
|
||||
editingFolder = folder.copy(tileShape = shapeStr),
|
||||
editingFolder = folder.copy(tileShape = shape.name.lowercase()),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -203,39 +239,44 @@ object CollectionEditorRepository {
|
|||
catalogId = catalog.catalogId,
|
||||
genre = defaultGenre,
|
||||
)
|
||||
if (folder.catalogSources.any {
|
||||
if (folder.resolvedCatalogSources.any {
|
||||
it.addonId == source.addonId && it.type == source.type && it.catalogId == source.catalogId
|
||||
}) return
|
||||
_uiState.value = _uiState.value.copy(
|
||||
editingFolder = folder.copy(catalogSources = folder.catalogSources + source),
|
||||
editingFolder = folder.withSources(folder.resolvedSources + source.toCollectionSource()),
|
||||
)
|
||||
}
|
||||
|
||||
fun removeCatalogSource(index: Int) {
|
||||
val folder = _uiState.value.editingFolder ?: return
|
||||
if (index !in folder.catalogSources.indices) return
|
||||
val sources = folder.resolvedSources
|
||||
if (index !in sources.indices) return
|
||||
_uiState.value = _uiState.value.copy(
|
||||
editingFolder = folder.copy(
|
||||
catalogSources = folder.catalogSources.toMutableList().apply { removeAt(index) },
|
||||
),
|
||||
editingFolder = folder.withSources(sources.toMutableList().apply { removeAt(index) }),
|
||||
genrePickerSourceIndex = null,
|
||||
)
|
||||
}
|
||||
|
||||
fun updateCatalogSourceGenre(index: Int, genre: String?) {
|
||||
val folder = _uiState.value.editingFolder ?: return
|
||||
if (index !in folder.catalogSources.indices) return
|
||||
val updated = folder.catalogSources.toMutableList()
|
||||
val sources = folder.resolvedSources
|
||||
if (index !in sources.indices || sources[index].addonCatalogSource() == null) return
|
||||
val updated = sources.toMutableList()
|
||||
updated[index] = updated[index].copy(genre = genre)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
editingFolder = folder.copy(catalogSources = updated),
|
||||
editingFolder = folder.withSources(updated),
|
||||
)
|
||||
}
|
||||
|
||||
fun toggleCatalogSource(catalog: AvailableCatalog) {
|
||||
val folder = _uiState.value.editingFolder ?: return
|
||||
val existingIndex = folder.catalogSources.indexOfFirst {
|
||||
it.addonId == catalog.addonId && it.type == catalog.type && it.catalogId == catalog.catalogId
|
||||
val sources = folder.resolvedSources
|
||||
val existingIndex = sources.indexOfFirst {
|
||||
!it.isTmdb &&
|
||||
!it.isTrakt &&
|
||||
it.addonId == catalog.addonId &&
|
||||
it.type == catalog.type &&
|
||||
it.catalogId == catalog.catalogId
|
||||
}
|
||||
if (existingIndex >= 0) {
|
||||
removeCatalogSource(existingIndex)
|
||||
|
|
@ -247,6 +288,9 @@ object CollectionEditorRepository {
|
|||
fun showCatalogPicker() {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
showCatalogPicker = true,
|
||||
showTmdbSourcePicker = false,
|
||||
showTraktSourcePicker = false,
|
||||
editingTraktSourceIndex = null,
|
||||
genrePickerSourceIndex = null,
|
||||
)
|
||||
}
|
||||
|
|
@ -255,12 +299,154 @@ object CollectionEditorRepository {
|
|||
_uiState.value = _uiState.value.copy(showCatalogPicker = false)
|
||||
}
|
||||
|
||||
fun showTmdbSourcePicker() {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
showTmdbSourcePicker = true,
|
||||
showCatalogPicker = false,
|
||||
showTraktSourcePicker = false,
|
||||
editingTraktSourceIndex = null,
|
||||
genrePickerSourceIndex = null,
|
||||
tmdbSearchError = null,
|
||||
)
|
||||
}
|
||||
|
||||
fun hideTmdbSourcePicker() {
|
||||
_uiState.value = _uiState.value.copy(showTmdbSourcePicker = false, tmdbSearchError = null)
|
||||
}
|
||||
|
||||
fun showTraktSourcePicker() {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
showTraktSourcePicker = true,
|
||||
showCatalogPicker = false,
|
||||
showTmdbSourcePicker = false,
|
||||
editingTraktSourceIndex = null,
|
||||
genrePickerSourceIndex = null,
|
||||
traktInput = "",
|
||||
traktTitleInput = "",
|
||||
traktMediaType = TmdbCollectionMediaType.MOVIE,
|
||||
traktMediaBoth = true,
|
||||
traktSortBy = TraktListSort.RANK.value,
|
||||
traktSortHow = TraktSortHow.ASC.value,
|
||||
traktSearchResults = emptyList(),
|
||||
traktSearchError = null,
|
||||
)
|
||||
loadTraktFeaturedLists()
|
||||
}
|
||||
|
||||
fun hideTraktSourcePicker() {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
showTraktSourcePicker = false,
|
||||
editingTraktSourceIndex = null,
|
||||
traktSearchError = null,
|
||||
)
|
||||
}
|
||||
|
||||
fun editTraktSource(index: Int) {
|
||||
val folder = _uiState.value.editingFolder ?: return
|
||||
val source = folder.resolvedSources.getOrNull(index) ?: return
|
||||
if (!source.isTrakt) return
|
||||
_uiState.value = _uiState.value.copy(
|
||||
showTraktSourcePicker = true,
|
||||
showCatalogPicker = false,
|
||||
showTmdbSourcePicker = false,
|
||||
editingTraktSourceIndex = index,
|
||||
genrePickerSourceIndex = null,
|
||||
traktInput = source.traktListId?.toString().orEmpty(),
|
||||
traktTitleInput = source.title.orEmpty(),
|
||||
traktMediaType = TmdbCollectionMediaType.fromString(source.mediaType),
|
||||
traktMediaBoth = false,
|
||||
traktSortBy = TraktListSort.normalize(source.sortBy),
|
||||
traktSortHow = TraktSortHow.normalize(source.sortHow),
|
||||
traktSearchResults = emptyList(),
|
||||
traktSearchError = null,
|
||||
)
|
||||
loadTraktFeaturedLists()
|
||||
}
|
||||
|
||||
fun setTraktInput(value: String) {
|
||||
_uiState.value = _uiState.value.copy(traktInput = value, traktSearchError = null)
|
||||
}
|
||||
|
||||
fun setTraktTitleInput(value: String) {
|
||||
_uiState.value = _uiState.value.copy(traktTitleInput = value)
|
||||
}
|
||||
|
||||
fun setTraktMediaType(value: TmdbCollectionMediaType) {
|
||||
_uiState.value = _uiState.value.copy(traktMediaType = value, traktMediaBoth = false)
|
||||
}
|
||||
|
||||
fun setTraktMediaBoth(value: Boolean) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
traktMediaBoth = value,
|
||||
traktMediaType = if (value) TmdbCollectionMediaType.MOVIE else _uiState.value.traktMediaType,
|
||||
)
|
||||
}
|
||||
|
||||
fun setTraktSortBy(value: String) {
|
||||
_uiState.value = _uiState.value.copy(traktSortBy = TraktListSort.normalize(value))
|
||||
}
|
||||
|
||||
fun setTraktSortHow(value: String) {
|
||||
_uiState.value = _uiState.value.copy(traktSortHow = TraktSortHow.normalize(value))
|
||||
}
|
||||
|
||||
fun searchTraktLists() {
|
||||
val state = _uiState.value
|
||||
val query = state.traktInput.trim()
|
||||
if (query.isBlank()) {
|
||||
_uiState.value = state.copy(traktSearchError = "Enter a Trakt list name, URL, or ID")
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
val results = if (query.isTraktListIdentifierInput()) {
|
||||
runCatching {
|
||||
val metadata = TraktPublicListSourceResolver.listImportMetadata(query)
|
||||
val id = metadata.traktListId ?: error("Could not load Trakt list")
|
||||
listOf(
|
||||
TraktPublicListSearchResult(
|
||||
traktListId = id,
|
||||
title = metadata.title ?: "Trakt List $id",
|
||||
subtitle = "Resolved Trakt list",
|
||||
coverImageUrl = metadata.coverImageUrl,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
runCatching { TraktPublicListSourceResolver.searchPublicLists(query) }
|
||||
}
|
||||
val mapped = results.getOrDefault(emptyList())
|
||||
_uiState.value = _uiState.value.copy(
|
||||
traktSearchResults = mapped,
|
||||
traktSearchError = results.exceptionOrNull()?.message
|
||||
?: if (mapped.isEmpty()) "No Trakt lists found" else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadTraktFeaturedLists() {
|
||||
scope.launch {
|
||||
val trending = runCatching { TraktPublicListSourceResolver.trendingPublicLists() }
|
||||
val popular = runCatching { TraktPublicListSourceResolver.popularPublicLists() }
|
||||
_uiState.value = _uiState.value.copy(
|
||||
traktTrendingResults = trending.getOrDefault(_uiState.value.traktTrendingResults),
|
||||
traktPopularResults = popular.getOrDefault(_uiState.value.traktPopularResults),
|
||||
traktSearchError = _uiState.value.traktSearchError
|
||||
?: trending.exceptionOrNull()?.message
|
||||
?: popular.exceptionOrNull()?.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showGenrePicker(index: Int) {
|
||||
val folder = _uiState.value.editingFolder ?: return
|
||||
if (index !in folder.catalogSources.indices) return
|
||||
val sources = folder.resolvedSources
|
||||
if (index !in sources.indices || sources[index].addonCatalogSource() == null) return
|
||||
_uiState.value = _uiState.value.copy(
|
||||
genrePickerSourceIndex = index,
|
||||
showCatalogPicker = false,
|
||||
showTmdbSourcePicker = false,
|
||||
showTraktSourcePicker = false,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -270,17 +456,21 @@ object CollectionEditorRepository {
|
|||
|
||||
fun saveFolderEdit() {
|
||||
val folder = _uiState.value.editingFolder ?: return
|
||||
val normalizedFolder = folder.withSources(folder.resolvedSources)
|
||||
val existing = _uiState.value.folders
|
||||
val updated = if (existing.any { it.id == folder.id }) {
|
||||
existing.map { if (it.id == folder.id) folder else it }
|
||||
val updated = if (existing.any { it.id == normalizedFolder.id }) {
|
||||
existing.map { if (it.id == normalizedFolder.id) normalizedFolder else it }
|
||||
} else {
|
||||
existing + folder
|
||||
existing + normalizedFolder
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(
|
||||
folders = updated,
|
||||
editingFolder = null,
|
||||
showFolderEditor = false,
|
||||
showCatalogPicker = false,
|
||||
showTmdbSourcePicker = false,
|
||||
showTraktSourcePicker = false,
|
||||
editingTraktSourceIndex = null,
|
||||
genrePickerSourceIndex = null,
|
||||
)
|
||||
}
|
||||
|
|
@ -290,10 +480,320 @@ object CollectionEditorRepository {
|
|||
editingFolder = null,
|
||||
showFolderEditor = false,
|
||||
showCatalogPicker = false,
|
||||
showTmdbSourcePicker = false,
|
||||
showTraktSourcePicker = false,
|
||||
editingTraktSourceIndex = null,
|
||||
genrePickerSourceIndex = null,
|
||||
)
|
||||
}
|
||||
|
||||
fun setTmdbBuilderMode(mode: TmdbBuilderMode) {
|
||||
val mediaType = if (mode == TmdbBuilderMode.NETWORK) {
|
||||
TmdbCollectionMediaType.TV
|
||||
} else {
|
||||
_uiState.value.tmdbMediaType
|
||||
}
|
||||
val sortBy = when (mode) {
|
||||
TmdbBuilderMode.LIST,
|
||||
TmdbBuilderMode.COLLECTION -> TmdbCollectionSort.ORIGINAL.value
|
||||
else -> TmdbCollectionSort.POPULAR_DESC.value
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(
|
||||
tmdbBuilderMode = mode,
|
||||
tmdbMediaType = mediaType,
|
||||
tmdbSortBy = sortBy,
|
||||
tmdbMediaBoth = if (
|
||||
mode == TmdbBuilderMode.NETWORK ||
|
||||
mode == TmdbBuilderMode.LIST ||
|
||||
mode == TmdbBuilderMode.COLLECTION
|
||||
) {
|
||||
false
|
||||
} else {
|
||||
_uiState.value.tmdbMediaBoth
|
||||
},
|
||||
tmdbCompanyResults = emptyList(),
|
||||
tmdbCollectionResults = emptyList(),
|
||||
tmdbSearchError = null,
|
||||
)
|
||||
}
|
||||
|
||||
fun setTmdbInput(value: String) {
|
||||
_uiState.value = _uiState.value.copy(tmdbInput = value, tmdbSearchError = null)
|
||||
}
|
||||
|
||||
fun setTmdbTitleInput(value: String) {
|
||||
_uiState.value = _uiState.value.copy(tmdbTitleInput = value)
|
||||
}
|
||||
|
||||
fun setTmdbMediaType(value: TmdbCollectionMediaType) {
|
||||
_uiState.value = _uiState.value.copy(tmdbMediaType = value, tmdbMediaBoth = false)
|
||||
}
|
||||
|
||||
fun setTmdbMediaBoth(value: Boolean) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
tmdbMediaBoth = value,
|
||||
tmdbMediaType = if (value) TmdbCollectionMediaType.MOVIE else _uiState.value.tmdbMediaType,
|
||||
)
|
||||
}
|
||||
|
||||
fun setTmdbSortBy(value: String) {
|
||||
_uiState.value = _uiState.value.copy(tmdbSortBy = value)
|
||||
}
|
||||
|
||||
fun updateTmdbFilters(transform: (TmdbCollectionFilters) -> TmdbCollectionFilters) {
|
||||
_uiState.value = _uiState.value.copy(tmdbFilters = transform(_uiState.value.tmdbFilters))
|
||||
}
|
||||
|
||||
fun addTmdbPreset(source: CollectionSource) {
|
||||
addTmdbSource(source)
|
||||
}
|
||||
|
||||
fun searchTmdbCompanies() {
|
||||
val query = _uiState.value.tmdbInput.trim()
|
||||
if (query.isBlank()) return
|
||||
scope.launch {
|
||||
val results = runCatching { TmdbCollectionSourceResolver.searchCompanies(query) }
|
||||
_uiState.value = _uiState.value.copy(
|
||||
tmdbCompanyResults = results.getOrDefault(emptyList()),
|
||||
tmdbSearchError = results.exceptionOrNull()?.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun searchTmdbCollections() {
|
||||
val query = _uiState.value.tmdbInput.trim()
|
||||
if (query.isBlank()) return
|
||||
scope.launch {
|
||||
val results = runCatching { TmdbCollectionSourceResolver.searchCollections(query) }
|
||||
_uiState.value = _uiState.value.copy(
|
||||
tmdbCollectionResults = results.getOrDefault(emptyList()),
|
||||
tmdbSearchError = results.exceptionOrNull()?.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun addTmdbSource(source: CollectionSource) {
|
||||
val sourceType = source.tmdbType()
|
||||
if (source.tmdbId != null && sourceType in coverMetadataSourceTypes) {
|
||||
scope.launch {
|
||||
val metadata = runCatching { TmdbCollectionSourceResolver.importMetadata(sourceType, source.tmdbId) }
|
||||
val resolved = metadata.getOrNull()
|
||||
addTmdbSources(
|
||||
sources = listOf(
|
||||
if (source.title.isNullOrBlank()) {
|
||||
source.copy(title = resolved?.title)
|
||||
} else {
|
||||
source
|
||||
},
|
||||
),
|
||||
coverImageUrl = resolved?.coverImageUrl,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
addTmdbSources(listOf(source))
|
||||
}
|
||||
|
||||
fun addTmdbSourcesFromPicker(sources: List<CollectionSource>) {
|
||||
val metadataSource = sources.firstOrNull {
|
||||
it.tmdbId != null && it.tmdbType() in coverMetadataSourceTypes
|
||||
}
|
||||
if (metadataSource != null) {
|
||||
scope.launch {
|
||||
val sourceType = metadataSource.tmdbType()
|
||||
val metadata = runCatching { TmdbCollectionSourceResolver.importMetadata(sourceType, metadataSource.tmdbId!!) }
|
||||
addTmdbSources(sources, metadata.getOrNull()?.coverImageUrl)
|
||||
}
|
||||
return
|
||||
}
|
||||
addTmdbSources(sources)
|
||||
}
|
||||
|
||||
fun addTmdbSourceFromInput() {
|
||||
val state = _uiState.value
|
||||
val mode = state.tmdbBuilderMode
|
||||
val sourceType = when (mode) {
|
||||
TmdbBuilderMode.PRESETS -> TmdbCollectionSourceType.DISCOVER
|
||||
TmdbBuilderMode.LIST -> TmdbCollectionSourceType.LIST
|
||||
TmdbBuilderMode.COLLECTION -> TmdbCollectionSourceType.COLLECTION
|
||||
TmdbBuilderMode.PRODUCTION -> TmdbCollectionSourceType.COMPANY
|
||||
TmdbBuilderMode.NETWORK -> TmdbCollectionSourceType.NETWORK
|
||||
TmdbBuilderMode.PERSON -> TmdbCollectionSourceType.PERSON
|
||||
TmdbBuilderMode.DIRECTOR -> TmdbCollectionSourceType.DIRECTOR
|
||||
TmdbBuilderMode.DISCOVER -> TmdbCollectionSourceType.DISCOVER
|
||||
}
|
||||
val id = TmdbCollectionSourceResolver.parseTmdbId(state.tmdbInput)
|
||||
if (sourceType != TmdbCollectionSourceType.DISCOVER && id == null) {
|
||||
_uiState.value = state.copy(tmdbSearchError = "Enter a valid TMDB ID or URL.")
|
||||
return
|
||||
}
|
||||
val mediaTypes = selectedMediaTypes(state, sourceType)
|
||||
val baseTitle = state.tmdbTitleInput.ifBlank {
|
||||
when (sourceType) {
|
||||
TmdbCollectionSourceType.LIST -> "TMDB List ${id ?: ""}".trim()
|
||||
TmdbCollectionSourceType.COLLECTION -> "TMDB Collection ${id ?: ""}".trim()
|
||||
TmdbCollectionSourceType.COMPANY -> "TMDB Production ${id ?: ""}".trim()
|
||||
TmdbCollectionSourceType.NETWORK -> "TMDB Network ${id ?: ""}".trim()
|
||||
TmdbCollectionSourceType.PERSON -> "TMDB Person ${id ?: ""}".trim()
|
||||
TmdbCollectionSourceType.DIRECTOR -> "TMDB Director ${id ?: ""}".trim()
|
||||
TmdbCollectionSourceType.DISCOVER -> "TMDB Discover"
|
||||
}
|
||||
}
|
||||
val sources = mediaTypes.map { mediaType ->
|
||||
CollectionSource(
|
||||
provider = "tmdb",
|
||||
tmdbSourceType = sourceType.name,
|
||||
title = titleForMedia(baseTitle, mediaType, mediaTypes.size > 1),
|
||||
tmdbId = id,
|
||||
mediaType = mediaType.name,
|
||||
sortBy = state.tmdbSortBy,
|
||||
filters = state.tmdbFilters,
|
||||
)
|
||||
}
|
||||
if (sourceType == TmdbCollectionSourceType.LIST || sourceType == TmdbCollectionSourceType.COLLECTION) {
|
||||
scope.launch {
|
||||
val metadata = runCatching { TmdbCollectionSourceResolver.importMetadata(sourceType, id!!) }
|
||||
val resolved = metadata.getOrNull()
|
||||
if (metadata.isFailure) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
tmdbSearchError = metadata.exceptionOrNull()?.message ?: "Could not load TMDB source",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
addTmdbSources(
|
||||
sources.map { source ->
|
||||
source.copy(title = state.tmdbTitleInput.ifBlank { resolved?.title ?: baseTitle })
|
||||
},
|
||||
coverImageUrl = resolved?.coverImageUrl,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
addTmdbSourcesFromPicker(sources)
|
||||
}
|
||||
|
||||
private fun addTmdbSources(sources: List<CollectionSource>, coverImageUrl: String? = null) {
|
||||
val folder = _uiState.value.editingFolder ?: return
|
||||
val existingKeys = folder.resolvedSources.mapTo(mutableSetOf(), ::collectionSourceKey)
|
||||
val newSources = sources.filter { existingKeys.add(collectionSourceKey(it)) }
|
||||
if (newSources.isEmpty()) return
|
||||
val shouldApplyCover = newSources.any { it.tmdbType() in coverMetadataSourceTypes } &&
|
||||
!coverImageUrl.isNullOrBlank() &&
|
||||
folder.coverImageUrl.isNullOrBlank()
|
||||
val updatedFolder = if (shouldApplyCover) {
|
||||
folder.withSources(folder.resolvedSources + newSources)
|
||||
.copy(coverImageUrl = coverImageUrl, coverEmoji = null)
|
||||
} else {
|
||||
folder.withSources(folder.resolvedSources + newSources)
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(
|
||||
editingFolder = updatedFolder,
|
||||
showTmdbSourcePicker = false,
|
||||
tmdbInput = "",
|
||||
tmdbTitleInput = "",
|
||||
tmdbCompanyResults = emptyList(),
|
||||
tmdbCollectionResults = emptyList(),
|
||||
tmdbSearchError = null,
|
||||
)
|
||||
}
|
||||
|
||||
fun addTraktSourceFromInput() {
|
||||
val state = _uiState.value
|
||||
val input = state.traktInput.trim()
|
||||
if (input.isBlank()) {
|
||||
_uiState.value = state.copy(traktSearchError = "Enter a Trakt list ID or URL")
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
val metadata = runCatching { TraktPublicListSourceResolver.listImportMetadata(input) }
|
||||
val resolved = metadata.getOrNull()
|
||||
val listId = resolved?.traktListId
|
||||
if (metadata.isFailure || listId == null) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
traktSearchError = metadata.exceptionOrNull()?.message ?: "Could not load Trakt list",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val title = state.traktTitleInput.ifBlank { resolved.title ?: "Trakt List $listId" }
|
||||
addTraktSourcesToFolder(
|
||||
sources = selectedTraktMediaTypes(state).map { mediaType ->
|
||||
CollectionSource(
|
||||
provider = "trakt",
|
||||
title = titleForMedia(title, mediaType, state.traktMediaBoth),
|
||||
traktListId = listId,
|
||||
mediaType = mediaType.name,
|
||||
sortBy = TraktListSort.normalize(state.traktSortBy),
|
||||
sortHow = TraktSortHow.normalize(state.traktSortHow),
|
||||
)
|
||||
},
|
||||
coverImageUrl = resolved.coverImageUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun addTraktSourceFromResult(result: TraktPublicListSearchResult) {
|
||||
val state = _uiState.value
|
||||
val title = state.traktTitleInput.ifBlank { result.title }
|
||||
addTraktSourcesToFolder(
|
||||
sources = selectedTraktMediaTypes(state).map { mediaType ->
|
||||
CollectionSource(
|
||||
provider = "trakt",
|
||||
title = titleForMedia(title, mediaType, state.traktMediaBoth),
|
||||
traktListId = result.traktListId,
|
||||
mediaType = mediaType.name,
|
||||
sortBy = TraktListSort.normalize(state.traktSortBy),
|
||||
sortHow = TraktSortHow.normalize(state.traktSortHow),
|
||||
)
|
||||
},
|
||||
coverImageUrl = result.coverImageUrl,
|
||||
)
|
||||
}
|
||||
|
||||
private fun addTraktSourcesToFolder(sources: List<CollectionSource>, coverImageUrl: String? = null) {
|
||||
val state = _uiState.value
|
||||
val folder = state.editingFolder ?: return
|
||||
val editingIndex = state.editingTraktSourceIndex
|
||||
val existingKeys = folder.resolvedSources
|
||||
.mapIndexedNotNull { index, source ->
|
||||
collectionSourceKey(source).takeUnless { index == editingIndex }
|
||||
}
|
||||
.toMutableSet()
|
||||
val newSources = sources.filter { existingKeys.add(collectionSourceKey(it)) }
|
||||
if (newSources.isEmpty()) return
|
||||
|
||||
val updatedSources = if (
|
||||
editingIndex != null &&
|
||||
editingIndex in folder.resolvedSources.indices &&
|
||||
folder.resolvedSources[editingIndex].isTrakt
|
||||
) {
|
||||
folder.resolvedSources.toMutableList().also {
|
||||
it.removeAt(editingIndex)
|
||||
it.addAll(editingIndex, newSources)
|
||||
}
|
||||
} else {
|
||||
folder.resolvedSources + newSources
|
||||
}
|
||||
val shouldApplyCover = !coverImageUrl.isNullOrBlank() && folder.coverImageUrl.isNullOrBlank()
|
||||
val updatedFolder = if (shouldApplyCover) {
|
||||
folder.withSources(updatedSources)
|
||||
.copy(coverImageUrl = coverImageUrl, coverEmoji = null)
|
||||
} else {
|
||||
folder.withSources(updatedSources)
|
||||
}
|
||||
|
||||
_uiState.value = state.copy(
|
||||
editingFolder = updatedFolder,
|
||||
showTraktSourcePicker = false,
|
||||
editingTraktSourceIndex = null,
|
||||
traktInput = "",
|
||||
traktTitleInput = "",
|
||||
traktSearchResults = emptyList(),
|
||||
traktSearchError = null,
|
||||
)
|
||||
}
|
||||
|
||||
fun save(): Boolean {
|
||||
val state = _uiState.value
|
||||
if (state.title.isBlank()) return false
|
||||
|
|
@ -316,3 +816,92 @@ object CollectionEditorRepository {
|
|||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private val coverMetadataSourceTypes = setOf(
|
||||
TmdbCollectionSourceType.COLLECTION,
|
||||
TmdbCollectionSourceType.COMPANY,
|
||||
TmdbCollectionSourceType.NETWORK,
|
||||
TmdbCollectionSourceType.PERSON,
|
||||
TmdbCollectionSourceType.DIRECTOR,
|
||||
)
|
||||
|
||||
private fun CollectionCatalogSource.toCollectionSource(): CollectionSource =
|
||||
CollectionSource(
|
||||
provider = "addon",
|
||||
addonId = addonId,
|
||||
type = type,
|
||||
catalogId = catalogId,
|
||||
genre = genre,
|
||||
)
|
||||
|
||||
private fun CollectionFolder.withSources(nextSources: List<CollectionSource>): CollectionFolder =
|
||||
copy(
|
||||
sources = nextSources,
|
||||
catalogSources = nextSources.mapNotNull { it.addonCatalogSource() },
|
||||
)
|
||||
|
||||
private fun collectionSourceKey(source: CollectionSource): String =
|
||||
when {
|
||||
source.isTmdb -> {
|
||||
"tmdb_${source.tmdbSourceType}_${source.tmdbId}_${source.mediaType}_${source.sortBy}_${source.filters.hashCode()}"
|
||||
}
|
||||
|
||||
source.isTrakt -> {
|
||||
"trakt_${source.traktListId}_${source.mediaType}_${TraktListSort.normalize(source.sortBy)}_${TraktSortHow.normalize(source.sortHow)}"
|
||||
}
|
||||
|
||||
else -> {
|
||||
"addon_${source.addonId}_${source.type}_${source.catalogId}_${source.genre.orEmpty()}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectedMediaTypes(
|
||||
state: CollectionEditorUiState,
|
||||
sourceType: TmdbCollectionSourceType,
|
||||
): List<TmdbCollectionMediaType> =
|
||||
when (sourceType) {
|
||||
TmdbCollectionSourceType.COMPANY,
|
||||
TmdbCollectionSourceType.PERSON,
|
||||
TmdbCollectionSourceType.DIRECTOR,
|
||||
TmdbCollectionSourceType.DISCOVER -> if (state.tmdbMediaBoth) {
|
||||
listOf(TmdbCollectionMediaType.MOVIE, TmdbCollectionMediaType.TV)
|
||||
} else {
|
||||
listOf(state.tmdbMediaType)
|
||||
}
|
||||
TmdbCollectionSourceType.NETWORK -> listOf(TmdbCollectionMediaType.TV)
|
||||
TmdbCollectionSourceType.COLLECTION,
|
||||
TmdbCollectionSourceType.LIST -> listOf(TmdbCollectionMediaType.MOVIE)
|
||||
}
|
||||
|
||||
private fun titleForMedia(
|
||||
title: String,
|
||||
mediaType: TmdbCollectionMediaType,
|
||||
addSuffix: Boolean,
|
||||
): String {
|
||||
if (!addSuffix) return title
|
||||
val suffix = when (mediaType) {
|
||||
TmdbCollectionMediaType.MOVIE -> "Movies"
|
||||
TmdbCollectionMediaType.TV -> "Series"
|
||||
}
|
||||
return "$title $suffix"
|
||||
}
|
||||
|
||||
private fun selectedTraktMediaTypes(state: CollectionEditorUiState): List<TmdbCollectionMediaType> =
|
||||
if (state.traktMediaBoth) {
|
||||
listOf(TmdbCollectionMediaType.MOVIE, TmdbCollectionMediaType.TV)
|
||||
} else {
|
||||
listOf(state.traktMediaType)
|
||||
}
|
||||
|
||||
private fun CollectionSource.tmdbType(): TmdbCollectionSourceType =
|
||||
tmdbSourceType
|
||||
?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() }
|
||||
?: TmdbCollectionSourceType.DISCOVER
|
||||
|
||||
private fun String.isTraktListIdentifierInput(): Boolean {
|
||||
val trimmed = trim()
|
||||
if (trimmed.isBlank()) return false
|
||||
if (trimmed.toLongOrNull() != null) return true
|
||||
if (trimmed.contains("trakt.tv/", ignoreCase = true)) return true
|
||||
return Regex("""[?&]id=([^&#/]+)""").containsMatchIn(trimmed)
|
||||
}
|
||||
|
|
|
|||
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.NuvioStatusModal
|
||||
import com.nuvio.app.core.ui.NuvioSurfaceCard
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import sh.calvin.reorderable.ReorderableCollectionItemScope
|
||||
import sh.calvin.reorderable.ReorderableItem
|
||||
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||
|
|
@ -75,7 +77,7 @@ fun CollectionManagementScreen(
|
|||
NuvioScreen {
|
||||
stickyHeader {
|
||||
NuvioScreenHeader(
|
||||
title = "Collections",
|
||||
title = stringResource(Res.string.collections_header),
|
||||
onBack = onBack,
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
|
|
@ -84,14 +86,14 @@ fun CollectionManagementScreen(
|
|||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.ContentCopy,
|
||||
contentDescription = "Copy JSON",
|
||||
contentDescription = stringResource(Res.string.collections_copy_json),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { showImportDialog = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.ContentPaste,
|
||||
contentDescription = "Import",
|
||||
contentDescription = stringResource(Res.string.collections_import),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
|
@ -100,8 +102,11 @@ fun CollectionManagementScreen(
|
|||
item {
|
||||
NuvioSurfaceCard {
|
||||
Text(
|
||||
text = "${collections.size} collection${if (collections.size != 1) "s" else ""}, " +
|
||||
"${collections.sumOf { it.folders.size }} folder${if (collections.sumOf { it.folders.size } != 1) "s" else ""}",
|
||||
text = stringResource(
|
||||
Res.string.collections_count_summary,
|
||||
collections.size,
|
||||
collections.sumOf { it.folders.size },
|
||||
),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -110,13 +115,13 @@ fun CollectionManagementScreen(
|
|||
|
||||
item {
|
||||
NuvioPrimaryButton(
|
||||
text = "New Collection",
|
||||
text = stringResource(Res.string.collections_new),
|
||||
onClick = { onNavigateToEditor(null) },
|
||||
)
|
||||
}
|
||||
|
||||
if (collections.isNotEmpty()) {
|
||||
item { NuvioSectionLabel(text = "YOUR COLLECTIONS") }
|
||||
item { NuvioSectionLabel(text = stringResource(Res.string.collections_your_collections)) }
|
||||
}
|
||||
|
||||
if (collections.isNotEmpty()) {
|
||||
|
|
@ -142,13 +147,13 @@ fun CollectionManagementScreen(
|
|||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "No collections yet",
|
||||
text = stringResource(Res.string.collections_empty_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Create one to organize your catalogs.",
|
||||
text = stringResource(Res.string.collections_empty_subtitle),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -187,11 +192,11 @@ fun CollectionManagementScreen(
|
|||
val deleteId = showDeleteConfirm
|
||||
val deleteCollection = deleteId?.let { id -> collections.find { it.id == id } }
|
||||
NuvioStatusModal(
|
||||
title = "Delete Collection",
|
||||
message = "Delete \"${deleteCollection?.title ?: ""}\"? This cannot be undone.",
|
||||
title = stringResource(Res.string.collections_delete_title),
|
||||
message = stringResource(Res.string.collections_delete_message, deleteCollection?.title.orEmpty()),
|
||||
isVisible = deleteId != null,
|
||||
confirmText = "Delete",
|
||||
dismissText = "Cancel",
|
||||
confirmText = stringResource(Res.string.action_delete),
|
||||
dismissText = stringResource(Res.string.action_cancel),
|
||||
onConfirm = {
|
||||
if (deleteId != null) {
|
||||
CollectionRepository.removeCollection(deleteId)
|
||||
|
|
@ -261,6 +266,13 @@ private fun CollectionListItem(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
val summary = buildString {
|
||||
append(stringResource(Res.string.collections_folder_count, collection.folders.size))
|
||||
if (collection.pinToTop) {
|
||||
append(" · ")
|
||||
append(stringResource(Res.string.collections_pinned))
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = collection.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
|
|
@ -271,8 +283,7 @@ private fun CollectionListItem(
|
|||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "${collection.folders.size} folder${if (collection.folders.size != 1) "s" else ""}" +
|
||||
if (collection.pinToTop) " · Pinned" else "",
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -298,7 +309,7 @@ private fun CollectionListItem(
|
|||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Menu,
|
||||
contentDescription = "Reorder",
|
||||
contentDescription = stringResource(Res.string.action_reorder),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -310,7 +321,7 @@ private fun CollectionListItem(
|
|||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Edit,
|
||||
contentDescription = "Edit",
|
||||
contentDescription = stringResource(Res.string.action_edit),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
|
@ -321,7 +332,7 @@ private fun CollectionListItem(
|
|||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Delete,
|
||||
contentDescription = "Delete",
|
||||
contentDescription = stringResource(Res.string.action_delete),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
|
|
@ -349,13 +360,13 @@ private fun ImportDialog(
|
|||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Text(
|
||||
text = "Import Collections",
|
||||
text = stringResource(Res.string.collections_import_header),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Paste your collections JSON below.",
|
||||
text = stringResource(Res.string.collections_import_paste_description),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -366,7 +377,12 @@ private fun ImportDialog(
|
|||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(160.dp),
|
||||
placeholder = { Text("JSON", style = MaterialTheme.typography.bodyLarge) },
|
||||
placeholder = {
|
||||
Text(
|
||||
stringResource(Res.string.collections_import_json_placeholder),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
},
|
||||
isError = importError != null,
|
||||
supportingText = importError?.let {
|
||||
{ Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
|
|
@ -399,7 +415,7 @@ private fun ImportDialog(
|
|||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) {
|
||||
Text("Cancel")
|
||||
Text(stringResource(Res.string.action_cancel))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
androidx.compose.material3.Button(
|
||||
|
|
@ -407,7 +423,7 @@ private fun ImportDialog(
|
|||
enabled = importText.isNotBlank(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Text("Import")
|
||||
Text(stringResource(Res.string.action_import))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,136 @@ data class CollectionCatalogSource(
|
|||
val genre: String? = null,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
@Serializable
|
||||
data class CollectionSource(
|
||||
val provider: String = "addon",
|
||||
val addonId: String? = null,
|
||||
val type: String? = null,
|
||||
val catalogId: String? = null,
|
||||
val genre: String? = null,
|
||||
val tmdbSourceType: String? = null,
|
||||
val title: String? = null,
|
||||
val tmdbId: Int? = null,
|
||||
val traktListId: Long? = null,
|
||||
val mediaType: String? = null,
|
||||
val sortBy: String? = null,
|
||||
val sortHow: String? = null,
|
||||
val filters: TmdbCollectionFilters? = null,
|
||||
) {
|
||||
val isTmdb: Boolean
|
||||
get() = provider.equals("tmdb", ignoreCase = true)
|
||||
|
||||
val isTrakt: Boolean
|
||||
get() = provider.equals("trakt", ignoreCase = true)
|
||||
|
||||
fun addonCatalogSource(): CollectionCatalogSource? {
|
||||
if (isTmdb || isTrakt) return null
|
||||
val sourceAddonId = addonId?.takeIf { it.isNotBlank() } ?: return null
|
||||
val sourceType = type?.takeIf { it.isNotBlank() } ?: return null
|
||||
val sourceCatalogId = catalogId?.takeIf { it.isNotBlank() } ?: return null
|
||||
return CollectionCatalogSource(
|
||||
addonId = sourceAddonId,
|
||||
type = sourceType,
|
||||
catalogId = sourceCatalogId,
|
||||
genre = genre.normalizedOptionalGenre(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun CollectionSource.hasInvalidTraktListId(): Boolean =
|
||||
isTrakt && (traktListId == null || traktListId <= 0L)
|
||||
|
||||
@Serializable
|
||||
enum class TmdbCollectionSourceType {
|
||||
LIST,
|
||||
COLLECTION,
|
||||
COMPANY,
|
||||
NETWORK,
|
||||
DISCOVER,
|
||||
PERSON,
|
||||
DIRECTOR,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class TmdbCollectionMediaType(val value: String) {
|
||||
MOVIE("movie"),
|
||||
TV("tv");
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String?): TmdbCollectionMediaType =
|
||||
when (value?.trim()?.lowercase()) {
|
||||
"tv", "series" -> TV
|
||||
else -> MOVIE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class TmdbCollectionSort(val value: String) {
|
||||
ORIGINAL("original"),
|
||||
POPULAR_DESC("popularity.desc"),
|
||||
VOTE_AVERAGE_DESC("vote_average.desc"),
|
||||
RELEASE_DATE_DESC("primary_release_date.desc"),
|
||||
FIRST_AIR_DATE_DESC("first_air_date.desc"),
|
||||
}
|
||||
|
||||
enum class TraktListSort(val value: String) {
|
||||
RANK("rank"),
|
||||
ADDED("added"),
|
||||
TITLE("title"),
|
||||
RELEASED("released"),
|
||||
RUNTIME("runtime"),
|
||||
POPULARITY("popularity"),
|
||||
PERCENTAGE("percentage"),
|
||||
VOTES("votes");
|
||||
|
||||
companion object {
|
||||
fun normalize(value: String?): String {
|
||||
val raw = value?.trim()?.lowercase().orEmpty()
|
||||
return entries.firstOrNull { it.value == raw }?.value ?: RANK.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class TraktSortHow(val value: String) {
|
||||
ASC("asc"),
|
||||
DESC("desc");
|
||||
|
||||
companion object {
|
||||
fun normalize(value: String?): String {
|
||||
val raw = value?.trim()?.lowercase().orEmpty()
|
||||
return entries.firstOrNull { it.value == raw }?.value ?: ASC.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
@Serializable
|
||||
data class TmdbCollectionFilters(
|
||||
val withGenres: String? = null,
|
||||
val releaseDateGte: String? = null,
|
||||
val releaseDateLte: String? = null,
|
||||
val voteAverageGte: Double? = null,
|
||||
val voteAverageLte: Double? = null,
|
||||
val voteCountGte: Int? = null,
|
||||
val withOriginalLanguage: String? = null,
|
||||
val withOriginCountry: String? = null,
|
||||
val withKeywords: String? = null,
|
||||
val withCompanies: String? = null,
|
||||
val withNetworks: String? = null,
|
||||
val year: Int? = null,
|
||||
)
|
||||
|
||||
data class TmdbSourceImportMetadata(
|
||||
val title: String? = null,
|
||||
val coverImageUrl: String? = null,
|
||||
)
|
||||
|
||||
data class TmdbPresetSource(
|
||||
val label: String,
|
||||
val source: CollectionSource,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
@Serializable
|
||||
data class CollectionFolder(
|
||||
|
|
@ -39,9 +169,13 @@ data class CollectionFolder(
|
|||
val focusGifUrl: String? = null,
|
||||
val focusGifEnabled: Boolean = true,
|
||||
val coverEmoji: String? = null,
|
||||
val tileShape: String = "Poster",
|
||||
val tileShape: String = "poster",
|
||||
val hideTitle: Boolean = false,
|
||||
val sources: List<CollectionSource> = emptyList(),
|
||||
val catalogSources: List<CollectionCatalogSource> = emptyList(),
|
||||
val heroBackdropUrl: String? = null,
|
||||
val heroVideoUrl: String? = null,
|
||||
val titleLogoUrl: String? = null,
|
||||
) {
|
||||
val posterShape: PosterShape
|
||||
get() = when (tileShape.lowercase()) {
|
||||
|
|
@ -50,6 +184,22 @@ data class CollectionFolder(
|
|||
"square" -> PosterShape.Square
|
||||
else -> PosterShape.Poster
|
||||
}
|
||||
|
||||
val resolvedSources: List<CollectionSource>
|
||||
get() = sources.ifEmpty {
|
||||
catalogSources.map { source ->
|
||||
CollectionSource(
|
||||
provider = "addon",
|
||||
addonId = source.addonId,
|
||||
type = source.type,
|
||||
catalogId = source.catalogId,
|
||||
genre = source.genre.normalizedOptionalGenre(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val resolvedCatalogSources: List<CollectionCatalogSource>
|
||||
get() = resolvedSources.mapNotNull { it.addonCatalogSource() }
|
||||
}
|
||||
|
||||
@Immutable
|
||||
|
|
@ -67,6 +217,11 @@ data class Collection(
|
|||
get() = FolderViewMode.fromString(viewMode)
|
||||
}
|
||||
|
||||
private fun String?.normalizedOptionalGenre(): String? =
|
||||
this
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() && !it.equals("none", ignoreCase = true) }
|
||||
|
||||
data class AvailableCatalog(
|
||||
val addonId: String,
|
||||
val addonName: String,
|
||||
|
|
|
|||
|
|
@ -4,11 +4,27 @@ import co.touchlab.kermit.Logger
|
|||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.addons.ManagedAddon
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.collections_import_error_collection_blank_id
|
||||
import nuvio.composeapp.generated.resources.collections_import_error_collection_blank_title
|
||||
import nuvio.composeapp.generated.resources.collections_import_error_empty_json
|
||||
import nuvio.composeapp.generated.resources.collections_import_error_folder_blank_id
|
||||
import nuvio.composeapp.generated.resources.collections_import_error_folder_blank_title
|
||||
import nuvio.composeapp.generated.resources.collections_import_error_invalid_json
|
||||
import nuvio.composeapp.generated.resources.collections_import_error_source_blank_fields
|
||||
import nuvio.composeapp.generated.resources.collections_import_error_trakt_list_id
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
|
|
@ -21,6 +37,9 @@ object CollectionRepository {
|
|||
|
||||
private val _collections = MutableStateFlow<List<Collection>>(emptyList())
|
||||
val collections: StateFlow<List<Collection>> = _collections.asStateFlow()
|
||||
private val _localChangeEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
internal val localChangeEvents: SharedFlow<Unit> = _localChangeEvents.asSharedFlow()
|
||||
private var rawCollectionsJson: JsonElement = JsonArray(emptyList())
|
||||
|
||||
private var hasLoaded = false
|
||||
|
||||
|
|
@ -31,6 +50,8 @@ object CollectionRepository {
|
|||
if (payload.isNullOrBlank()) return
|
||||
|
||||
runCatching {
|
||||
val parsed = json.parseToJsonElement(payload)
|
||||
rawCollectionsJson = parsed
|
||||
_collections.value = json.decodeFromString<List<Collection>>(payload)
|
||||
}.onFailure { e ->
|
||||
log.e(e) { "Failed to load collections from storage" }
|
||||
|
|
@ -40,11 +61,13 @@ object CollectionRepository {
|
|||
fun onProfileChanged() {
|
||||
hasLoaded = false
|
||||
_collections.value = emptyList()
|
||||
rawCollectionsJson = JsonArray(emptyList())
|
||||
}
|
||||
|
||||
fun clearLocalState() {
|
||||
hasLoaded = false
|
||||
_collections.value = emptyList()
|
||||
rawCollectionsJson = JsonArray(emptyList())
|
||||
}
|
||||
|
||||
fun getCollection(id: String): Collection? =
|
||||
|
|
@ -71,6 +94,7 @@ object CollectionRepository {
|
|||
}
|
||||
|
||||
fun setCollections(collections: List<Collection>) {
|
||||
ensureLoaded()
|
||||
_collections.value = collections
|
||||
persist()
|
||||
}
|
||||
|
|
@ -96,11 +120,12 @@ object CollectionRepository {
|
|||
|
||||
fun exportToJson(): String {
|
||||
ensureLoaded()
|
||||
return json.encodeToString(_collections.value)
|
||||
return mergedCollectionsJson().toString()
|
||||
}
|
||||
|
||||
fun importFromJson(jsonString: String): Result<List<Collection>> {
|
||||
return runCatching {
|
||||
rawCollectionsJson = json.parseToJsonElement(jsonString)
|
||||
val imported = json.decodeFromString<List<Collection>>(jsonString)
|
||||
_collections.value = imported
|
||||
persist()
|
||||
|
|
@ -110,28 +135,85 @@ object CollectionRepository {
|
|||
|
||||
fun validateJson(jsonString: String): ValidationResult {
|
||||
if (jsonString.isBlank()) {
|
||||
return ValidationResult(valid = false, error = "JSON is empty.")
|
||||
return ValidationResult(
|
||||
valid = false,
|
||||
error = runBlocking { getString(Res.string.collections_import_error_empty_json) },
|
||||
)
|
||||
}
|
||||
return try {
|
||||
val collections = json.decodeFromString<List<Collection>>(jsonString)
|
||||
var totalFolders = 0
|
||||
collections.forEachIndexed { ci, c ->
|
||||
if (c.id.isBlank()) {
|
||||
return ValidationResult(valid = false, error = "Collection ${ci + 1} has blank id.")
|
||||
return ValidationResult(
|
||||
valid = false,
|
||||
error = runBlocking {
|
||||
getString(Res.string.collections_import_error_collection_blank_id, ci + 1)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (c.title.isBlank()) {
|
||||
return ValidationResult(valid = false, error = "Collection '${c.id}' has blank title.")
|
||||
return ValidationResult(
|
||||
valid = false,
|
||||
error = runBlocking {
|
||||
getString(Res.string.collections_import_error_collection_blank_title, c.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
c.folders.forEachIndexed { fi, f ->
|
||||
if (f.id.isBlank()) {
|
||||
return ValidationResult(valid = false, error = "Folder ${fi + 1} in '${c.title}' has blank id.")
|
||||
return ValidationResult(
|
||||
valid = false,
|
||||
error = runBlocking {
|
||||
getString(
|
||||
Res.string.collections_import_error_folder_blank_id,
|
||||
fi + 1,
|
||||
c.title,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (f.title.isBlank()) {
|
||||
return ValidationResult(valid = false, error = "Folder '${f.id}' in '${c.title}' has blank title.")
|
||||
return ValidationResult(
|
||||
valid = false,
|
||||
error = runBlocking {
|
||||
getString(
|
||||
Res.string.collections_import_error_folder_blank_title,
|
||||
f.id,
|
||||
c.title,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
f.catalogSources.forEachIndexed { si, s ->
|
||||
if (s.addonId.isBlank() || s.type.isBlank() || s.catalogId.isBlank()) {
|
||||
return ValidationResult(valid = false, error = "Source ${si + 1} in folder '${f.title}' has blank fields.")
|
||||
f.resolvedSources.forEachIndexed { si, s ->
|
||||
if (s.hasInvalidTraktListId()) {
|
||||
return ValidationResult(
|
||||
valid = false,
|
||||
error = runBlocking {
|
||||
getString(
|
||||
Res.string.collections_import_error_trakt_list_id,
|
||||
si + 1,
|
||||
f.title,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val invalidAddon = !s.isTmdb && !s.isTrakt &&
|
||||
(s.addonId.isNullOrBlank() || s.type.isNullOrBlank() || s.catalogId.isNullOrBlank())
|
||||
val invalidTmdb = s.isTmdb &&
|
||||
s.tmdbSourceType.isNullOrBlank()
|
||||
if (invalidAddon || invalidTmdb) {
|
||||
return ValidationResult(
|
||||
valid = false,
|
||||
error = runBlocking {
|
||||
getString(
|
||||
Res.string.collections_import_error_source_blank_fields,
|
||||
si + 1,
|
||||
f.title,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
totalFolders++
|
||||
|
|
@ -143,7 +225,12 @@ object CollectionRepository {
|
|||
folderCount = totalFolders,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
ValidationResult(valid = false, error = "Invalid JSON: ${e.message}")
|
||||
ValidationResult(
|
||||
valid = false,
|
||||
error = runBlocking {
|
||||
getString(Res.string.collections_import_error_invalid_json, e.message.orEmpty())
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -173,20 +260,29 @@ object CollectionRepository {
|
|||
}
|
||||
}
|
||||
|
||||
internal fun applyFromRemote(collections: List<Collection>) {
|
||||
internal fun applyFromRemote(collections: List<Collection>, rawJson: JsonElement) {
|
||||
rawCollectionsJson = rawJson
|
||||
_collections.value = collections
|
||||
persist()
|
||||
persist(sync = false)
|
||||
}
|
||||
|
||||
private fun ensureLoaded() {
|
||||
if (!hasLoaded) initialize()
|
||||
}
|
||||
|
||||
private fun persist() {
|
||||
private fun persist(sync: Boolean = true) {
|
||||
runCatching {
|
||||
CollectionStorage.savePayload(json.encodeToString(_collections.value))
|
||||
CollectionStorage.savePayload(mergedCollectionsJson().toString())
|
||||
if (sync) {
|
||||
_localChangeEvents.tryEmit(Unit)
|
||||
}
|
||||
}.onFailure { e ->
|
||||
log.e(e) { "Failed to persist collections" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun mergedCollectionsJson(): JsonArray =
|
||||
CollectionJsonPreserver.merge(json, rawCollectionsJson, _collections.value).also {
|
||||
rawCollectionsJson = it
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,11 +15,10 @@ import kotlinx.coroutines.Job
|
|||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
|
|
@ -58,16 +57,13 @@ object CollectionSyncService {
|
|||
return
|
||||
}
|
||||
|
||||
val remoteJson = blob.collectionsJson.toString()
|
||||
val localJson = CollectionRepository.exportToJson()
|
||||
|
||||
if (remoteJson == "[]" || remoteJson == "null") {
|
||||
val currentCollections = CollectionRepository.collections.value
|
||||
if (currentCollections.isNotEmpty()) {
|
||||
log.i { "pullFromServer — remote empty, preserving local ${currentCollections.size} collections" }
|
||||
return
|
||||
}
|
||||
val remoteCollectionsJson = if (blob.collectionsJson == JsonNull) {
|
||||
JsonArray(emptyList())
|
||||
} else {
|
||||
blob.collectionsJson
|
||||
}
|
||||
val remoteJson = remoteCollectionsJson.toString()
|
||||
val localJson = CollectionRepository.exportToJson()
|
||||
|
||||
if (remoteJson == localJson) {
|
||||
log.d { "pullFromServer — remote matches local, no update needed" }
|
||||
|
|
@ -80,7 +76,7 @@ object CollectionSyncService {
|
|||
|
||||
if (remoteCollections != null) {
|
||||
isSyncingFromRemote = true
|
||||
CollectionRepository.applyFromRemote(remoteCollections)
|
||||
CollectionRepository.applyFromRemote(remoteCollections, remoteCollectionsJson)
|
||||
isSyncingFromRemote = false
|
||||
log.i { "pullFromServer — applied ${remoteCollections.size} collections from remote" }
|
||||
} else {
|
||||
|
|
@ -125,9 +121,7 @@ object CollectionSyncService {
|
|||
@OptIn(FlowPreview::class)
|
||||
private fun observeLocalChangesAndPush() {
|
||||
observeJob = scope.launch {
|
||||
CollectionRepository.collections
|
||||
.drop(1)
|
||||
.distinctUntilChanged()
|
||||
CollectionRepository.localChangeEvents
|
||||
.debounce(PUSH_DEBOUNCE_MS)
|
||||
.collect {
|
||||
if (isSyncingFromRemote) return@collect
|
||||
|
|
|
|||
|
|
@ -3,12 +3,18 @@ package com.nuvio.app.features.collection
|
|||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE
|
||||
import com.nuvio.app.features.catalog.CatalogPage
|
||||
import com.nuvio.app.features.catalog.fetchCatalogPage
|
||||
import com.nuvio.app.features.catalog.mergeCatalogItems
|
||||
import com.nuvio.app.features.catalog.supportsPagination
|
||||
import com.nuvio.app.core.i18n.localizedMediaTypeLabel
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
import com.nuvio.app.features.home.HomeCatalogSection
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.home.filterReleasedItems
|
||||
import com.nuvio.app.features.home.stableKey
|
||||
import com.nuvio.app.features.trakt.TraktPublicListSourceResolver
|
||||
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
|
|
@ -17,10 +23,16 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.collections_folder_addon_not_found
|
||||
import nuvio.composeapp.generated.resources.collections_tab_all
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
data class FolderTab(
|
||||
val label: String,
|
||||
val typeLabel: String = "",
|
||||
val source: CollectionSource? = null,
|
||||
val manifestUrl: String? = null,
|
||||
val type: String = "",
|
||||
val catalogId: String = "",
|
||||
|
|
@ -108,36 +120,81 @@ object FolderDetailRepository {
|
|||
return
|
||||
}
|
||||
|
||||
val showAll = collection.showAllTab && folder.catalogSources.size > 1
|
||||
val sources = folder.resolvedSources
|
||||
val showAll = collection.showAllTab && sources.size > 1
|
||||
val addons = AddonRepository.uiState.value.addons
|
||||
|
||||
val tabs = buildList {
|
||||
if (showAll) {
|
||||
add(FolderTab(label = "All", isAllTab = true, isLoading = true))
|
||||
}
|
||||
folder.catalogSources.forEach { source ->
|
||||
val addon = addons.find { it.manifest?.id == source.addonId }
|
||||
val catalog = addon?.manifest?.catalogs?.find {
|
||||
it.id == source.catalogId && it.type == source.type
|
||||
}
|
||||
val label = catalog?.name ?: source.catalogId
|
||||
val typeLabel = source.type.replaceFirstChar {
|
||||
if (it.isLowerCase()) it.titlecase() else it.toString()
|
||||
}
|
||||
val genreSuffix = if (source.genre != null) " · ${source.genre}" else ""
|
||||
add(
|
||||
FolderTab(
|
||||
label = "$label ($typeLabel)$genreSuffix",
|
||||
typeLabel = typeLabel,
|
||||
manifestUrl = addon?.manifestUrl,
|
||||
type = source.type,
|
||||
catalogId = source.catalogId,
|
||||
genre = source.genre,
|
||||
supportsPagination = catalog?.supportsPagination() == true,
|
||||
label = runBlocking { getString(Res.string.collections_tab_all) },
|
||||
isAllTab = true,
|
||||
isLoading = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
sources.forEach { source ->
|
||||
if (source.isTmdb) {
|
||||
val mediaType = TmdbCollectionMediaType.fromString(source.mediaType)
|
||||
val type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie"
|
||||
add(
|
||||
FolderTab(
|
||||
label = source.title?.takeIf { it.isNotBlank() } ?: "TMDB",
|
||||
typeLabel = "TMDB",
|
||||
source = source,
|
||||
type = type,
|
||||
catalogId = tmdbCatalogId(source),
|
||||
supportsPagination = source.tmdbSourceType !in setOf(
|
||||
TmdbCollectionSourceType.COLLECTION.name,
|
||||
TmdbCollectionSourceType.PERSON.name,
|
||||
TmdbCollectionSourceType.DIRECTOR.name,
|
||||
),
|
||||
isLoading = true,
|
||||
),
|
||||
)
|
||||
} else if (source.isTrakt) {
|
||||
val mediaType = TmdbCollectionMediaType.fromString(source.mediaType)
|
||||
val type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie"
|
||||
val typeLabel = if (mediaType == TmdbCollectionMediaType.TV) {
|
||||
"Trakt Series List"
|
||||
} else {
|
||||
"Trakt Movie List"
|
||||
}
|
||||
add(
|
||||
FolderTab(
|
||||
label = source.title?.takeIf { it.isNotBlank() } ?: "Trakt",
|
||||
typeLabel = typeLabel,
|
||||
source = source,
|
||||
type = type,
|
||||
catalogId = traktCatalogId(source),
|
||||
supportsPagination = true,
|
||||
isLoading = true,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
val catalogSource = source.addonCatalogSource() ?: return@forEach
|
||||
val resolvedCatalog = addons.findCollectionCatalog(catalogSource)
|
||||
val addon = resolvedCatalog?.addon
|
||||
val catalog = resolvedCatalog?.catalog
|
||||
val label = catalog?.name ?: catalogSource.catalogId
|
||||
val typeLabel = localizedMediaTypeLabel(catalogSource.type)
|
||||
val genreSuffix = if (catalogSource.genre != null) " · ${catalogSource.genre}" else ""
|
||||
add(
|
||||
FolderTab(
|
||||
label = "$label ($typeLabel)$genreSuffix",
|
||||
typeLabel = typeLabel,
|
||||
source = source,
|
||||
manifestUrl = addon?.manifestUrl,
|
||||
type = catalogSource.type,
|
||||
catalogId = catalogSource.catalogId,
|
||||
genre = catalogSource.genre,
|
||||
supportsPagination = catalog?.supportsPagination() == true,
|
||||
isLoading = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_uiState.value = FolderDetailUiState(
|
||||
|
|
@ -151,11 +208,19 @@ object FolderDetailRepository {
|
|||
)
|
||||
|
||||
// Load catalog data for each source
|
||||
folder.catalogSources.forEachIndexed { sourceIndex, source ->
|
||||
sources.forEachIndexed { sourceIndex, source ->
|
||||
val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex
|
||||
val addon = addons.find { it.manifest?.id == source.addonId }
|
||||
if (addon == null) {
|
||||
updateTab(tabIndex) { it.copy(isLoading = false, error = "Addon not found: ${source.addonId}") }
|
||||
val catalogSource = source.addonCatalogSource()
|
||||
val resolvedCatalog = catalogSource?.let { addons.findCollectionCatalog(it) }
|
||||
if (!source.isTmdb && !source.isTrakt && resolvedCatalog == null) {
|
||||
updateTab(tabIndex) {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = runBlocking {
|
||||
getString(Res.string.collections_folder_addon_not_found, catalogSource?.addonId.orEmpty())
|
||||
},
|
||||
)
|
||||
}
|
||||
return@forEachIndexed
|
||||
}
|
||||
|
||||
|
|
@ -163,7 +228,7 @@ object FolderDetailRepository {
|
|||
}
|
||||
|
||||
// If no sources, mark as done
|
||||
if (folder.catalogSources.isEmpty()) {
|
||||
if (sources.isEmpty()) {
|
||||
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||
}
|
||||
}
|
||||
|
|
@ -212,8 +277,13 @@ object FolderDetailRepository {
|
|||
|
||||
private fun loadTabPage(index: Int, reset: Boolean) {
|
||||
val currentTab = _uiState.value.tabs.getOrNull(index) ?: return
|
||||
val manifestUrl = currentTab.manifestUrl ?: return
|
||||
val requestedSkip = if (reset) 0 else currentTab.nextSkip ?: return
|
||||
val currentSource = currentTab.source
|
||||
if (
|
||||
currentSource?.isTmdb != true &&
|
||||
currentSource?.isTrakt != true &&
|
||||
currentTab.manifestUrl == null
|
||||
) return
|
||||
|
||||
updateTab(index) { tab ->
|
||||
if (reset) {
|
||||
|
|
@ -235,13 +305,26 @@ object FolderDetailRepository {
|
|||
loadJobs.remove(index)?.cancel()
|
||||
val job = scope.launch {
|
||||
runCatching {
|
||||
fetchCatalogPage(
|
||||
manifestUrl = manifestUrl,
|
||||
type = currentTab.type,
|
||||
catalogId = currentTab.catalogId,
|
||||
genre = currentTab.genre,
|
||||
skip = requestedSkip.takeIf { it > 0 },
|
||||
)
|
||||
val source = currentTab.source
|
||||
when {
|
||||
source?.isTmdb == true -> TmdbCollectionSourceResolver.resolve(
|
||||
source = source,
|
||||
page = if (reset) 1 else requestedSkip,
|
||||
)
|
||||
|
||||
source?.isTrakt == true -> TraktPublicListSourceResolver.resolve(
|
||||
source = source,
|
||||
page = if (reset) 1 else requestedSkip,
|
||||
)
|
||||
|
||||
else -> fetchCatalogPage(
|
||||
manifestUrl = requireNotNull(currentTab.manifestUrl),
|
||||
type = currentTab.type,
|
||||
catalogId = currentTab.catalogId,
|
||||
genre = currentTab.genre,
|
||||
skip = requestedSkip.takeIf { it > 0 },
|
||||
)
|
||||
}.withUnreleasedFilter()
|
||||
}.onSuccess { page ->
|
||||
updateTab(index) { tab ->
|
||||
val mergedItems = if (reset) {
|
||||
|
|
@ -262,7 +345,7 @@ object FolderDetailRepository {
|
|||
}
|
||||
rebuildAllTab()
|
||||
}.onFailure { error ->
|
||||
log.e(error) { "Failed to load catalog ${currentTab.catalogId} from $manifestUrl" }
|
||||
log.e(error) { "Failed to load source ${currentTab.catalogId}" }
|
||||
updateTab(index) { tab ->
|
||||
tab.copy(
|
||||
isLoading = false,
|
||||
|
|
@ -336,3 +419,33 @@ object FolderDetailRepository {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Boolean?.orFalse(): Boolean = this == true
|
||||
|
||||
private fun CatalogPage.withUnreleasedFilter(): CatalogPage {
|
||||
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
|
||||
val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate())
|
||||
return if (filteredItems.size == items.size) this else copy(items = filteredItems)
|
||||
}
|
||||
|
||||
private fun tmdbCatalogId(source: CollectionSource): String =
|
||||
buildString {
|
||||
append("tmdb_")
|
||||
append(source.tmdbSourceType?.lowercase().orEmpty())
|
||||
source.tmdbId?.let {
|
||||
append("_")
|
||||
append(it)
|
||||
}
|
||||
append("_")
|
||||
append(source.mediaType?.lowercase().orEmpty())
|
||||
}
|
||||
|
||||
private fun traktCatalogId(source: CollectionSource): String =
|
||||
listOf(
|
||||
"trakt",
|
||||
"list",
|
||||
source.traktListId?.toString().orEmpty(),
|
||||
source.mediaType?.lowercase().orEmpty(),
|
||||
TraktListSort.normalize(source.sortBy),
|
||||
TraktSortHow.normalize(source.sortHow),
|
||||
).joinToString("_")
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ import com.nuvio.app.core.ui.NuvioPosterCard
|
|||
import com.nuvio.app.core.ui.NuvioPosterShape
|
||||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
||||
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
|
||||
import com.nuvio.app.features.home.HomeCatalogSection
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.home.PosterShape
|
||||
|
|
@ -63,6 +64,11 @@ import com.nuvio.app.features.home.components.HomeCatalogRowSection
|
|||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.collections_folder_empty_items
|
||||
import nuvio.composeapp.generated.resources.collections_folder_not_found
|
||||
import nuvio.composeapp.generated.resources.collections_tab_all
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
private val FolderCoverHeight = 176.dp
|
||||
|
||||
|
|
@ -143,7 +149,7 @@ fun FolderDetailScreen(
|
|||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "Folder not found",
|
||||
text = stringResource(Res.string.collections_folder_not_found),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -229,7 +235,11 @@ private fun TabbedGridContent(
|
|||
onClick = { onTabSelected(index) },
|
||||
text = {
|
||||
Text(
|
||||
text = tab.label,
|
||||
text = if (tab.isAllTab) {
|
||||
stringResource(Res.string.collections_tab_all)
|
||||
} else {
|
||||
tab.label
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
|
@ -266,9 +276,10 @@ private fun TabbedGridContent(
|
|||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
items(
|
||||
items = selectedTab.items,
|
||||
key = { item -> item.stableKey() },
|
||||
) { item ->
|
||||
items = selectedTab.items.withDuplicateSafeLazyKeys { item -> item.stableKey() },
|
||||
key = { item -> item.lazyKey },
|
||||
) { keyedItem ->
|
||||
val item = keyedItem.value
|
||||
NuvioPosterCard(
|
||||
title = item.name,
|
||||
imageUrl = item.poster,
|
||||
|
|
@ -317,9 +328,10 @@ private fun RowsContent(
|
|||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(
|
||||
items = sections,
|
||||
key = { it.key },
|
||||
) { section ->
|
||||
items = sections.withDuplicateSafeLazyKeys { it.key },
|
||||
key = { it.lazyKey },
|
||||
) { keyedSection ->
|
||||
val section = keyedSection.value
|
||||
HomeCatalogRowSection(
|
||||
section = section,
|
||||
entries = section.items.take(18),
|
||||
|
|
@ -395,7 +407,7 @@ private fun EmptyMessage() {
|
|||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "No items found",
|
||||
text = stringResource(Res.string.collections_folder_empty_items),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
|
|
|
|||
|
|
@ -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.StreamItem
|
||||
import com.nuvio.app.features.streams.StreamProxyHeaders
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
|
@ -13,6 +14,8 @@ import kotlinx.serialization.json.contentOrNull
|
|||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
internal object MetaDetailsParser {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
|
@ -248,10 +251,10 @@ internal object MetaDetailsParser {
|
|||
MetaTrailer(
|
||||
id = trailer.string("id")?.takeIf(String::isNotBlank) ?: normalizedKey,
|
||||
key = normalizedKey,
|
||||
name = trailer.string("name")?.takeIf(String::isNotBlank) ?: "Trailer",
|
||||
name = trailer.string("name")?.takeIf(String::isNotBlank) ?: runBlocking { getString(Res.string.generic_trailer) },
|
||||
site = trailer.string("site")?.takeIf(String::isNotBlank) ?: "YouTube",
|
||||
size = trailer.int("size"),
|
||||
type = trailer.string("type")?.takeIf(String::isNotBlank) ?: "Trailer",
|
||||
type = trailer.string("type")?.takeIf(String::isNotBlank) ?: runBlocking { getString(Res.string.generic_trailer) },
|
||||
official = trailer.boolean("official") == true,
|
||||
publishedAt = trailer.string("published_at") ?: trailer.string("publishedAt"),
|
||||
seasonNumber = trailer.int("seasonNumber") ?: trailer.int("season_number"),
|
||||
|
|
@ -273,7 +276,9 @@ internal object MetaDetailsParser {
|
|||
?.objectValue("proxyHeaders")
|
||||
?.toProxyHeaders()
|
||||
val streamData = obj["streamData"] as? JsonObject
|
||||
val addonName = streamData?.string("addon") ?: obj.string("name") ?: "Embedded"
|
||||
val addonName = streamData?.string("addon")
|
||||
?: obj.string("name")
|
||||
?: runBlocking { getString(Res.string.source_embedded) }
|
||||
StreamItem(
|
||||
name = obj.string("name"),
|
||||
description = obj.string("description") ?: obj.string("title"),
|
||||
|
|
|
|||
|
|
@ -5,10 +5,14 @@ import com.nuvio.app.features.addons.AddonManifest
|
|||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.addons.buildAddonResourceUrl
|
||||
import com.nuvio.app.features.addons.httpGetText
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
import com.nuvio.app.features.home.filterReleasedItems
|
||||
import com.nuvio.app.features.mdblist.MdbListMetadataService
|
||||
import com.nuvio.app.features.mdblist.MdbListSettingsRepository
|
||||
import com.nuvio.app.features.tmdb.TmdbMetadataService
|
||||
import com.nuvio.app.features.tmdb.TmdbService
|
||||
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
||||
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -19,6 +23,8 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
object MetaDetailsRepository {
|
||||
private data class CachedMetaEntry(
|
||||
|
|
@ -45,14 +51,14 @@ object MetaDetailsRepository {
|
|||
cachedEntry.metaScreenMeta
|
||||
?.takeIf { cachedEntry.metaScreenSettingsFingerprint == metaScreenSettingsFingerprint }
|
||||
?.let { cachedMeta ->
|
||||
_uiState.value = MetaDetailsUiState(meta = cachedMeta)
|
||||
_uiState.value = MetaDetailsUiState(meta = cachedMeta.withUnreleasedFilter())
|
||||
activeRequestKey = requestKey
|
||||
return
|
||||
}
|
||||
|
||||
val cachedBaseMeta = cachedEntry.baseMeta
|
||||
if (!shouldFetchMdbListOnMetaScreen(cachedBaseMeta, id, mdbListSettings)) {
|
||||
_uiState.value = MetaDetailsUiState(meta = cachedBaseMeta)
|
||||
_uiState.value = MetaDetailsUiState(meta = cachedBaseMeta.withUnreleasedFilter())
|
||||
activeRequestKey = requestKey
|
||||
return
|
||||
}
|
||||
|
|
@ -78,7 +84,7 @@ object MetaDetailsRepository {
|
|||
settingsFingerprint = metaScreenSettingsFingerprint,
|
||||
)
|
||||
}
|
||||
_uiState.value = MetaDetailsUiState(meta = enrichedMeta)
|
||||
_uiState.value = MetaDetailsUiState(meta = enrichedMeta.withUnreleasedFilter())
|
||||
activeRequestKey = requestKey
|
||||
}
|
||||
return
|
||||
|
|
@ -99,20 +105,25 @@ object MetaDetailsRepository {
|
|||
_uiState.value = MetaDetailsUiState(isLoading = true)
|
||||
|
||||
scope.launch {
|
||||
val manifests = AddonRepository.uiState.value.addons
|
||||
.mapNotNull { it.manifest }
|
||||
.filter { manifest ->
|
||||
manifest.resources.any { resource ->
|
||||
resource.name == "meta" &&
|
||||
resource.types.contains(type) &&
|
||||
(resource.idPrefixes.isEmpty() || resource.idPrefixes.any { id.startsWith(it) })
|
||||
}
|
||||
}
|
||||
val metaLookupId = resolveMetaLookupId(itemId = id, itemType = type)
|
||||
val manifests = findMetaManifests(type = type, id = metaLookupId)
|
||||
|
||||
if (manifests.isEmpty()) {
|
||||
val tmdbMeta = tryFetchTmdbFallbackMeta(type = type, id = id)
|
||||
if (tmdbMeta != null) {
|
||||
publishLoadedMeta(
|
||||
requestKey = requestKey,
|
||||
meta = tmdbMeta,
|
||||
fallbackItemId = id,
|
||||
mdbListSettings = mdbListSettings,
|
||||
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
log.w { "No addon provides meta for type=$type id=$id" }
|
||||
_uiState.value = MetaDetailsUiState(
|
||||
errorMessage = "No addon provides meta for this content.",
|
||||
errorMessage = getString(Res.string.details_no_addon_meta),
|
||||
)
|
||||
activeRequestKey = null
|
||||
return@launch
|
||||
|
|
@ -120,44 +131,34 @@ object MetaDetailsRepository {
|
|||
|
||||
for (manifest in manifests) {
|
||||
val result = withContext(Dispatchers.Default) {
|
||||
tryFetchMeta(manifest, type, id, includeMdbList = false)
|
||||
tryFetchMeta(manifest, type, metaLookupId, includeMdbList = false)
|
||||
}
|
||||
if (result != null) {
|
||||
var cachedEntry = CachedMetaEntry(baseMeta = result)
|
||||
cachedMetaByRequestKey[requestKey] = cachedEntry
|
||||
|
||||
if (!shouldFetchMdbListOnMetaScreen(result, id, mdbListSettings)) {
|
||||
_uiState.value = MetaDetailsUiState(meta = result)
|
||||
activeRequestKey = requestKey
|
||||
return@launch
|
||||
}
|
||||
|
||||
_uiState.value = MetaDetailsUiState(
|
||||
isLoading = true,
|
||||
publishLoadedMeta(
|
||||
requestKey = requestKey,
|
||||
meta = result,
|
||||
)
|
||||
val enrichedMeta = withContext(Dispatchers.Default) {
|
||||
enrichForMetaScreen(
|
||||
requestKey = requestKey,
|
||||
meta = result,
|
||||
fallbackItemId = id,
|
||||
settings = mdbListSettings,
|
||||
settingsFingerprint = metaScreenSettingsFingerprint,
|
||||
)
|
||||
}
|
||||
cachedEntry = cachedEntry.copy(
|
||||
metaScreenMeta = enrichedMeta,
|
||||
fallbackItemId = metaLookupId,
|
||||
mdbListSettings = mdbListSettings,
|
||||
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
|
||||
)
|
||||
cachedMetaByRequestKey[requestKey] = cachedEntry
|
||||
_uiState.value = MetaDetailsUiState(meta = enrichedMeta)
|
||||
activeRequestKey = requestKey
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
val tmdbMeta = tryFetchTmdbFallbackMeta(type = type, id = id)
|
||||
if (tmdbMeta != null) {
|
||||
publishLoadedMeta(
|
||||
requestKey = requestKey,
|
||||
meta = tmdbMeta,
|
||||
fallbackItemId = id,
|
||||
mdbListSettings = mdbListSettings,
|
||||
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
_uiState.value = MetaDetailsUiState(
|
||||
errorMessage = "Could not load details from any addon.",
|
||||
errorMessage = getString(Res.string.details_load_failed_all_addons),
|
||||
)
|
||||
activeRequestKey = null
|
||||
}
|
||||
|
|
@ -185,19 +186,12 @@ object MetaDetailsRepository {
|
|||
val requestKey = "$type:$id"
|
||||
cachedMetaByRequestKey[requestKey]?.let { return it.baseMeta }
|
||||
|
||||
val manifests = AddonRepository.uiState.value.addons
|
||||
.mapNotNull { it.manifest }
|
||||
.filter { manifest ->
|
||||
manifest.resources.any { resource ->
|
||||
resource.name == "meta" &&
|
||||
resource.types.contains(type) &&
|
||||
(resource.idPrefixes.isEmpty() || resource.idPrefixes.any { id.startsWith(it) })
|
||||
}
|
||||
}
|
||||
val metaLookupId = resolveMetaLookupId(itemId = id, itemType = type)
|
||||
val manifests = findMetaManifests(type = type, id = metaLookupId)
|
||||
|
||||
for (manifest in manifests) {
|
||||
val result = withTimeoutOrNull(FETCH_TIMEOUT_MS) {
|
||||
tryFetchMeta(manifest, type, id, includeMdbList = false)
|
||||
tryFetchMeta(manifest, type, metaLookupId, includeMdbList = false)
|
||||
}
|
||||
if (result != null) {
|
||||
cachedMetaByRequestKey[requestKey] = CachedMetaEntry(baseMeta = result)
|
||||
|
|
@ -205,7 +199,9 @@ object MetaDetailsRepository {
|
|||
}
|
||||
}
|
||||
|
||||
return null
|
||||
return tryFetchTmdbFallbackMeta(type = type, id = id)?.also { result ->
|
||||
cachedMetaByRequestKey[requestKey] = CachedMetaEntry(baseMeta = result)
|
||||
}
|
||||
}
|
||||
|
||||
private const val FETCH_TIMEOUT_MS = 5_000L
|
||||
|
|
@ -263,6 +259,78 @@ object MetaDetailsRepository {
|
|||
}
|
||||
}
|
||||
|
||||
private fun findMetaManifests(type: String, id: String): List<AddonManifest> =
|
||||
AddonRepository.uiState.value.addons
|
||||
.mapNotNull { it.manifest }
|
||||
.filter { manifest ->
|
||||
manifest.resources.any { resource ->
|
||||
resource.name == "meta" &&
|
||||
resource.types.contains(type) &&
|
||||
(resource.idPrefixes.isEmpty() || resource.idPrefixes.any { id.startsWith(it) })
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveMetaLookupId(itemId: String, itemType: String): String {
|
||||
val tmdbId = itemId
|
||||
.takeIf { it.startsWith("tmdb:", ignoreCase = true) }
|
||||
?.substringAfter(':')
|
||||
?.substringBefore(':')
|
||||
?.toIntOrNull()
|
||||
?: return itemId
|
||||
|
||||
return withTimeoutOrNull(FETCH_TIMEOUT_MS) {
|
||||
TmdbService.tmdbToImdb(tmdbId = tmdbId, mediaType = itemType)
|
||||
}
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: itemId
|
||||
}
|
||||
|
||||
private suspend fun tryFetchTmdbFallbackMeta(type: String, id: String): MetaDetails? =
|
||||
withTimeoutOrNull(TMDB_ENRICH_TIMEOUT_MS) {
|
||||
TmdbMetadataService.fetchStandaloneMeta(
|
||||
type = type,
|
||||
id = id,
|
||||
settings = TmdbSettingsRepository.snapshot(),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun publishLoadedMeta(
|
||||
requestKey: String,
|
||||
meta: MetaDetails,
|
||||
fallbackItemId: String,
|
||||
mdbListSettings: com.nuvio.app.features.mdblist.MdbListSettings,
|
||||
metaScreenSettingsFingerprint: String,
|
||||
) {
|
||||
val cachedEntry = CachedMetaEntry(baseMeta = meta)
|
||||
cachedMetaByRequestKey[requestKey] = cachedEntry
|
||||
|
||||
if (!shouldFetchMdbListOnMetaScreen(meta, fallbackItemId, mdbListSettings)) {
|
||||
_uiState.value = MetaDetailsUiState(meta = meta.withUnreleasedFilter())
|
||||
activeRequestKey = requestKey
|
||||
return
|
||||
}
|
||||
|
||||
_uiState.value = MetaDetailsUiState(
|
||||
isLoading = true,
|
||||
meta = meta,
|
||||
)
|
||||
val enrichedMeta = withContext(Dispatchers.Default) {
|
||||
enrichForMetaScreen(
|
||||
requestKey = requestKey,
|
||||
meta = meta,
|
||||
fallbackItemId = fallbackItemId,
|
||||
settings = mdbListSettings,
|
||||
settingsFingerprint = metaScreenSettingsFingerprint,
|
||||
)
|
||||
}
|
||||
cachedMetaByRequestKey[requestKey] = cachedEntry.copy(
|
||||
metaScreenMeta = enrichedMeta,
|
||||
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
|
||||
)
|
||||
_uiState.value = MetaDetailsUiState(meta = enrichedMeta.withUnreleasedFilter())
|
||||
activeRequestKey = requestKey
|
||||
}
|
||||
|
||||
private suspend fun enrichForMetaScreen(
|
||||
requestKey: String,
|
||||
meta: MetaDetails,
|
||||
|
|
@ -309,6 +377,15 @@ object MetaDetailsRepository {
|
|||
return "${settings.enabled}:${settings.apiKey.trim()}:$providers"
|
||||
}
|
||||
|
||||
private fun MetaDetails.withUnreleasedFilter(): MetaDetails {
|
||||
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
|
||||
val todayIsoDate = CurrentDateProvider.todayIsoDate()
|
||||
return copy(
|
||||
moreLikeThis = moreLikeThis.filterReleasedItems(todayIsoDate),
|
||||
collectionItems = collectionItems.filterReleasedItems(todayIsoDate),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
fun findEmbeddedStreams(videoId: String): List<com.nuvio.app.features.streams.StreamItem> {
|
||||
val meta = _uiState.value.meta ?: return emptyList()
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ import com.nuvio.app.features.library.LibraryRepository
|
|||
import com.nuvio.app.features.library.toLibraryItem
|
||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||
import com.nuvio.app.features.streams.StreamAutoPlayPolicy
|
||||
import com.nuvio.app.features.tmdb.TmdbService
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktCommentReview
|
||||
import com.nuvio.app.features.trakt.TraktCommentsRepository
|
||||
|
|
@ -100,6 +101,9 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepositor
|
|||
import com.nuvio.app.features.watching.application.WatchingActions
|
||||
import com.nuvio.app.features.watching.application.WatchingState
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
|
|
@ -164,6 +168,7 @@ fun MetaDetailsScreen(
|
|||
var pickerMembership by remember(type, id) { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
|
||||
var pickerPending by remember(type, id) { mutableStateOf(false) }
|
||||
var pickerError by remember(type, id) { mutableStateOf<String?>(null) }
|
||||
var episodeImdbRatings by remember(type, id) { mutableStateOf<Map<Pair<Int, Int>, Double>>(emptyMap()) }
|
||||
|
||||
val shouldShowComments = commentsEnabled &&
|
||||
traktAuthUiState.mode == TraktConnectionMode.CONNECTED &&
|
||||
|
|
@ -186,11 +191,35 @@ fun MetaDetailsScreen(
|
|||
commentsCurrentPage = result.currentPage
|
||||
commentsPageCount = result.pageCount
|
||||
} catch (e: Exception) {
|
||||
commentsError = e.message ?: "Failed to load comments"
|
||||
commentsError = e.message ?: getString(Res.string.details_comments_load_failed)
|
||||
}
|
||||
isCommentsLoading = false
|
||||
}
|
||||
|
||||
LaunchedEffect(displayedMeta?.id, displayedMeta?.videos) {
|
||||
val metaForRatings = displayedMeta
|
||||
if (metaForRatings == null || !metaForRatings.isSeriesLikeForEpisodeRatings()) {
|
||||
episodeImdbRatings = emptyMap()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val imdbId = extractImdbId(metaForRatings.id) ?: extractImdbId(id)
|
||||
val tmdbId = extractTmdbId(metaForRatings.id)
|
||||
?: extractTmdbId(id)
|
||||
?: TmdbService.ensureTmdbId(metaForRatings.id, metaForRatings.type)?.toIntOrNull()
|
||||
?: TmdbService.ensureTmdbId(id, type)?.toIntOrNull()
|
||||
|
||||
if (imdbId == null && tmdbId == null) {
|
||||
episodeImdbRatings = emptyMap()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
episodeImdbRatings = ImdbEpisodeRatingsRepository.getEpisodeRatings(
|
||||
imdbId = imdbId,
|
||||
tmdbId = tmdbId,
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(type, id, displayedMeta, uiState.isLoading, autoLoadAttempted) {
|
||||
if (!autoLoadAttempted && displayedMeta == null && !uiState.isLoading) {
|
||||
autoLoadAttempted = true
|
||||
|
|
@ -242,14 +271,14 @@ fun MetaDetailsScreen(
|
|||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Failed to load",
|
||||
text = stringResource(Res.string.details_failed_to_load),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Text(
|
||||
text = when (networkStatusUiState.condition) {
|
||||
NetworkCondition.NoInternet -> "Check your Wi-Fi or mobile data connection and try again."
|
||||
NetworkCondition.ServersUnreachable -> "Your device is online, but Nuvio could not reach required servers."
|
||||
NetworkCondition.NoInternet -> stringResource(Res.string.details_check_connection)
|
||||
NetworkCondition.ServersUnreachable -> stringResource(Res.string.details_servers_unreachable)
|
||||
else -> uiState.errorMessage.orEmpty()
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
|
|
@ -262,7 +291,7 @@ fun MetaDetailsScreen(
|
|||
MetaDetailsRepository.load(type, id)
|
||||
},
|
||||
) {
|
||||
Text("Retry")
|
||||
Text(stringResource(Res.string.action_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -273,39 +302,39 @@ fun MetaDetailsScreen(
|
|||
val isSaved = remember(
|
||||
libraryUiState.items,
|
||||
libraryUiState.sections,
|
||||
traktAuthUiState.mode,
|
||||
libraryUiState.sourceMode,
|
||||
meta.id,
|
||||
meta.type,
|
||||
) {
|
||||
LibraryRepository.isSaved(meta.id, meta.type)
|
||||
}
|
||||
val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED
|
||||
val toggleSaved = remember(meta, isTraktConnected) {
|
||||
val openLibraryListPicker = remember(meta) {
|
||||
{
|
||||
val libraryItem = meta.toLibraryItem(savedAtEpochMs = 0L)
|
||||
if (!isTraktConnected) {
|
||||
LibraryRepository.toggleSaved(libraryItem)
|
||||
} else {
|
||||
pickerTabs = LibraryRepository.traktListTabs()
|
||||
pickerMembership = pickerTabs.associate { it.key to false }
|
||||
pickerPending = true
|
||||
pickerError = null
|
||||
showLibraryListPicker = true
|
||||
detailsScope.launch {
|
||||
runCatching {
|
||||
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
|
||||
val tabs = LibraryRepository.traktListTabs()
|
||||
pickerTabs = tabs
|
||||
pickerMembership = tabs.associate { tab ->
|
||||
tab.key to (snapshot[tab.key] == true)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
pickerError = error.message ?: "Failed to load Trakt lists"
|
||||
pickerTabs = LibraryRepository.libraryListTabs()
|
||||
pickerMembership = pickerTabs.associate { it.key to false }
|
||||
pickerPending = true
|
||||
pickerError = null
|
||||
showLibraryListPicker = true
|
||||
detailsScope.launch {
|
||||
runCatching {
|
||||
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
|
||||
val tabs = LibraryRepository.libraryListTabs()
|
||||
pickerTabs = tabs
|
||||
pickerMembership = tabs.associate { tab ->
|
||||
tab.key to (snapshot[tab.key] == true)
|
||||
}
|
||||
pickerPending = false
|
||||
}.onFailure { error ->
|
||||
pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed)
|
||||
}
|
||||
Unit
|
||||
pickerPending = false
|
||||
}
|
||||
Unit
|
||||
}
|
||||
}
|
||||
val toggleSaved = remember(meta) {
|
||||
{
|
||||
LibraryRepository.toggleSaved(meta.toLibraryItem(savedAtEpochMs = 0L))
|
||||
}
|
||||
}
|
||||
val movieProgress = watchProgressUiState.byVideoId[meta.id]
|
||||
|
|
@ -394,7 +423,7 @@ fun MetaDetailsScreen(
|
|||
}
|
||||
trailerPlaybackSource = resolvedSource
|
||||
trailerErrorMessage = if (resolvedSource == null) {
|
||||
"No playable trailer stream found."
|
||||
getString(Res.string.trailer_no_playable_stream)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
|
@ -403,13 +432,15 @@ fun MetaDetailsScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
val playButtonLabel = remember(movieProgress, seriesAction, meta.type, hasEpisodes) {
|
||||
val playText = stringResource(Res.string.action_play)
|
||||
val resumeText = stringResource(Res.string.action_resume)
|
||||
val playButtonLabel = remember(movieProgress, seriesAction, meta.type, hasEpisodes, playText, resumeText) {
|
||||
when {
|
||||
(meta.type == "series" || hasEpisodes) && seriesAction != null ->
|
||||
seriesAction.label
|
||||
meta.type != "series" && !hasEpisodes && movieProgress != null ->
|
||||
"Resume"
|
||||
else -> "Play"
|
||||
resumeText
|
||||
else -> playText
|
||||
}
|
||||
}
|
||||
val onPrimaryPlayClick: () -> Unit = {
|
||||
|
|
@ -634,8 +665,10 @@ fun MetaDetailsScreen(
|
|||
onPrimaryPlayClick = onPrimaryPlayClick,
|
||||
onPrimaryPlayLongClick = onPrimaryPlayLongClick,
|
||||
onSaveClick = toggleSaved,
|
||||
onSaveLongClick = openLibraryListPicker,
|
||||
showManualPlayOption = showManualPlayOption,
|
||||
preferredEpisodeSeasonNumber = seriesAction?.seasonNumber,
|
||||
preferredEpisodeNumber = seriesAction?.episodeNumber,
|
||||
hasProductionSection = hasProductionSection,
|
||||
hasTrailersSection = hasTrailersSection,
|
||||
hasEpisodes = hasEpisodes,
|
||||
|
|
@ -649,6 +682,7 @@ fun MetaDetailsScreen(
|
|||
commentsCurrentPage = commentsCurrentPage,
|
||||
commentsPageCount = commentsPageCount,
|
||||
commentsError = commentsError,
|
||||
episodeImdbRatings = episodeImdbRatings,
|
||||
onRetryComments = {
|
||||
detailsScope.launch {
|
||||
isCommentsLoading = true
|
||||
|
|
@ -659,7 +693,7 @@ fun MetaDetailsScreen(
|
|||
commentsCurrentPage = result.currentPage
|
||||
commentsPageCount = result.pageCount
|
||||
} catch (e: Exception) {
|
||||
commentsError = e.message ?: "Failed to load comments"
|
||||
commentsError = e.message ?: getString(Res.string.details_comments_load_failed)
|
||||
}
|
||||
isCommentsLoading = false
|
||||
}
|
||||
|
|
@ -683,6 +717,7 @@ fun MetaDetailsScreen(
|
|||
onTrailerClick = resolveTrailer,
|
||||
progressByVideoId = watchProgressUiState.byVideoId,
|
||||
watchedKeys = watchedUiState.watchedKeys,
|
||||
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
|
||||
onEpisodeClick = onEpisodePlayClick,
|
||||
onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
|
||||
onOpenMeta = onOpenMeta,
|
||||
|
|
@ -779,7 +814,9 @@ fun MetaDetailsScreen(
|
|||
}
|
||||
EpisodeWatchedActionSheet(
|
||||
episode = selectedEpisode,
|
||||
seasonLabel = selectedEpisode.season?.let { "Season $it" } ?: "Specials",
|
||||
seasonLabel = selectedEpisode.season?.let {
|
||||
stringResource(Res.string.episodes_season, it)
|
||||
} ?: stringResource(Res.string.episodes_specials),
|
||||
isEpisodeWatched = isSelectedEpisodeWatched,
|
||||
canMarkPreviousEpisodes = previousEpisodes.isNotEmpty(),
|
||||
arePreviousEpisodesWatched = arePreviousEpisodesWatched,
|
||||
|
|
@ -864,7 +901,7 @@ fun MetaDetailsScreen(
|
|||
}.onSuccess {
|
||||
showLibraryListPicker = false
|
||||
}.onFailure { error ->
|
||||
pickerError = error.message ?: "Failed to update Trakt lists"
|
||||
pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed)
|
||||
}
|
||||
pickerPending = false
|
||||
}
|
||||
|
|
@ -927,6 +964,30 @@ fun MetaDetailsScreen(
|
|||
}
|
||||
}
|
||||
|
||||
private fun MetaDetails.isSeriesLikeForEpisodeRatings(): Boolean {
|
||||
val normalizedType = type.trim().lowercase()
|
||||
val hasNumberedEpisodes = videos.any { it.season != null && it.episode != null }
|
||||
return hasNumberedEpisodes && normalizedType in setOf("series", "show", "tv", "tvshow")
|
||||
}
|
||||
|
||||
private fun extractImdbId(value: String?): String? =
|
||||
value
|
||||
?.trim()
|
||||
?.split(':', '/', '?', '&')
|
||||
?.firstOrNull { part -> part.startsWith("tt", ignoreCase = true) }
|
||||
?.takeIf { it.length > 2 }
|
||||
|
||||
private fun extractTmdbId(value: String?): Int? {
|
||||
val trimmed = value?.trim().orEmpty()
|
||||
if (trimmed.isBlank()) return null
|
||||
return trimmed
|
||||
.takeIf { it.startsWith("tmdb:", ignoreCase = true) }
|
||||
?.substringAfter(':')
|
||||
?.substringBefore(':')
|
||||
?.substringBefore('/')
|
||||
?.toIntOrNull()
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
private fun ConfiguredMetaSections(
|
||||
|
|
@ -938,8 +999,10 @@ private fun ConfiguredMetaSections(
|
|||
onPrimaryPlayClick: () -> Unit,
|
||||
onPrimaryPlayLongClick: (() -> Unit)?,
|
||||
onSaveClick: () -> Unit,
|
||||
onSaveLongClick: (() -> Unit)?,
|
||||
showManualPlayOption: Boolean,
|
||||
preferredEpisodeSeasonNumber: Int?,
|
||||
preferredEpisodeNumber: Int?,
|
||||
hasProductionSection: Boolean,
|
||||
hasTrailersSection: Boolean,
|
||||
hasEpisodes: Boolean,
|
||||
|
|
@ -953,12 +1016,14 @@ private fun ConfiguredMetaSections(
|
|||
commentsCurrentPage: Int,
|
||||
commentsPageCount: Int,
|
||||
commentsError: String?,
|
||||
episodeImdbRatings: Map<Pair<Int, Int>, Double>,
|
||||
onRetryComments: () -> Unit,
|
||||
onLoadMoreComments: () -> Unit,
|
||||
onCommentClick: (TraktCommentReview) -> Unit,
|
||||
onTrailerClick: (MetaTrailer) -> Unit,
|
||||
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||
watchedKeys: Set<String>,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
onEpisodeClick: (MetaVideo) -> Unit,
|
||||
onEpisodeLongPress: (MetaVideo) -> Unit,
|
||||
onOpenMeta: ((MetaPreview) -> Unit)?,
|
||||
|
|
@ -991,12 +1056,17 @@ private fun ConfiguredMetaSections(
|
|||
MetaScreenSectionKey.ACTIONS -> {
|
||||
DetailActionButtons(
|
||||
playLabel = playButtonLabel,
|
||||
saveLabel = if (isSaved) "Saved" else "Save",
|
||||
saveLabel = if (isSaved) {
|
||||
stringResource(Res.string.action_saved)
|
||||
} else {
|
||||
stringResource(Res.string.action_save)
|
||||
},
|
||||
isSaved = isSaved,
|
||||
isTablet = isTablet,
|
||||
onPlayClick = onPrimaryPlayClick,
|
||||
onPlayLongClick = if (showManualPlayOption) onPrimaryPlayLongClick else null,
|
||||
onSaveClick = onSaveClick,
|
||||
onSaveLongClick = onSaveLongClick,
|
||||
)
|
||||
}
|
||||
MetaScreenSectionKey.OVERVIEW -> {
|
||||
|
|
@ -1042,9 +1112,12 @@ private fun ConfiguredMetaSections(
|
|||
meta = meta,
|
||||
showHeader = showHeader,
|
||||
preferredSeasonNumber = preferredEpisodeSeasonNumber,
|
||||
preferredEpisodeNumber = preferredEpisodeNumber,
|
||||
episodeCardStyle = settings.episodeCardStyle,
|
||||
progressByVideoId = progressByVideoId,
|
||||
watchedKeys = watchedKeys,
|
||||
episodeRatings = episodeImdbRatings,
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
onEpisodeClick = onEpisodeClick,
|
||||
onEpisodeLongPress = onEpisodeLongPress,
|
||||
)
|
||||
|
|
@ -1069,7 +1142,7 @@ private fun ConfiguredMetaSections(
|
|||
MetaScreenSectionKey.MORE_LIKE_THIS -> {
|
||||
if (hasMoreLikeThisSection) {
|
||||
DetailPosterRailSection(
|
||||
title = "More Like This",
|
||||
title = stringResource(Res.string.details_more_like_this),
|
||||
items = meta.moreLikeThis,
|
||||
watchedKeys = watchedKeys,
|
||||
showHeader = showHeader,
|
||||
|
|
|
|||
|
|
@ -3,11 +3,15 @@ package com.nuvio.app.features.details
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
enum class MetaScreenSectionKey {
|
||||
ACTIONS,
|
||||
|
|
@ -41,6 +45,7 @@ data class MetaScreenSettingsUiState(
|
|||
val cinematicBackground: Boolean = false,
|
||||
val tabLayout: Boolean = false,
|
||||
val episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
||||
val blurUnwatchedEpisodes: Boolean = false,
|
||||
)
|
||||
|
||||
enum class MetaEpisodeCardStyle {
|
||||
|
|
@ -77,12 +82,14 @@ private data class StoredMetaScreenSettingsPayload(
|
|||
@SerialName("tvStyleLayout")
|
||||
val tabLayout: Boolean = false,
|
||||
val episodeCardStyle: String = "horizontal",
|
||||
@SerialName("blur_unwatched_episodes")
|
||||
val blurUnwatchedEpisodes: Boolean = false,
|
||||
)
|
||||
|
||||
private data class MetaScreenSectionDefinition(
|
||||
val key: MetaScreenSectionKey,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val titleRes: StringResource,
|
||||
val descriptionRes: StringResource,
|
||||
)
|
||||
|
||||
object MetaScreenSettingsRepository {
|
||||
|
|
@ -94,53 +101,53 @@ object MetaScreenSettingsRepository {
|
|||
private val definitions = listOf(
|
||||
MetaScreenSectionDefinition(
|
||||
key = MetaScreenSectionKey.ACTIONS,
|
||||
title = "Actions",
|
||||
description = "Play and save controls.",
|
||||
titleRes = Res.string.meta_section_actions_title,
|
||||
descriptionRes = Res.string.meta_section_actions_description,
|
||||
),
|
||||
MetaScreenSectionDefinition(
|
||||
key = MetaScreenSectionKey.OVERVIEW,
|
||||
title = "Overview",
|
||||
description = "Synopsis, ratings, genres, and core credits.",
|
||||
titleRes = Res.string.meta_section_overview_title,
|
||||
descriptionRes = Res.string.meta_section_overview_description,
|
||||
),
|
||||
MetaScreenSectionDefinition(
|
||||
key = MetaScreenSectionKey.PRODUCTION,
|
||||
title = "Production",
|
||||
description = "Studios and networks.",
|
||||
titleRes = Res.string.meta_section_production_title,
|
||||
descriptionRes = Res.string.meta_section_production_description,
|
||||
),
|
||||
MetaScreenSectionDefinition(
|
||||
key = MetaScreenSectionKey.CAST,
|
||||
title = "Cast",
|
||||
description = "Principal cast list.",
|
||||
titleRes = Res.string.settings_meta_cast,
|
||||
descriptionRes = Res.string.meta_section_cast_description,
|
||||
),
|
||||
MetaScreenSectionDefinition(
|
||||
key = MetaScreenSectionKey.COMMENTS,
|
||||
title = "Comments",
|
||||
description = "Trakt comments section.",
|
||||
titleRes = Res.string.settings_meta_comments,
|
||||
descriptionRes = Res.string.meta_section_comments_description,
|
||||
),
|
||||
MetaScreenSectionDefinition(
|
||||
key = MetaScreenSectionKey.TRAILERS,
|
||||
title = "Trailers",
|
||||
description = "Trailer rail and playback shortcuts.",
|
||||
titleRes = Res.string.settings_meta_trailers,
|
||||
descriptionRes = Res.string.meta_section_trailers_description,
|
||||
),
|
||||
MetaScreenSectionDefinition(
|
||||
key = MetaScreenSectionKey.EPISODES,
|
||||
title = "Episodes",
|
||||
description = "Seasons and episode list for series.",
|
||||
titleRes = Res.string.settings_meta_episodes,
|
||||
descriptionRes = Res.string.meta_section_episodes_description,
|
||||
),
|
||||
MetaScreenSectionDefinition(
|
||||
key = MetaScreenSectionKey.DETAILS,
|
||||
title = "Details",
|
||||
description = "Runtime, status, release, language, and related info.",
|
||||
titleRes = Res.string.meta_section_details_title,
|
||||
descriptionRes = Res.string.meta_section_details_description,
|
||||
),
|
||||
MetaScreenSectionDefinition(
|
||||
key = MetaScreenSectionKey.COLLECTION,
|
||||
title = "Collection",
|
||||
description = "Related collection or franchise rail.",
|
||||
titleRes = Res.string.meta_section_collection_title,
|
||||
descriptionRes = Res.string.meta_section_collection_description,
|
||||
),
|
||||
MetaScreenSectionDefinition(
|
||||
key = MetaScreenSectionKey.MORE_LIKE_THIS,
|
||||
title = "More Like This",
|
||||
description = "Recommendation rail.",
|
||||
titleRes = Res.string.meta_section_more_like_this_title,
|
||||
descriptionRes = Res.string.meta_section_more_like_this_description,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -152,6 +159,8 @@ object MetaScreenSettingsRepository {
|
|||
private var cinematicBackground: Boolean = false
|
||||
private var tabLayout: Boolean = false
|
||||
private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
||||
private var blurUnwatchedEpisodes: Boolean = false
|
||||
private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) }
|
||||
|
||||
fun ensureLoaded() {
|
||||
if (hasLoaded) return
|
||||
|
|
@ -167,6 +176,7 @@ object MetaScreenSettingsRepository {
|
|||
tabLayout = parsed.tabLayout
|
||||
episodeCardStyle = MetaEpisodeCardStyle.parse(parsed.episodeCardStyle)
|
||||
?: MetaEpisodeCardStyle.Horizontal
|
||||
blurUnwatchedEpisodes = parsed.blurUnwatchedEpisodes
|
||||
preferences = parsed.items.mapNotNull { item ->
|
||||
val key = runCatching { MetaScreenSectionKey.valueOf(item.key) }.getOrNull() ?: return@mapNotNull null
|
||||
key to item
|
||||
|
|
@ -185,6 +195,7 @@ object MetaScreenSettingsRepository {
|
|||
cinematicBackground = false
|
||||
tabLayout = false
|
||||
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
||||
blurUnwatchedEpisodes = false
|
||||
_uiState.value = MetaScreenSettingsUiState()
|
||||
ensureLoaded()
|
||||
}
|
||||
|
|
@ -210,6 +221,13 @@ object MetaScreenSettingsRepository {
|
|||
persist()
|
||||
}
|
||||
|
||||
fun setBlurUnwatchedEpisodes(enabled: Boolean) {
|
||||
ensureLoaded()
|
||||
blurUnwatchedEpisodes = enabled
|
||||
publish()
|
||||
persist()
|
||||
}
|
||||
|
||||
fun setTabGroup(key: MetaScreenSectionKey, groupId: Int?) {
|
||||
ensureLoaded()
|
||||
if (!key.canBeTabbed) return
|
||||
|
|
@ -228,6 +246,8 @@ object MetaScreenSettingsRepository {
|
|||
preferences.clear()
|
||||
cinematicBackground = false
|
||||
tabLayout = false
|
||||
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
||||
blurUnwatchedEpisodes = false
|
||||
_uiState.value = MetaScreenSettingsUiState()
|
||||
}
|
||||
|
||||
|
|
@ -236,11 +256,13 @@ object MetaScreenSettingsRepository {
|
|||
cinematicBackground: Boolean,
|
||||
tabLayout: Boolean,
|
||||
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
||||
blurUnwatchedEpisodes: Boolean = false,
|
||||
) {
|
||||
ensureLoaded()
|
||||
this.cinematicBackground = cinematicBackground
|
||||
this.tabLayout = tabLayout
|
||||
this.episodeCardStyle = episodeCardStyle
|
||||
this.blurUnwatchedEpisodes = blurUnwatchedEpisodes
|
||||
preferences = items.associate { item ->
|
||||
item.key to StoredMetaScreenSectionPreference(
|
||||
key = item.key.name,
|
||||
|
|
@ -266,6 +288,7 @@ object MetaScreenSettingsRepository {
|
|||
cinematicBackground = false
|
||||
tabLayout = false
|
||||
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
||||
blurUnwatchedEpisodes = false
|
||||
normalizePreferences()
|
||||
publish()
|
||||
persist()
|
||||
|
|
@ -322,8 +345,8 @@ object MetaScreenSettingsRepository {
|
|||
val preference = preferences[definition.key]
|
||||
MetaScreenSectionItem(
|
||||
key = definition.key,
|
||||
title = definition.title,
|
||||
description = definition.description,
|
||||
title = localizedString(definition.titleRes),
|
||||
description = localizedString(definition.descriptionRes),
|
||||
enabled = preference?.enabled ?: true,
|
||||
order = preference?.order ?: 0,
|
||||
tabGroup = preference?.tabGroup,
|
||||
|
|
@ -332,6 +355,7 @@ object MetaScreenSettingsRepository {
|
|||
cinematicBackground = cinematicBackground,
|
||||
tabLayout = tabLayout,
|
||||
episodeCardStyle = episodeCardStyle,
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -343,8 +367,9 @@ object MetaScreenSettingsRepository {
|
|||
cinematicBackground = cinematicBackground,
|
||||
tabLayout = tabLayout,
|
||||
episodeCardStyle = MetaEpisodeCardStyle.persist(episodeCardStyle),
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import androidx.compose.ui.unit.sp
|
|||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.LocalPlatformContext
|
||||
import coil3.request.ImageRequest
|
||||
import com.nuvio.app.core.i18n.localizedShortMonthName
|
||||
import com.nuvio.app.core.ui.landscapePosterHeightForWidth
|
||||
import com.nuvio.app.core.ui.landscapePosterWidth
|
||||
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||
|
|
@ -63,6 +64,9 @@ import com.nuvio.app.features.details.components.DetailPosterRailSection
|
|||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.tmdb.TmdbMetadataService
|
||||
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
private sealed interface PersonDetailUiState {
|
||||
data object Loading : PersonDetailUiState
|
||||
|
|
@ -96,7 +100,7 @@ fun PersonDetailScreen(
|
|||
uiState = if (detail != null) {
|
||||
PersonDetailUiState.Success(detail)
|
||||
} else {
|
||||
PersonDetailUiState.Error("Could not load details for $personName")
|
||||
PersonDetailUiState.Error(getString(Res.string.person_load_failed, personName))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -141,7 +145,7 @@ fun PersonDetailScreen(
|
|||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
contentDescription = stringResource(Res.string.action_back),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
|
@ -268,7 +272,7 @@ private fun PersonDetailContent(
|
|||
if (popularCredits.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
DetailPosterRailSection(
|
||||
title = "Popular",
|
||||
title = stringResource(Res.string.person_popular),
|
||||
items = popularCredits,
|
||||
watchedKeys = emptySet(),
|
||||
headerHorizontalPadding = 20.dp,
|
||||
|
|
@ -279,7 +283,7 @@ private fun PersonDetailContent(
|
|||
if (latestCredits.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
DetailPosterRailSection(
|
||||
title = "Latest",
|
||||
title = stringResource(Res.string.person_latest),
|
||||
items = latestCredits,
|
||||
watchedKeys = emptySet(),
|
||||
headerHorizontalPadding = 20.dp,
|
||||
|
|
@ -290,7 +294,7 @@ private fun PersonDetailContent(
|
|||
if (upcomingCredits.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
DetailPosterRailSection(
|
||||
title = "Upcoming",
|
||||
title = stringResource(Res.string.person_upcoming),
|
||||
items = upcomingCredits,
|
||||
watchedKeys = emptySet(),
|
||||
headerHorizontalPadding = 20.dp,
|
||||
|
|
@ -405,18 +409,23 @@ private fun HeroSection(
|
|||
val infoItems = buildList {
|
||||
person.birthday?.let { bday ->
|
||||
val age = calculateAge(bday, person.deathday)
|
||||
val ageStr = if (age != null) " (age $age)" else ""
|
||||
val ageStr = if (age != null) stringResource(Res.string.person_age, age) else ""
|
||||
val bdayDisplay = formatDateForDisplay(bday) ?: bday
|
||||
val deathDisplay = person.deathday?.let { formatDateForDisplay(it) ?: it }
|
||||
val line = if (deathDisplay != null) {
|
||||
"Born $bdayDisplay — Died $deathDisplay$ageStr"
|
||||
buildString {
|
||||
append(stringResource(Res.string.person_born, bdayDisplay, ""))
|
||||
append(" — ")
|
||||
append(stringResource(Res.string.person_died, deathDisplay))
|
||||
append(ageStr)
|
||||
}
|
||||
} else {
|
||||
"Born $bdayDisplay$ageStr"
|
||||
stringResource(Res.string.person_born, bdayDisplay, ageStr)
|
||||
}
|
||||
add(line)
|
||||
}
|
||||
person.placeOfBirth?.let { add(it) }
|
||||
person.knownFor?.let { add("Known for: $it") }
|
||||
person.knownFor?.let { add(stringResource(Res.string.person_known_for, it)) }
|
||||
}
|
||||
if (infoItems.isNotEmpty()) {
|
||||
infoItems.forEach { info ->
|
||||
|
|
@ -682,7 +691,7 @@ private fun PersonDetailError(
|
|||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "Something went wrong",
|
||||
text = stringResource(Res.string.person_something_wrong),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
|
@ -700,7 +709,7 @@ private fun PersonDetailError(
|
|||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
),
|
||||
) {
|
||||
Text("Retry")
|
||||
Text(stringResource(Res.string.action_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -741,15 +750,11 @@ private fun calculateAge(birthday: String, deathday: String?): Int? {
|
|||
private fun formatDateForDisplay(date: String): String? {
|
||||
val parts = date.split("-").mapNotNull { it.toIntOrNull() }
|
||||
if (parts.size < 3) return null
|
||||
val months = arrayOf(
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
)
|
||||
val month = parts[1]
|
||||
val day = parts[2]
|
||||
val year = parts[0]
|
||||
return if (month in 1..12) {
|
||||
"${months[month - 1]} $day, $year"
|
||||
"${localizedShortMonthName(month)} $day, $year"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
import com.nuvio.app.features.watched.WatchedItem
|
||||
import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||
import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode
|
||||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||
|
|
@ -85,19 +86,35 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
|
|||
seasonNumber = seasonNumber,
|
||||
episodeNumber = episodeNumber,
|
||||
)
|
||||
val candidates = sortedEpisodes
|
||||
.dropWhile { episode ->
|
||||
buildPlaybackVideoId(
|
||||
content = WatchingContentRef(type = type, id = id),
|
||||
seasonNumber = episode.season,
|
||||
episodeNumber = episode.episode,
|
||||
fallbackVideoId = episode.id,
|
||||
) != watchedVideoId
|
||||
var watchedIndex = sortedEpisodes.indexOfFirst { episode ->
|
||||
buildPlaybackVideoId(
|
||||
content = WatchingContentRef(type = type, id = id),
|
||||
seasonNumber = episode.season,
|
||||
episodeNumber = episode.episode,
|
||||
fallbackVideoId = episode.id,
|
||||
) == watchedVideoId
|
||||
}
|
||||
|
||||
// Fallback: if the seed wasn't found by season+episode (anime with absolute
|
||||
// numbering on Trakt vs multi-season on addon), try global index matching.
|
||||
if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) {
|
||||
val addonSeasons = sortedEpisodes.mapTo(mutableSetOf()) { it.season }
|
||||
if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) {
|
||||
val globalIndex = episodeNumber - 1
|
||||
if (globalIndex in sortedEpisodes.indices) {
|
||||
watchedIndex = globalIndex
|
||||
}
|
||||
}
|
||||
.drop(1)
|
||||
}
|
||||
|
||||
if (watchedIndex < 0) return null
|
||||
|
||||
val watchedEpisodeSeason = sortedEpisodes[watchedIndex].season
|
||||
val candidates = sortedEpisodes
|
||||
.drop(watchedIndex + 1)
|
||||
.filter { episode ->
|
||||
shouldSurfaceNextEpisode(
|
||||
watchedSeasonNumber = seasonNumber,
|
||||
watchedSeasonNumber = watchedEpisodeSeason,
|
||||
candidateSeasonNumber = episode.season,
|
||||
todayIsoDate = todayIsoDate,
|
||||
releasedDate = episode.released,
|
||||
|
|
@ -190,7 +207,7 @@ private fun WatchedItem.toDomainWatchedRecord(): WatchingWatchedRecord =
|
|||
content = WatchingContentRef(type = type, id = id),
|
||||
seasonNumber = season,
|
||||
episodeNumber = episode,
|
||||
markedAtEpochMs = markedAtEpochMs,
|
||||
markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs),
|
||||
)
|
||||
|
||||
private fun WatchingSeriesPrimaryAction.toLegacySeriesPrimaryAction(): SeriesPrimaryAction =
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import com.nuvio.app.core.ui.landscapePosterHeightForWidth
|
||||
import com.nuvio.app.core.ui.landscapePosterWidth
|
||||
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||
|
|
@ -73,6 +75,7 @@ fun TmdbEntityBrowseScreen(
|
|||
var uiState by remember(entityKind, entityId) {
|
||||
mutableStateOf<EntityBrowseUiState>(EntityBrowseUiState.Loading)
|
||||
}
|
||||
val loadFailedMessage = stringResource(Res.string.details_browse_load_failed, entityName)
|
||||
|
||||
LaunchedEffect(entityKind, entityId) {
|
||||
uiState = EntityBrowseUiState.Loading
|
||||
|
|
@ -85,7 +88,7 @@ fun TmdbEntityBrowseScreen(
|
|||
uiState = if (data != null) {
|
||||
EntityBrowseUiState.Success(data)
|
||||
} else {
|
||||
EntityBrowseUiState.Error("Could not load $entityName")
|
||||
EntityBrowseUiState.Error(loadFailedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -117,7 +120,7 @@ fun TmdbEntityBrowseScreen(
|
|||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
contentDescription = stringResource(Res.string.action_back),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
|
@ -170,7 +173,7 @@ private fun EntityBrowseContent(
|
|||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "No titles found",
|
||||
text = stringResource(Res.string.catalog_empty_title),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -191,18 +194,16 @@ private fun EntityBrowseContent(
|
|||
)
|
||||
|
||||
data.rails.forEach { rail ->
|
||||
val railTitle = remember(rail.mediaType, rail.railType) {
|
||||
val mediaLabel = when (rail.mediaType) {
|
||||
TmdbEntityMediaType.MOVIE -> "Movies"
|
||||
TmdbEntityMediaType.TV -> "Series"
|
||||
}
|
||||
val railLabel = when (rail.railType) {
|
||||
TmdbEntityRailType.POPULAR -> "Popular"
|
||||
TmdbEntityRailType.TOP_RATED -> "Top Rated"
|
||||
TmdbEntityRailType.RECENT -> "Recent"
|
||||
}
|
||||
"$mediaLabel • $railLabel"
|
||||
val mediaLabel = when (rail.mediaType) {
|
||||
TmdbEntityMediaType.MOVIE -> stringResource(Res.string.media_movies)
|
||||
TmdbEntityMediaType.TV -> stringResource(Res.string.media_series)
|
||||
}
|
||||
val railLabel = when (rail.railType) {
|
||||
TmdbEntityRailType.POPULAR -> stringResource(Res.string.details_browse_rail_popular)
|
||||
TmdbEntityRailType.TOP_RATED -> stringResource(Res.string.details_browse_rail_top_rated)
|
||||
TmdbEntityRailType.RECENT -> stringResource(Res.string.details_browse_rail_recent)
|
||||
}
|
||||
val railTitle = stringResource(Res.string.details_browse_rail_title, mediaLabel, railLabel)
|
||||
|
||||
DetailPosterRailSection(
|
||||
title = railTitle,
|
||||
|
|
@ -230,8 +231,8 @@ private fun EntityHeroSection(
|
|||
Column(modifier = modifier.padding(horizontal = 20.dp)) {
|
||||
Text(
|
||||
text = when (header.kind) {
|
||||
TmdbEntityKind.COMPANY -> "Production Company"
|
||||
TmdbEntityKind.NETWORK -> "Network"
|
||||
TmdbEntityKind.COMPANY -> stringResource(Res.string.details_browse_kind_company)
|
||||
TmdbEntityKind.NETWORK -> stringResource(Res.string.details_browse_kind_network)
|
||||
},
|
||||
style = MaterialTheme.typography.labelLarge.copy(
|
||||
fontWeight = FontWeight.Medium,
|
||||
|
|
@ -405,7 +406,7 @@ private fun EntityBrowseError(
|
|||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
) {
|
||||
Text("Retry")
|
||||
Text(stringResource(Res.string.action_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.sp
|
||||
import com.nuvio.app.core.ui.NuvioModalBottomSheet
|
||||
import com.nuvio.app.features.trakt.TraktCommentReview
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
@ -114,7 +116,7 @@ fun CommentDetailSheet(
|
|||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowLeft,
|
||||
contentDescription = "Previous",
|
||||
contentDescription = stringResource(Res.string.action_previous),
|
||||
tint = if (canGoBack) MaterialTheme.colorScheme.onSurface
|
||||
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
|
||||
modifier = Modifier.size(20.dp),
|
||||
|
|
@ -140,7 +142,7 @@ fun CommentDetailSheet(
|
|||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
contentDescription = "Next",
|
||||
contentDescription = stringResource(Res.string.action_next),
|
||||
tint = if (canGoForward) MaterialTheme.colorScheme.onSurface
|
||||
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
|
||||
modifier = Modifier.size(20.dp),
|
||||
|
|
@ -153,13 +155,13 @@ fun CommentDetailSheet(
|
|||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
if (comment.review) {
|
||||
CommentDetailChip(text = "Review")
|
||||
CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_review))
|
||||
}
|
||||
if (comment.hasSpoilerContent) {
|
||||
CommentDetailChip(text = "Spoiler")
|
||||
CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_spoiler))
|
||||
}
|
||||
comment.rating?.let { rating ->
|
||||
CommentDetailChip(text = "Rating $rating/10")
|
||||
CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_rating, rating))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -173,7 +175,7 @@ fun CommentDetailSheet(
|
|||
) {
|
||||
Text(
|
||||
text = if (comment.hasSpoilerContent) {
|
||||
"This comment contains spoilers and has been hidden."
|
||||
stringResource(Res.string.detail_comments_spoiler_hidden_sheet)
|
||||
} else {
|
||||
comment.comment
|
||||
},
|
||||
|
|
@ -189,7 +191,7 @@ fun CommentDetailSheet(
|
|||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = "${comment.likes} likes",
|
||||
text = stringResource(Res.string.detail_comments_likes, comment.likes),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,11 +13,8 @@ import androidx.compose.foundation.layout.width
|
|||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -28,18 +25,23 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.core.ui.AppIconResource
|
||||
import com.nuvio.app.core.ui.appIconPainter
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.action_play
|
||||
import nuvio.composeapp.generated.resources.action_save
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun DetailActionButtons(
|
||||
modifier: Modifier = Modifier,
|
||||
playLabel: String = "Play",
|
||||
saveLabel: String = "Save",
|
||||
playLabel: String = stringResource(Res.string.action_play),
|
||||
saveLabel: String = stringResource(Res.string.action_save),
|
||||
isSaved: Boolean = false,
|
||||
isTablet: Boolean = false,
|
||||
onPlayClick: () -> Unit = {},
|
||||
onPlayLongClick: (() -> Unit)? = null,
|
||||
onSaveClick: () -> Unit = {},
|
||||
onSaveLongClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val playPainter = appIconPainter(AppIconResource.PlayerPlay)
|
||||
val libraryAddPainter = appIconPainter(AppIconResource.LibraryAddPlus)
|
||||
|
|
@ -92,35 +94,49 @@ fun DetailActionButtons(
|
|||
}
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onSaveClick,
|
||||
Surface(
|
||||
modifier = rowButtonModifier.height(50.dp),
|
||||
shape = RoundedCornerShape(40.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
if (isSaved) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = libraryAddPainter,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = onSaveClick,
|
||||
onLongClick = onSaveLongClick,
|
||||
role = Role.Button,
|
||||
)
|
||||
.height(50.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isSaved) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = libraryAddPainter,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = saveLabel,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = saveLabel,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ import androidx.compose.ui.unit.dp
|
|||
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
||||
import com.nuvio.app.features.details.MetaDetails
|
||||
import com.nuvio.app.features.details.formatRuntimeForDisplay
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun DetailAdditionalInfoSection(
|
||||
|
|
@ -27,14 +29,24 @@ fun DetailAdditionalInfoSection(
|
|||
showHeader: Boolean = true,
|
||||
) {
|
||||
val isSeriesLike = meta.type == "series" || meta.videos.any { it.season != null || it.episode != null }
|
||||
val title = if (isSeriesLike) "Show Details" else "Movie Details"
|
||||
val title = if (isSeriesLike) {
|
||||
stringResource(Res.string.details_show_details)
|
||||
} else {
|
||||
stringResource(Res.string.details_movie_details)
|
||||
}
|
||||
val rows = buildList {
|
||||
meta.status?.let { add("Status" to it) }
|
||||
meta.releaseInfo?.let { add("Release Info" to formatReleaseDateForDisplay(it)) }
|
||||
formatRuntimeForDisplay(meta.runtime)?.let { add("Runtime" to it) }
|
||||
meta.ageRating?.let { add("Certification" to it) }
|
||||
meta.country?.let { add("Origin Country" to it) }
|
||||
meta.language?.let { add("Original Language" to it.uppercase()) }
|
||||
meta.status?.let { add(stringResource(Res.string.details_status) to it) }
|
||||
meta.releaseInfo?.let {
|
||||
add(stringResource(Res.string.details_release_info) to formatReleaseDateForDisplay(it))
|
||||
}
|
||||
formatRuntimeForDisplay(meta.runtime)?.let {
|
||||
add(stringResource(Res.string.details_runtime) to it)
|
||||
}
|
||||
meta.ageRating?.let { add(stringResource(Res.string.details_certification) to it) }
|
||||
meta.country?.let { add(stringResource(Res.string.details_origin_country) to it) }
|
||||
meta.language?.let {
|
||||
add(stringResource(Res.string.details_original_language) to it.uppercase())
|
||||
}
|
||||
}
|
||||
if (rows.isEmpty()) return
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ import coil3.compose.LocalPlatformContext
|
|||
import coil3.request.ImageRequest
|
||||
import com.nuvio.app.features.details.MetaPerson
|
||||
import com.nuvio.app.features.details.castAvatarSharedTransitionKey
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
|
|
@ -48,7 +50,7 @@ fun DetailCastSection(
|
|||
if (cast.isEmpty()) return
|
||||
|
||||
DetailSection(
|
||||
title = "Cast",
|
||||
title = stringResource(Res.string.settings_meta_cast),
|
||||
modifier = modifier,
|
||||
showHeader = showHeader,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -38,8 +38,11 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
|
||||
import com.nuvio.app.features.trakt.TraktCommentReview
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun DetailCommentsSection(
|
||||
|
|
@ -101,14 +104,14 @@ fun DetailCommentsSection(
|
|||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) {
|
||||
Text("Retry")
|
||||
Text(stringResource(Res.string.action_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
comments.isEmpty() -> {
|
||||
Text(
|
||||
text = "No comments yet.",
|
||||
text = stringResource(Res.string.detail_comments_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -120,7 +123,11 @@ fun DetailCommentsSection(
|
|||
state = listState,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(comments, key = { it.id }) { review ->
|
||||
items(
|
||||
items = comments.withDuplicateSafeLazyKeys { it.id },
|
||||
key = { it.lazyKey },
|
||||
) { keyedReview ->
|
||||
val review = keyedReview.value
|
||||
CommentCard(
|
||||
review = review,
|
||||
onClick = { onCommentClick(review) },
|
||||
|
|
@ -144,7 +151,7 @@ private fun CommentsHeader() {
|
|||
val titleSize = if (isTablet) 22.sp else 20.sp
|
||||
|
||||
Text(
|
||||
text = "Trakt Comments",
|
||||
text = stringResource(Res.string.detail_comments_title),
|
||||
style = MaterialTheme.typography.titleLarge.copy(
|
||||
fontSize = titleSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
|
|
@ -163,7 +170,7 @@ private fun CommentCard(
|
|||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isAmoled = colorScheme.background == Color.Black && colorScheme.surface == Color(0xFF050505)
|
||||
val bodyText = if (review.hasSpoilerContent) {
|
||||
"This comment contains spoilers."
|
||||
stringResource(Res.string.detail_comments_spoiler_card)
|
||||
} else {
|
||||
review.comment
|
||||
}
|
||||
|
|
@ -199,13 +206,13 @@ private fun CommentCard(
|
|||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
if (review.review) {
|
||||
CommentChip(text = "Review")
|
||||
CommentChip(text = stringResource(Res.string.detail_comments_badge_review))
|
||||
}
|
||||
if (review.hasSpoilerContent) {
|
||||
CommentChip(text = "Spoiler")
|
||||
CommentChip(text = stringResource(Res.string.detail_comments_badge_spoiler))
|
||||
}
|
||||
review.rating?.let { rating ->
|
||||
CommentChip(text = "Rating $rating/10")
|
||||
CommentChip(text = stringResource(Res.string.detail_comments_badge_rating, rating))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -219,7 +226,7 @@ private fun CommentCard(
|
|||
)
|
||||
|
||||
Text(
|
||||
text = "${review.likes} likes",
|
||||
text = stringResource(Res.string.detail_comments_likes, review.likes),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
maxLines = 1,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ import coil3.compose.AsyncImage
|
|||
import com.nuvio.app.core.ui.NuvioBackButton
|
||||
import com.nuvio.app.features.details.MetaDetails
|
||||
import com.nuvio.app.isIos
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun DetailFloatingHeader(
|
||||
|
|
@ -112,7 +114,7 @@ fun DetailFloatingHeader(
|
|||
if (meta.logo != null && !logoLoadError) {
|
||||
AsyncImage(
|
||||
model = meta.logo,
|
||||
contentDescription = "${meta.name} logo",
|
||||
contentDescription = stringResource(Res.string.detail_logo_content_description, meta.name),
|
||||
modifier = Modifier
|
||||
.width(logoWidth)
|
||||
.widthIn(max = 240.dp)
|
||||
|
|
@ -166,7 +168,11 @@ private fun DetailFloatingHeaderAction(
|
|||
) {
|
||||
Icon(
|
||||
imageVector = if (isSaved) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
|
||||
contentDescription = if (isSaved) "Remove from Library" else "Add to Library",
|
||||
contentDescription = if (isSaved) {
|
||||
stringResource(Res.string.hero_remove_from_library)
|
||||
} else {
|
||||
stringResource(Res.string.hero_add_to_library)
|
||||
},
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.features.details.MetaDetails
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun DetailHero(
|
||||
|
|
@ -103,7 +105,7 @@ fun DetailHero(
|
|||
if (meta.logo != null) {
|
||||
AsyncImage(
|
||||
model = meta.logo,
|
||||
contentDescription = "${meta.name} logo",
|
||||
contentDescription = stringResource(Res.string.detail_logo_content_description, meta.name),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(if (isTablet) 0.56f else 0.6f)
|
||||
.widthIn(max = contentMaxWidth)
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_METACRITIC
|
|||
import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TMDB
|
||||
import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TOMATOES
|
||||
import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TRAKT
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import nuvio.composeapp.generated.resources.rating_audience_score
|
||||
import nuvio.composeapp.generated.resources.rating_imdb
|
||||
import nuvio.composeapp.generated.resources.rating_letterboxd
|
||||
|
|
@ -58,7 +58,10 @@ import nuvio.composeapp.generated.resources.rating_rotten_tomatoes
|
|||
import nuvio.composeapp.generated.resources.rating_tmdb
|
||||
import nuvio.composeapp.generated.resources.rating_trakt
|
||||
import org.jetbrains.compose.resources.DrawableResource
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
|
@ -114,7 +117,7 @@ fun DetailMetaInfo(
|
|||
color = ImdbYellow,
|
||||
) {
|
||||
Text(
|
||||
text = "IMDb",
|
||||
text = stringResource(Res.string.source_imdb),
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontSize = 10.sp,
|
||||
|
|
@ -148,14 +151,14 @@ fun DetailMetaInfo(
|
|||
|
||||
if (meta.director.isNotEmpty()) {
|
||||
MetaLabelValueRow(
|
||||
label = "Director",
|
||||
label = stringResource(Res.string.details_director),
|
||||
value = meta.director.joinToString(", "),
|
||||
)
|
||||
}
|
||||
|
||||
if (meta.writer.isNotEmpty()) {
|
||||
MetaLabelValueRow(
|
||||
label = "Writer",
|
||||
label = stringResource(Res.string.details_writer),
|
||||
value = meta.writer.joinToString(", "),
|
||||
)
|
||||
}
|
||||
|
|
@ -182,7 +185,11 @@ fun DetailMetaInfo(
|
|||
if (canExpand) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = if (expanded) "Show Less" else "Show More ▾",
|
||||
text = if (expanded) {
|
||||
stringResource(Res.string.details_show_less)
|
||||
} else {
|
||||
stringResource(Res.string.details_show_more)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.clickable { expanded = !expanded },
|
||||
|
|
@ -341,7 +348,7 @@ private val ratingVisuals = listOf(
|
|||
),
|
||||
RatingVisuals(
|
||||
source = PROVIDER_AUDIENCE,
|
||||
displayName = "Audience Score",
|
||||
displayName = runBlocking { getString(Res.string.rating_audience_score) },
|
||||
logo = Res.drawable.rating_audience_score,
|
||||
logoWidth = 16.dp,
|
||||
valueColor = Color(0xFFFA320A),
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ import androidx.compose.ui.unit.sp
|
|||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.features.details.MetaCompany
|
||||
import com.nuvio.app.features.details.MetaDetails
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
|
|
@ -54,7 +56,11 @@ fun DetailProductionSection(
|
|||
if (displayItems.isEmpty()) return
|
||||
|
||||
DetailSection(
|
||||
title = if (isSeriesLike) "Network" else "Production",
|
||||
title = if (isSeriesLike) {
|
||||
stringResource(Res.string.details_networks)
|
||||
} else {
|
||||
stringResource(Res.string.meta_section_production_title)
|
||||
},
|
||||
modifier = modifier,
|
||||
showHeader = showHeader,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -15,12 +15,14 @@ import androidx.compose.foundation.border
|
|||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
|
|
@ -30,6 +32,7 @@ import androidx.compose.foundation.layout.size
|
|||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
|
|
@ -44,6 +47,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
|
@ -57,6 +61,7 @@ import androidx.compose.ui.unit.sp
|
|||
import coil3.compose.AsyncImage
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
||||
import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode
|
||||
import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge
|
||||
import com.nuvio.app.core.ui.NuvioProgressBar
|
||||
import com.nuvio.app.features.details.MetaDetails
|
||||
|
|
@ -71,6 +76,13 @@ import com.nuvio.app.features.details.seasonSortKey
|
|||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
|
||||
import com.nuvio.app.features.watching.application.WatchingState
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val log = Logger.withTag("SeriesContent")
|
||||
|
||||
|
|
@ -80,9 +92,12 @@ fun DetailSeriesContent(
|
|||
modifier: Modifier = Modifier,
|
||||
showHeader: Boolean = true,
|
||||
preferredSeasonNumber: Int? = null,
|
||||
preferredEpisodeNumber: Int? = null,
|
||||
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
||||
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
|
||||
watchedKeys: Set<String> = emptySet(),
|
||||
episodeRatings: Map<Pair<Int, Int>, Double> = emptyMap(),
|
||||
blurUnwatchedEpisodes: Boolean = false,
|
||||
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
|
||||
onEpisodeLongPress: ((MetaVideo) -> Unit)? = null,
|
||||
) {
|
||||
|
|
@ -91,16 +106,16 @@ fun DetailSeriesContent(
|
|||
|
||||
if (meta.videos.isEmpty()) {
|
||||
DetailSection(
|
||||
title = "Episodes",
|
||||
title = stringResource(Res.string.settings_meta_episodes),
|
||||
modifier = modifier,
|
||||
showHeader = showHeader,
|
||||
) {
|
||||
Text(
|
||||
text = when {
|
||||
meta.status.equals("Not yet aired", ignoreCase = true) || meta.hasScheduledVideos ->
|
||||
"Episodes have not been published by this addon yet."
|
||||
stringResource(Res.string.details_series_unpublished)
|
||||
else ->
|
||||
"This addon did not provide episode metadata for this series."
|
||||
stringResource(Res.string.details_series_no_metadata)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
|
|
@ -131,12 +146,12 @@ fun DetailSeriesContent(
|
|||
if (groupedEpisodes.isEmpty()) {
|
||||
if (meta.type == "series") {
|
||||
DetailSection(
|
||||
title = "Episodes",
|
||||
title = stringResource(Res.string.settings_meta_episodes),
|
||||
modifier = modifier,
|
||||
showHeader = showHeader,
|
||||
) {
|
||||
Text(
|
||||
text = "This addon returned videos for the series, but none included season or episode numbers.",
|
||||
text = stringResource(Res.string.details_series_missing_numbers),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -181,7 +196,7 @@ fun DetailSeriesContent(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Seasons",
|
||||
text = stringResource(Res.string.details_seasons),
|
||||
style = MaterialTheme.typography.titleLarge.copy(
|
||||
fontSize = sizing.seasonHeaderSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
|
|
@ -249,7 +264,7 @@ fun DetailSeriesContent(
|
|||
label = "season_episodes",
|
||||
) { seasonForContent ->
|
||||
val sectionTitle = if (meta.type != "series" && seasons.size == 1 && seasonForContent <= 0) {
|
||||
"Videos"
|
||||
stringResource(Res.string.details_videos)
|
||||
} else {
|
||||
seasonForContent.label()
|
||||
}
|
||||
|
|
@ -269,6 +284,9 @@ fun DetailSeriesContent(
|
|||
watchedKeys = watchedKeys,
|
||||
fallbackImage = meta.background ?: meta.poster,
|
||||
progressByVideoId = progressByVideoId,
|
||||
episodeRatings = episodeRatings,
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
preferredEpisodeNumber = preferredEpisodeNumber,
|
||||
onEpisodeClick = onEpisodeClick,
|
||||
onEpisodeLongPress = onEpisodeLongPress,
|
||||
)
|
||||
|
|
@ -287,13 +305,15 @@ fun DetailSeriesContent(
|
|||
video = episode,
|
||||
fallbackImage = meta.background ?: meta.poster,
|
||||
progressEntry = progressByVideoId[episodeVideoId],
|
||||
isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true ||
|
||||
imdbRating = episode.seasonEpisodeKey()?.let { episodeRatings[it] },
|
||||
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
|
||||
WatchingState.isEpisodeWatched(
|
||||
watchedKeys = watchedKeys,
|
||||
metaType = meta.type,
|
||||
metaId = meta.id,
|
||||
episode = episode,
|
||||
),
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
sizing = sizing,
|
||||
onClick = { onEpisodeClick?.invoke(episode) },
|
||||
onLongPress = { onEpisodeLongPress?.invoke(episode) },
|
||||
|
|
@ -334,7 +354,11 @@ private fun SeasonViewModeToggle(
|
|||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = if (isPosters) "Posters" else "Text",
|
||||
text = if (isPosters) {
|
||||
stringResource(Res.string.details_season_view_posters)
|
||||
} else {
|
||||
stringResource(Res.string.details_season_view_text)
|
||||
},
|
||||
style = MaterialTheme.typography.labelLarge.copy(
|
||||
fontSize = sizing.seasonToggleTextSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
|
|
@ -541,17 +565,42 @@ private fun EpisodeHorizontalRow(
|
|||
watchedKeys: Set<String>,
|
||||
fallbackImage: String?,
|
||||
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||
episodeRatings: Map<Pair<Int, Int>, Double>,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
preferredEpisodeNumber: Int? = null,
|
||||
onEpisodeClick: ((MetaVideo) -> Unit)?,
|
||||
onEpisodeLongPress: ((MetaVideo) -> Unit)?,
|
||||
) {
|
||||
val rowMetrics = rememberEpisodeHorizontalCardMetrics(maxWidthDp)
|
||||
val listState = rememberLazyListState()
|
||||
var hasPositioned by remember(episodes) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(episodes, preferredEpisodeNumber) {
|
||||
val targetIndex = if (preferredEpisodeNumber != null) {
|
||||
episodes.indexOfFirst { it.episode == preferredEpisodeNumber }
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
if (targetIndex >= 0) {
|
||||
if (hasPositioned) {
|
||||
listState.animateScrollToItem(targetIndex)
|
||||
} else {
|
||||
listState.scrollToItem(targetIndex)
|
||||
hasPositioned = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LazyRow(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = rowMetrics.rowHorizontalPadding, vertical = rowMetrics.rowVerticalPadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(rowMetrics.itemSpacing),
|
||||
) {
|
||||
items(episodes, key = { it.id }) { episode ->
|
||||
itemsIndexed(
|
||||
items = episodes,
|
||||
key = { index, episode -> "${episode.season}:${episode.episode}:${episode.id}#$index" },
|
||||
) { _, episode ->
|
||||
val episodeVideoId = buildPlaybackVideoId(
|
||||
parentMetaId = parentMetaId,
|
||||
seasonNumber = episode.season,
|
||||
|
|
@ -562,13 +611,15 @@ private fun EpisodeHorizontalRow(
|
|||
video = episode,
|
||||
fallbackImage = fallbackImage,
|
||||
progressEntry = progressByVideoId[episodeVideoId],
|
||||
isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true ||
|
||||
imdbRating = episode.seasonEpisodeKey()?.let { episodeRatings[it] },
|
||||
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
|
||||
WatchingState.isEpisodeWatched(
|
||||
watchedKeys = watchedKeys,
|
||||
metaType = metaType,
|
||||
metaId = parentMetaId,
|
||||
episode = episode,
|
||||
),
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
metrics = rowMetrics,
|
||||
onClick = { onEpisodeClick?.invoke(episode) },
|
||||
onLongPress = { onEpisodeLongPress?.invoke(episode) },
|
||||
|
|
@ -583,12 +634,17 @@ private fun EpisodeHorizontalCard(
|
|||
video: MetaVideo,
|
||||
fallbackImage: String?,
|
||||
progressEntry: WatchProgressEntry?,
|
||||
imdbRating: Double?,
|
||||
isWatched: Boolean,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
metrics: EpisodeHorizontalCardMetrics,
|
||||
onClick: (() -> Unit)? = null,
|
||||
onLongPress: (() -> Unit)? = null,
|
||||
) {
|
||||
val cardShape = RoundedCornerShape(metrics.cornerRadius)
|
||||
val ratingLabel = remember(imdbRating) { imdbRating?.takeIf { it > 0.0 }?.let(::formatEpisodeRating) }
|
||||
val formattedDate = remember(video.released) { video.released?.let { formatReleaseDateForDisplay(it) } }
|
||||
val runtimeLabel = remember(video.runtime) { video.runtime?.takeIf { it > 0 }?.let(::formatEpisodeRuntime) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(metrics.cardWidth)
|
||||
|
|
@ -607,11 +663,14 @@ private fun EpisodeHorizontalCard(
|
|||
),
|
||||
) {
|
||||
val imageUrl = video.thumbnail ?: fallbackImage
|
||||
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
|
||||
if (imageUrl != null) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = video.title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
|
|
@ -631,30 +690,6 @@ private fun EpisodeHorizontalCard(
|
|||
),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(start = metrics.contentPadding, top = metrics.contentPadding)
|
||||
.clip(RoundedCornerShape(metrics.badgeRadius))
|
||||
.background(Color.Black.copy(alpha = 0.75f))
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = Color.White.copy(alpha = 0.18f),
|
||||
shape = RoundedCornerShape(metrics.badgeRadius),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = video.episodeBadge(),
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontSize = metrics.badgeTextSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
|
||||
NuvioAnimatedWatchedBadge(
|
||||
isVisible = isWatched,
|
||||
modifier = Modifier
|
||||
|
|
@ -674,6 +709,15 @@ private fun EpisodeHorizontalCard(
|
|||
),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
EpisodeCodeBadge(
|
||||
text = video.episodeBadge(),
|
||||
textSize = metrics.badgeTextSize,
|
||||
radius = metrics.badgeRadius,
|
||||
horizontalPadding = metrics.badgeHorizontalPadding,
|
||||
verticalPadding = metrics.badgeVerticalPadding,
|
||||
backgroundAlpha = 0.42f,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = video.title,
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
|
|
@ -699,27 +743,39 @@ private fun EpisodeHorizontalCard(
|
|||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
video.runtime?.takeIf { it > 0 }?.let { runtimeMinutes ->
|
||||
Text(
|
||||
text = formatEpisodeRuntime(runtimeMinutes),
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
|
||||
color = Color.White.copy(alpha = 0.78f),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
video.released?.let { formatReleaseDateForDisplay(it) }?.let { formattedDate ->
|
||||
Text(
|
||||
text = formattedDate,
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
|
||||
color = Color.White.copy(alpha = 0.78f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (runtimeLabel != null || ratingLabel != null || formattedDate != null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
runtimeLabel?.let { runtime ->
|
||||
Text(
|
||||
text = runtime,
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
|
||||
color = Color.White.copy(alpha = 0.78f),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
ratingLabel?.let { rating ->
|
||||
ImdbEpisodeRatingBadge(
|
||||
rating = rating,
|
||||
logoWidth = metrics.imdbLogoWidth,
|
||||
logoHeight = metrics.imdbLogoHeight,
|
||||
textSize = metrics.metaTextSize,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
formattedDate?.let { date ->
|
||||
Text(
|
||||
text = date,
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
|
||||
color = Color.White.copy(alpha = 0.78f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -758,6 +814,10 @@ private data class EpisodeHorizontalCardMetrics(
|
|||
val metaTextSize: androidx.compose.ui.unit.TextUnit,
|
||||
val badgeTextSize: androidx.compose.ui.unit.TextUnit,
|
||||
val badgeRadius: Dp,
|
||||
val badgeHorizontalPadding: Dp,
|
||||
val badgeVerticalPadding: Dp,
|
||||
val imdbLogoWidth: Dp,
|
||||
val imdbLogoHeight: Dp,
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
|
@ -780,7 +840,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
|
|||
overviewMaxLines = 3,
|
||||
metaTextSize = 12.sp,
|
||||
badgeTextSize = 11.sp,
|
||||
badgeRadius = 6.dp,
|
||||
badgeRadius = 8.dp,
|
||||
badgeHorizontalPadding = 10.dp,
|
||||
badgeVerticalPadding = 5.dp,
|
||||
imdbLogoWidth = 28.dp,
|
||||
imdbLogoHeight = 14.dp,
|
||||
)
|
||||
|
||||
maxWidthDp >= 1000f -> EpisodeHorizontalCardMetrics(
|
||||
|
|
@ -799,7 +863,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
|
|||
overviewMaxLines = 3,
|
||||
metaTextSize = 12.sp,
|
||||
badgeTextSize = 10.sp,
|
||||
badgeRadius = 6.dp,
|
||||
badgeRadius = 7.dp,
|
||||
badgeHorizontalPadding = 9.dp,
|
||||
badgeVerticalPadding = 4.dp,
|
||||
imdbLogoWidth = 26.dp,
|
||||
imdbLogoHeight = 13.dp,
|
||||
)
|
||||
|
||||
maxWidthDp >= 760f -> EpisodeHorizontalCardMetrics(
|
||||
|
|
@ -818,7 +886,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
|
|||
overviewMaxLines = 2,
|
||||
metaTextSize = 11.sp,
|
||||
badgeTextSize = 10.sp,
|
||||
badgeRadius = 5.dp,
|
||||
badgeRadius = 6.dp,
|
||||
badgeHorizontalPadding = 8.dp,
|
||||
badgeVerticalPadding = 4.dp,
|
||||
imdbLogoWidth = 24.dp,
|
||||
imdbLogoHeight = 12.dp,
|
||||
)
|
||||
|
||||
else -> EpisodeHorizontalCardMetrics(
|
||||
|
|
@ -838,6 +910,10 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
|
|||
metaTextSize = 10.sp,
|
||||
badgeTextSize = 9.sp,
|
||||
badgeRadius = 5.dp,
|
||||
badgeHorizontalPadding = 7.dp,
|
||||
badgeVerticalPadding = 3.dp,
|
||||
imdbLogoWidth = 22.dp,
|
||||
imdbLogoHeight = 11.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -847,19 +923,83 @@ private fun formatEpisodeRuntime(runtimeMinutes: Int): String {
|
|||
return formatRuntimeFromMinutes(runtimeMinutes)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpisodeCodeBadge(
|
||||
text: String,
|
||||
textSize: androidx.compose.ui.unit.TextUnit,
|
||||
radius: Dp,
|
||||
horizontalPadding: Dp,
|
||||
verticalPadding: Dp,
|
||||
backgroundAlpha: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(radius))
|
||||
.background(Color.Black.copy(alpha = backgroundAlpha))
|
||||
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontSize = textSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
color = Color.White.copy(alpha = 0.9f),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImdbEpisodeRatingBadge(
|
||||
rating: String,
|
||||
logoWidth: Dp,
|
||||
logoHeight: Dp,
|
||||
textSize: androidx.compose.ui.unit.TextUnit,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(Res.drawable.rating_imdb),
|
||||
contentDescription = stringResource(Res.string.source_imdb),
|
||||
modifier = Modifier
|
||||
.width(logoWidth)
|
||||
.height(logoHeight),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
Text(
|
||||
text = rating,
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontSize = textSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = Color(0xFFF5C518),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun EpisodeListCard(
|
||||
video: MetaVideo,
|
||||
fallbackImage: String?,
|
||||
progressEntry: WatchProgressEntry?,
|
||||
imdbRating: Double?,
|
||||
isWatched: Boolean,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
sizing: SeriesContentSizing,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
onLongPress: (() -> Unit)? = null,
|
||||
) {
|
||||
val cardShape = RoundedCornerShape(sizing.cardRadius)
|
||||
val ratingLabel = remember(imdbRating) { imdbRating?.takeIf { it > 0.0 }?.let(::formatEpisodeRating) }
|
||||
val formattedDate = remember(video.released) { video.released?.let { formatReleaseDateForDisplay(it) } }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -888,11 +1028,14 @@ private fun EpisodeListCard(
|
|||
.clip(RoundedCornerShape(topStart = sizing.cardRadius, bottomStart = sizing.cardRadius)),
|
||||
) {
|
||||
val imageUrl = video.thumbnail ?: fallbackImage
|
||||
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
|
||||
if (imageUrl != null) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = video.title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
} else {
|
||||
|
|
@ -903,32 +1046,17 @@ private fun EpisodeListCard(
|
|||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
EpisodeCodeBadge(
|
||||
text = video.episodeBadge(),
|
||||
textSize = sizing.badgeTextSize,
|
||||
radius = sizing.badgeRadius,
|
||||
horizontalPadding = sizing.badgeHorizontalPadding,
|
||||
verticalPadding = sizing.badgeVerticalPadding,
|
||||
backgroundAlpha = 0.85f,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(start = 8.dp, top = 8.dp)
|
||||
.clip(RoundedCornerShape(sizing.badgeRadius))
|
||||
.background(Color.Black.copy(alpha = 0.85f))
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = Color.White.copy(alpha = 0.2f),
|
||||
shape = RoundedCornerShape(sizing.badgeRadius),
|
||||
)
|
||||
.padding(
|
||||
horizontal = sizing.badgeHorizontalPadding,
|
||||
vertical = sizing.badgeVerticalPadding,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = video.episodeBadge(),
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontSize = sizing.badgeTextSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 0.3.sp,
|
||||
),
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
.padding(start = 8.dp, top = 8.dp),
|
||||
)
|
||||
|
||||
NuvioAnimatedWatchedBadge(
|
||||
isVisible = isWatched,
|
||||
|
|
@ -956,24 +1084,39 @@ private fun EpisodeListCard(
|
|||
fontSize = sizing.titleTextSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = sizing.titleLineHeight,
|
||||
letterSpacing = 0.3.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = sizing.titleMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
video.released?.let { formatReleaseDateForDisplay(it) }?.let { formattedDate ->
|
||||
Text(
|
||||
text = formattedDate,
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontSize = sizing.metaTextSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (formattedDate != null || ratingLabel != null) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
formattedDate?.let { date ->
|
||||
Text(
|
||||
text = date,
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontSize = sizing.metaTextSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
ratingLabel?.let { rating ->
|
||||
ImdbEpisodeRatingBadge(
|
||||
rating = rating,
|
||||
logoWidth = 24.dp,
|
||||
logoHeight = 12.dp,
|
||||
textSize = sizing.metaTextSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!video.overview.isNullOrBlank()) {
|
||||
|
|
@ -1165,14 +1308,27 @@ private fun seriesContentSizing(maxWidthDp: Float): SeriesContentSizing =
|
|||
|
||||
private fun Int.label(): String =
|
||||
if (this <= 0) {
|
||||
"Specials"
|
||||
runBlocking { getString(Res.string.episodes_specials) }
|
||||
} else {
|
||||
"Season $this"
|
||||
runBlocking { getString(Res.string.episodes_season, this@label) }
|
||||
}
|
||||
|
||||
private fun MetaVideo.episodeBadge(): String =
|
||||
when {
|
||||
episode != null -> "E${episode.toString().padStart(2, '0')}"
|
||||
season != null -> "S${season.toString().padStart(2, '0')}"
|
||||
else -> "FILE"
|
||||
episode != null || season != null ->
|
||||
localizedSeasonEpisodeCode(seasonNumber = season, episodeNumber = episode).orEmpty()
|
||||
else -> runBlocking { getString(Res.string.details_episode_badge_file) }
|
||||
}
|
||||
|
||||
private fun MetaVideo.seasonEpisodeKey(): Pair<Int, Int>? {
|
||||
val seasonNumber = season ?: return null
|
||||
val episodeNumber = episode ?: return null
|
||||
return seasonNumber to episodeNumber
|
||||
}
|
||||
|
||||
private fun formatEpisodeRating(rating: Double): String {
|
||||
val roundedTenths = (rating * 10.0).roundToInt()
|
||||
val whole = roundedTenths / 10
|
||||
val tenth = (roundedTenths % 10).absoluteValue
|
||||
return "$whole.$tenth"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
|
|
@ -38,6 +38,11 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.features.details.MetaTrailer
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import nuvio.composeapp.generated.resources.detail_tab_trailer
|
||||
import nuvio.composeapp.generated.resources.detail_trailer_category_count
|
||||
import nuvio.composeapp.generated.resources.detail_trailers_title
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun DetailTrailersSection(
|
||||
|
|
@ -48,10 +53,11 @@ fun DetailTrailersSection(
|
|||
) {
|
||||
if (trailers.isEmpty()) return
|
||||
|
||||
val trailerLabel = stringResource(Res.string.detail_tab_trailer)
|
||||
val grouped = remember(trailers) {
|
||||
linkedMapOf<String, MutableList<MetaTrailer>>().apply {
|
||||
trailers.forEach { trailer ->
|
||||
val category = trailer.type.ifBlank { "Trailer" }
|
||||
val category = trailer.type.ifBlank { trailerLabel }
|
||||
getOrPut(category) { mutableListOf() }.add(trailer)
|
||||
}
|
||||
}
|
||||
|
|
@ -60,7 +66,7 @@ fun DetailTrailersSection(
|
|||
if (grouped.isEmpty()) return
|
||||
|
||||
val initialCategory = remember(grouped) {
|
||||
grouped.keys.firstOrNull { it.equals("Trailer", ignoreCase = true) }
|
||||
grouped.keys.firstOrNull { it.equals(trailerLabel, ignoreCase = true) }
|
||||
?: grouped.keys.first()
|
||||
}
|
||||
var selectedCategory by remember(grouped) { mutableStateOf(initialCategory) }
|
||||
|
|
@ -82,7 +88,7 @@ fun DetailTrailersSection(
|
|||
) {
|
||||
if (showHeader) {
|
||||
DetailSectionTitle(
|
||||
title = "Trailers",
|
||||
title = stringResource(Res.string.detail_trailers_title),
|
||||
fullWidth = false,
|
||||
)
|
||||
}
|
||||
|
|
@ -131,7 +137,7 @@ fun DetailTrailersSection(
|
|||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = "$category ($count)",
|
||||
text = stringResource(Res.string.detail_trailer_category_count, category, count),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
},
|
||||
|
|
@ -152,10 +158,10 @@ fun DetailTrailersSection(
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(sizing.cardSpacing),
|
||||
) {
|
||||
items(
|
||||
itemsIndexed(
|
||||
items = selectedTrailers,
|
||||
key = { trailer -> "${trailer.type}-${trailer.id}-${trailer.seasonNumber ?: 0}" },
|
||||
) { trailer ->
|
||||
key = { index, trailer -> "${trailer.type}-${trailer.id}-${trailer.seasonNumber ?: 0}#$index" },
|
||||
) { _, trailer ->
|
||||
TrailerCard(
|
||||
trailer = trailer,
|
||||
cardWidth = sizing.cardWidth,
|
||||
|
|
|
|||
|
|
@ -29,8 +29,11 @@ import com.nuvio.app.core.ui.NuvioBottomSheetDivider
|
|||
import com.nuvio.app.core.ui.NuvioModalBottomSheet
|
||||
import com.nuvio.app.core.ui.dismissNuvioBottomSheet
|
||||
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
||||
import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode
|
||||
import com.nuvio.app.features.details.MetaVideo
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
@ -71,7 +74,11 @@ fun EpisodeWatchedActionSheet(
|
|||
NuvioBottomSheetDivider()
|
||||
NuvioBottomSheetActionRow(
|
||||
icon = Icons.Default.CheckCircle,
|
||||
title = if (isEpisodeWatched) "Mark as unwatched" else "Mark as watched",
|
||||
title = if (isEpisodeWatched) {
|
||||
stringResource(Res.string.episode_mark_unwatched)
|
||||
} else {
|
||||
stringResource(Res.string.episode_mark_watched)
|
||||
},
|
||||
onClick = {
|
||||
onToggleWatched()
|
||||
coroutineScope.launch {
|
||||
|
|
@ -84,9 +91,9 @@ fun EpisodeWatchedActionSheet(
|
|||
NuvioBottomSheetActionRow(
|
||||
icon = Icons.Default.DoneAll,
|
||||
title = if (arePreviousEpisodesWatched) {
|
||||
"Mark previous as unwatched"
|
||||
stringResource(Res.string.episode_mark_previous_unwatched)
|
||||
} else {
|
||||
"Mark previous as watched"
|
||||
stringResource(Res.string.episode_mark_previous_watched)
|
||||
},
|
||||
onClick = {
|
||||
onTogglePreviousWatched()
|
||||
|
|
@ -100,9 +107,9 @@ fun EpisodeWatchedActionSheet(
|
|||
NuvioBottomSheetActionRow(
|
||||
icon = Icons.Default.PlaylistAddCheckCircle,
|
||||
title = if (isSeasonWatched) {
|
||||
"Mark $seasonLabel as unwatched"
|
||||
stringResource(Res.string.episode_mark_season_unwatched, seasonLabel)
|
||||
} else {
|
||||
"Mark $seasonLabel as watched"
|
||||
stringResource(Res.string.episode_mark_season_watched, seasonLabel)
|
||||
},
|
||||
onClick = {
|
||||
onToggleSeasonWatched()
|
||||
|
|
@ -115,7 +122,7 @@ fun EpisodeWatchedActionSheet(
|
|||
NuvioBottomSheetDivider()
|
||||
NuvioBottomSheetActionRow(
|
||||
icon = Icons.Default.PlayArrow,
|
||||
title = "Play manually",
|
||||
title = stringResource(Res.string.play_manually),
|
||||
onClick = {
|
||||
onPlayManually()
|
||||
coroutineScope.launch {
|
||||
|
|
@ -149,8 +156,11 @@ private fun EpisodeActionSheetHeader(
|
|||
)
|
||||
Text(
|
||||
text = buildString {
|
||||
if (episode.season != null && episode.episode != null) {
|
||||
append("S${episode.season}E${episode.episode}")
|
||||
localizedSeasonEpisodeCode(
|
||||
seasonNumber = episode.season,
|
||||
episodeNumber = episode.episode,
|
||||
)?.let {
|
||||
append(it)
|
||||
append(" • ")
|
||||
}
|
||||
append(seasonLabel)
|
||||
|
|
@ -162,4 +172,3 @@ private fun EpisodeActionSheetHeader(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ import com.nuvio.app.features.player.PlatformPlayerSurface
|
|||
import com.nuvio.app.features.player.PlayerResizeMode
|
||||
import com.nuvio.app.features.trailer.TrailerPlaybackSource
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
@ -55,7 +57,7 @@ fun TrailerPlayerPopup(
|
|||
) {
|
||||
if (!visible) return
|
||||
|
||||
val headerType = trailerType.trim().ifBlank { "Trailer" }
|
||||
val headerType = trailerType.trim().ifBlank { stringResource(Res.string.detail_tab_trailer) }
|
||||
val headerSubtitle = buildList {
|
||||
if (trailerTitle.isNotBlank() && !trailerTitle.equals(headerType, ignoreCase = true)) {
|
||||
add(trailerTitle)
|
||||
|
|
@ -119,7 +121,7 @@ fun TrailerPlayerPopup(
|
|||
IconButton(onClick = dismissSheet) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Close,
|
||||
contentDescription = "Close trailer",
|
||||
contentDescription = stringResource(Res.string.trailer_close),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
|
@ -147,7 +149,7 @@ fun TrailerPlayerPopup(
|
|||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Unable to play trailer",
|
||||
text = stringResource(Res.string.trailer_unable_to_play),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
|
@ -160,7 +162,7 @@ fun TrailerPlayerPopup(
|
|||
)
|
||||
if (onRetry != null) {
|
||||
TextButton(onClick = onRetry) {
|
||||
Text("Retry")
|
||||
Text(stringResource(Res.string.action_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
package com.nuvio.app.features.downloads
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.downloads_enqueue_missing_url
|
||||
import nuvio.composeapp.generated.resources.downloads_enqueue_replaced
|
||||
import nuvio.composeapp.generated.resources.downloads_enqueue_started
|
||||
import nuvio.composeapp.generated.resources.downloads_enqueue_unsupported_format
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
@Serializable
|
||||
enum class DownloadStatus {
|
||||
|
|
@ -48,22 +55,7 @@ data class DownloadItem(
|
|||
get() = status == DownloadStatus.Completed && !localFileUri.isNullOrBlank()
|
||||
|
||||
val displaySubtitle: String
|
||||
get() = if (isEpisode) {
|
||||
buildString {
|
||||
append("S")
|
||||
append(seasonNumber)
|
||||
append("E")
|
||||
append(episodeNumber)
|
||||
episodeTitle
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let {
|
||||
append(" • ")
|
||||
append(it)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
"Movie"
|
||||
}
|
||||
get() = episodeTitle.orEmpty()
|
||||
|
||||
val progressFraction: Float
|
||||
get() {
|
||||
|
|
@ -91,11 +83,28 @@ data class DownloadsUiState(
|
|||
get() = items.filter { it.status == DownloadStatus.Completed }
|
||||
}
|
||||
|
||||
enum class DownloadEnqueueResult(
|
||||
val toastMessage: String,
|
||||
) {
|
||||
Started("Download started"),
|
||||
Replaced("Replaced previous download"),
|
||||
MissingUrl("No direct stream link available"),
|
||||
UnsupportedFormat("Unsupported stream format for downloads"),
|
||||
enum class DownloadEnqueueResult {
|
||||
Started,
|
||||
Replaced,
|
||||
MissingUrl,
|
||||
UnsupportedFormat;
|
||||
|
||||
fun toastMessage(): String = runBlocking {
|
||||
when (this@DownloadEnqueueResult) {
|
||||
Started -> getString(Res.string.downloads_enqueue_started)
|
||||
Replaced -> getString(Res.string.downloads_enqueue_replaced)
|
||||
MissingUrl -> getString(Res.string.downloads_enqueue_missing_url)
|
||||
UnsupportedFormat -> getString(Res.string.downloads_enqueue_unsupported_format)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun List<DownloadItem>.sortedForSeriesDownloads(): List<DownloadItem> =
|
||||
sortedWith(downloadSeriesEpisodeComparator)
|
||||
|
||||
internal val downloadSeriesEpisodeComparator: Comparator<DownloadItem> =
|
||||
compareBy<DownloadItem> { it.seasonNumber ?: Int.MAX_VALUE }
|
||||
.thenBy { it.episodeNumber ?: Int.MAX_VALUE }
|
||||
.thenBy { it.episodeTitle?.trim().orEmpty().lowercase() }
|
||||
.thenBy { it.title.trim().lowercase() }
|
||||
.thenBy { it.id }
|
||||
|
|
|
|||
|
|
@ -21,4 +21,6 @@ internal expect object DownloadsPlatformDownloader {
|
|||
fun removeFile(localFileUri: String?): Boolean
|
||||
|
||||
fun removePartialFile(destinationFileName: String): Boolean
|
||||
|
||||
fun resolveLocalFileUri(localFileUri: String?, destinationFileName: String): String?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.nuvio.app.features.downloads
|
||||
|
||||
import com.nuvio.app.features.streams.StreamItem
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
|
@ -9,6 +10,8 @@ import kotlinx.serialization.Serializable
|
|||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
object DownloadsRepository {
|
||||
private val _uiState = MutableStateFlow(DownloadsUiState())
|
||||
|
|
@ -40,7 +43,7 @@ object DownloadsRepository {
|
|||
val normalizedVideoId = videoId?.trim().orEmpty()
|
||||
if (normalizedVideoId.isBlank()) return null
|
||||
return _uiState.value.items.firstOrNull { item ->
|
||||
item.videoId == normalizedVideoId && item.isPlayable && !item.localFileUri.isNullOrBlank()
|
||||
item.videoId == normalizedVideoId && item.hasPlayableLocalFile()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -61,20 +64,42 @@ object DownloadsRepository {
|
|||
item.parentMetaId == normalizedParentMetaId &&
|
||||
item.seasonNumber == seasonNumber &&
|
||||
item.episodeNumber == episodeNumber &&
|
||||
item.isPlayable &&
|
||||
!item.localFileUri.isNullOrBlank()
|
||||
item.hasPlayableLocalFile()
|
||||
}
|
||||
} else {
|
||||
items.firstOrNull { item ->
|
||||
item.parentMetaId == normalizedParentMetaId &&
|
||||
item.seasonNumber == null &&
|
||||
item.episodeNumber == null &&
|
||||
item.isPlayable &&
|
||||
!item.localFileUri.isNullOrBlank()
|
||||
item.hasPlayableLocalFile()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun playableLocalFileUri(item: DownloadItem): String? {
|
||||
ensureLoaded()
|
||||
if (item.status != DownloadStatus.Completed) return null
|
||||
val resolvedUri = DownloadsPlatformDownloader.resolveLocalFileUri(
|
||||
localFileUri = item.localFileUri,
|
||||
destinationFileName = item.fileName,
|
||||
) ?: return null
|
||||
|
||||
if (resolvedUri != item.localFileUri) {
|
||||
mutateItem(item.id) { current ->
|
||||
if (current.fileName == item.fileName) {
|
||||
current.copy(
|
||||
localFileUri = resolvedUri,
|
||||
updatedAtEpochMs = DownloadsClock.nowEpochMs(),
|
||||
)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedUri
|
||||
}
|
||||
|
||||
fun enqueueFromStream(
|
||||
contentType: String,
|
||||
videoId: String,
|
||||
|
|
@ -114,7 +139,7 @@ object DownloadsRepository {
|
|||
if (existing != null) {
|
||||
replacedExisting = true
|
||||
activeHandles.remove(existing.id)?.cancel()
|
||||
DownloadsPlatformDownloader.removeFile(existing.localFileUri)
|
||||
DownloadsPlatformDownloader.removeFile(playableLocalFileUri(existing) ?: existing.localFileUri)
|
||||
DownloadsPlatformDownloader.removePartialFile(existing.fileName)
|
||||
currentItems.removeAll { it.id == existing.id }
|
||||
}
|
||||
|
|
@ -188,6 +213,14 @@ object DownloadsRepository {
|
|||
}
|
||||
}
|
||||
|
||||
fun pauseActiveDownloads() {
|
||||
ensureLoaded()
|
||||
_uiState.value.items
|
||||
.filter { it.status == DownloadStatus.Downloading }
|
||||
.map { it.id }
|
||||
.forEach(::pauseDownload)
|
||||
}
|
||||
|
||||
fun resumeDownload(downloadId: String) {
|
||||
ensureLoaded()
|
||||
val item = _uiState.value.items.firstOrNull { it.id == downloadId } ?: return
|
||||
|
|
@ -214,7 +247,7 @@ object DownloadsRepository {
|
|||
val item = _uiState.value.items.firstOrNull { it.id == downloadId } ?: return
|
||||
|
||||
activeHandles.remove(downloadId)?.cancel()
|
||||
DownloadsPlatformDownloader.removeFile(item.localFileUri)
|
||||
DownloadsPlatformDownloader.removeFile(playableLocalFileUri(item) ?: item.localFileUri)
|
||||
DownloadsPlatformDownloader.removePartialFile(item.fileName)
|
||||
|
||||
publish(_uiState.value.items.filterNot { it.id == downloadId })
|
||||
|
|
@ -230,9 +263,10 @@ object DownloadsRepository {
|
|||
return
|
||||
}
|
||||
|
||||
var shouldPersistNormalized = false
|
||||
val normalized = DownloadsCodec.decodeItems(payload)
|
||||
.map { item ->
|
||||
if (item.status == DownloadStatus.Downloading) {
|
||||
val statusNormalized = if (item.status == DownloadStatus.Downloading) {
|
||||
item.copy(
|
||||
status = DownloadStatus.Paused,
|
||||
errorMessage = null,
|
||||
|
|
@ -240,10 +274,19 @@ object DownloadsRepository {
|
|||
} else {
|
||||
item
|
||||
}
|
||||
|
||||
val localUriNormalized = normalizeCompletedLocalFileUri(statusNormalized)
|
||||
if (localUriNormalized != item) {
|
||||
shouldPersistNormalized = true
|
||||
}
|
||||
localUriNormalized
|
||||
}
|
||||
|
||||
_uiState.value = DownloadsUiState(normalized)
|
||||
notifyLiveStatusPlatform()
|
||||
if (shouldPersistNormalized) {
|
||||
persist()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startDownload(item: DownloadItem) {
|
||||
|
|
@ -294,7 +337,7 @@ object DownloadsRepository {
|
|||
} else {
|
||||
current.copy(
|
||||
status = DownloadStatus.Failed,
|
||||
errorMessage = message.ifBlank { "Download failed" },
|
||||
errorMessage = message.ifBlank { runBlocking { getString(Res.string.download_failed) } },
|
||||
updatedAtEpochMs = DownloadsClock.nowEpochMs(),
|
||||
)
|
||||
}
|
||||
|
|
@ -356,6 +399,26 @@ object DownloadsRepository {
|
|||
append(nextDownloadOrdinal.toString(36))
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeCompletedLocalFileUri(item: DownloadItem): DownloadItem {
|
||||
if (item.status != DownloadStatus.Completed) return item
|
||||
val resolvedUri = DownloadsPlatformDownloader.resolveLocalFileUri(
|
||||
localFileUri = item.localFileUri,
|
||||
destinationFileName = item.fileName,
|
||||
) ?: return item
|
||||
return if (resolvedUri != item.localFileUri) {
|
||||
item.copy(localFileUri = resolvedUri)
|
||||
} else {
|
||||
item
|
||||
}
|
||||
}
|
||||
|
||||
private fun DownloadItem.hasPlayableLocalFile(): Boolean =
|
||||
status == DownloadStatus.Completed &&
|
||||
DownloadsPlatformDownloader.resolveLocalFileUri(
|
||||
localFileUri = localFileUri,
|
||||
destinationFileName = fileName,
|
||||
) != null
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
|
|
|||
|
|
@ -35,8 +35,11 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.core.i18n.localizedByteUnit
|
||||
import com.nuvio.app.core.ui.NuvioScreen
|
||||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun DownloadsScreen(
|
||||
|
|
@ -53,7 +56,7 @@ fun DownloadsScreen(
|
|||
val completedEpisodes = remember(uiState.items) {
|
||||
uiState.completedItems
|
||||
.filter { it.isEpisode }
|
||||
.sortedByDescending { it.updatedAtEpochMs }
|
||||
.sortedForSeriesDownloads()
|
||||
}
|
||||
|
||||
val selectedShowTitle = remember(selectedShowId, completedEpisodes) {
|
||||
|
|
@ -66,9 +69,9 @@ fun DownloadsScreen(
|
|||
stickyHeader {
|
||||
NuvioScreenHeader(
|
||||
title = if (selectedShowId == null) {
|
||||
"Downloads"
|
||||
stringResource(Res.string.compose_settings_root_downloads_title)
|
||||
} else {
|
||||
selectedShowTitle ?: "Show Downloads"
|
||||
selectedShowTitle ?: stringResource(Res.string.downloads_show_downloads)
|
||||
},
|
||||
onBack = {
|
||||
if (selectedShowId != null) {
|
||||
|
|
@ -115,7 +118,7 @@ private fun LazyListScope.downloadsRootContent(
|
|||
|
||||
if (activeItems.isNotEmpty()) {
|
||||
item {
|
||||
SectionTitle("ACTIVE")
|
||||
SectionTitle(stringResource(Res.string.downloads_section_active))
|
||||
}
|
||||
items(
|
||||
items = activeItems,
|
||||
|
|
@ -134,7 +137,7 @@ private fun LazyListScope.downloadsRootContent(
|
|||
|
||||
if (completedMovies.isNotEmpty()) {
|
||||
item {
|
||||
SectionTitle("MOVIES")
|
||||
SectionTitle(stringResource(Res.string.downloads_section_movies))
|
||||
}
|
||||
items(
|
||||
items = completedMovies,
|
||||
|
|
@ -153,7 +156,7 @@ private fun LazyListScope.downloadsRootContent(
|
|||
|
||||
if (completedShows.isNotEmpty()) {
|
||||
item {
|
||||
SectionTitle("SHOWS")
|
||||
SectionTitle(stringResource(Res.string.downloads_section_shows))
|
||||
}
|
||||
items(
|
||||
items = completedShows,
|
||||
|
|
@ -186,7 +189,7 @@ private fun LazyListScope.downloadsRootContent(
|
|||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = "${episodes.size} downloaded episode${if (episodes.size == 1) "" else "s"}",
|
||||
text = stringResource(Res.string.downloads_episode_count, episodes.size),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -210,7 +213,7 @@ private fun LazyListScope.downloadsRootContent(
|
|||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "No downloads yet",
|
||||
text = stringResource(Res.string.downloads_empty_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -226,6 +229,7 @@ private fun LazyListScope.downloadsShowContent(
|
|||
) {
|
||||
val showEpisodes = episodes
|
||||
.filter { it.parentMetaId == showId }
|
||||
.sortedForSeriesDownloads()
|
||||
|
||||
val seasons = showEpisodes
|
||||
.groupBy { it.seasonNumber ?: 0 }
|
||||
|
|
@ -245,7 +249,7 @@ private fun LazyListScope.downloadsShowContent(
|
|||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "No completed episodes",
|
||||
text = stringResource(Res.string.downloads_empty_episodes),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -255,19 +259,17 @@ private fun LazyListScope.downloadsShowContent(
|
|||
}
|
||||
|
||||
seasons.forEach { (seasonNumber, entries) ->
|
||||
val seasonTitle = if (seasonNumber == 0) {
|
||||
"Specials"
|
||||
} else {
|
||||
"Season $seasonNumber"
|
||||
}
|
||||
item {
|
||||
SectionTitle(seasonTitle)
|
||||
SectionTitle(
|
||||
if (seasonNumber == 0) {
|
||||
stringResource(Res.string.episodes_specials)
|
||||
} else {
|
||||
stringResource(Res.string.episodes_season, seasonNumber)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val sortedEpisodes = entries.sortedWith(
|
||||
compareBy<DownloadItem> { it.episodeNumber ?: Int.MAX_VALUE }
|
||||
.thenByDescending { it.updatedAtEpochMs },
|
||||
)
|
||||
val sortedEpisodes = entries.sortedForSeriesDownloads()
|
||||
|
||||
items(
|
||||
items = sortedEpisodes,
|
||||
|
|
@ -294,6 +296,12 @@ private fun DownloadRow(
|
|||
onRetry: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val displayTitle = item.displayTitle()
|
||||
val displaySubtitle = downloadDisplaySubtitle(
|
||||
item = item,
|
||||
displayTitle = displayTitle,
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -318,7 +326,7 @@ private fun DownloadRow(
|
|||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = item.title,
|
||||
text = displayTitle,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
|
|
@ -326,7 +334,7 @@ private fun DownloadRow(
|
|||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = item.displaySubtitle,
|
||||
text = displaySubtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
|
|
@ -345,7 +353,7 @@ private fun DownloadRow(
|
|||
IconButton(onClick = onPause) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Pause,
|
||||
contentDescription = "Pause",
|
||||
contentDescription = stringResource(Res.string.compose_action_pause),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -353,7 +361,7 @@ private fun DownloadRow(
|
|||
IconButton(onClick = onResume) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.PlayArrow,
|
||||
contentDescription = "Resume",
|
||||
contentDescription = stringResource(Res.string.action_resume),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -361,7 +369,7 @@ private fun DownloadRow(
|
|||
IconButton(onClick = onRetry) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Refresh,
|
||||
contentDescription = "Retry",
|
||||
contentDescription = stringResource(Res.string.action_retry),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -369,7 +377,7 @@ private fun DownloadRow(
|
|||
IconButton(onClick = onOpen) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.PlayArrow,
|
||||
contentDescription = "Play",
|
||||
contentDescription = stringResource(Res.string.action_play),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -377,7 +385,7 @@ private fun DownloadRow(
|
|||
IconButton(onClick = onDelete) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Delete,
|
||||
contentDescription = "Delete",
|
||||
contentDescription = stringResource(Res.string.action_delete),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -399,6 +407,36 @@ private fun DownloadRow(
|
|||
}
|
||||
}
|
||||
|
||||
private fun DownloadItem.displayTitle(): String =
|
||||
if (isEpisode) {
|
||||
episodeTitle?.trim()?.takeIf { it.isNotBlank() } ?: title
|
||||
} else {
|
||||
title
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun downloadDisplaySubtitle(
|
||||
item: DownloadItem,
|
||||
displayTitle: String,
|
||||
): String {
|
||||
val seasonNumber = item.seasonNumber
|
||||
val episodeNumber = item.episodeNumber
|
||||
if (seasonNumber == null || episodeNumber == null) {
|
||||
return item.displaySubtitle
|
||||
}
|
||||
|
||||
val episodeCode = stringResource(
|
||||
Res.string.compose_player_episode_code_full,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
)
|
||||
return listOf(
|
||||
episodeCode,
|
||||
item.episodeTitle?.trim().orEmpty().takeIf { it.isNotBlank() && it != displayTitle },
|
||||
item.title.trim().takeIf { it.isNotBlank() && it != displayTitle },
|
||||
).filterNotNull().joinToString(" • ")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionTitle(title: String) {
|
||||
Text(
|
||||
|
|
@ -410,6 +448,7 @@ private fun SectionTitle(title: String) {
|
|||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun statusText(item: DownloadItem): String {
|
||||
val size = if (item.totalBytes != null && item.totalBytes > 0L) {
|
||||
"${formatBytes(item.downloadedBytes)} / ${formatBytes(item.totalBytes)}"
|
||||
|
|
@ -418,23 +457,26 @@ private fun statusText(item: DownloadItem): String {
|
|||
}
|
||||
|
||||
return when (item.status) {
|
||||
DownloadStatus.Downloading -> "Downloading • $size"
|
||||
DownloadStatus.Paused -> "Paused • $size"
|
||||
DownloadStatus.Completed -> "Completed • ${formatBytes(item.totalBytes ?: item.downloadedBytes)}"
|
||||
DownloadStatus.Failed -> item.errorMessage ?: "Failed"
|
||||
DownloadStatus.Downloading -> stringResource(Res.string.downloads_status_downloading, size)
|
||||
DownloadStatus.Paused -> stringResource(Res.string.downloads_status_paused, size)
|
||||
DownloadStatus.Completed -> stringResource(
|
||||
Res.string.downloads_status_completed,
|
||||
formatBytes(item.totalBytes ?: item.downloadedBytes),
|
||||
)
|
||||
DownloadStatus.Failed -> item.errorMessage ?: stringResource(Res.string.downloads_status_failed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatBytes(bytes: Long): String {
|
||||
if (bytes <= 0L) return "0 B"
|
||||
if (bytes <= 0L) return "0 ${localizedByteUnit("B")}"
|
||||
val kib = 1024.0
|
||||
val mib = kib * 1024.0
|
||||
val gib = mib * 1024.0
|
||||
val value = bytes.toDouble()
|
||||
return when {
|
||||
value >= gib -> "${((value / gib) * 10.0).toInt() / 10.0} GB"
|
||||
value >= mib -> "${((value / mib) * 10.0).toInt() / 10.0} MB"
|
||||
value >= kib -> "${((value / kib) * 10.0).toInt() / 10.0} KB"
|
||||
else -> "$bytes B"
|
||||
value >= gib -> "${((value / gib) * 10.0).toInt() / 10.0} ${localizedByteUnit("GB")}"
|
||||
value >= mib -> "${((value / mib) * 10.0).toInt() / 10.0} ${localizedByteUnit("MB")}"
|
||||
value >= kib -> "${((value / kib) * 10.0).toInt() / 10.0} ${localizedByteUnit("KB")}"
|
||||
else -> "$bytes ${localizedByteUnit("B")}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
package com.nuvio.app.features.home
|
||||
|
||||
import com.nuvio.app.core.i18n.localizedMediaTypeLabel
|
||||
import com.nuvio.app.features.addons.ManagedAddon
|
||||
import com.nuvio.app.features.catalog.supportsPagination
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.home_catalog_default_title
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
data class HomeCatalogDefinition(
|
||||
val key: String,
|
||||
|
|
@ -23,7 +28,13 @@ fun buildHomeCatalogDefinitions(addons: List<ManagedAddon>): List<HomeCatalogDef
|
|||
.map { catalog ->
|
||||
HomeCatalogDefinition(
|
||||
key = "${manifest.id}:${catalog.type}:${catalog.id}",
|
||||
defaultTitle = "${catalog.name} - ${catalog.type.displayLabel()}",
|
||||
defaultTitle = runBlocking {
|
||||
getString(
|
||||
Res.string.home_catalog_default_title,
|
||||
catalog.name,
|
||||
localizedMediaTypeLabel(catalog.type),
|
||||
)
|
||||
},
|
||||
addonName = addon.displayTitle,
|
||||
manifestUrl = addon.manifestUrl,
|
||||
type = catalog.type,
|
||||
|
|
@ -33,7 +44,4 @@ fun buildHomeCatalogDefinitions(addons: List<ManagedAddon>): List<HomeCatalogDef
|
|||
}
|
||||
}.distinctBy(HomeCatalogDefinition::key)
|
||||
|
||||
internal fun String.displayLabel(): String =
|
||||
replaceFirstChar { char ->
|
||||
if (char.isLowerCase()) char.titlecase() else char.toString()
|
||||
}
|
||||
internal fun String.displayLabel(): String = localizedMediaTypeLabel(this)
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ internal object HomeCatalogParser {
|
|||
posterShape = meta.string("posterShape").toPosterShape(),
|
||||
description = meta.string("description"),
|
||||
releaseInfo = meta.string("releaseInfo"),
|
||||
rawReleaseDate = meta.string("released"),
|
||||
imdbRating = meta.string("imdbRating"),
|
||||
genres = meta.array("genres").mapNotNull { genre ->
|
||||
genre.jsonPrimitive.contentOrNull?.takeIf { it.isNotBlank() }
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.features.home
|
|||
import com.nuvio.app.features.addons.ManagedAddon
|
||||
import com.nuvio.app.features.collection.Collection
|
||||
import com.nuvio.app.features.collection.CollectionRepository
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
|
@ -10,6 +11,8 @@ import kotlinx.serialization.Serializable
|
|||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
data class HomeCatalogSettingsItem(
|
||||
val key: String,
|
||||
|
|
@ -29,12 +32,15 @@ data class HomeCatalogSettingsItem(
|
|||
|
||||
data class HomeCatalogSettingsUiState(
|
||||
val heroEnabled: Boolean = true,
|
||||
val hideUnreleasedContent: Boolean = false,
|
||||
val items: List<HomeCatalogSettingsItem> = emptyList(),
|
||||
) {
|
||||
val signature: String
|
||||
get() = buildString {
|
||||
append(heroEnabled)
|
||||
append('|')
|
||||
append(hideUnreleasedContent)
|
||||
append('|')
|
||||
append(
|
||||
items.joinToString(separator = "|") { item ->
|
||||
"${item.key}:${item.order}:${item.enabled}:${item.heroSourceEnabled}:${item.customTitle}"
|
||||
|
|
@ -52,6 +58,7 @@ internal data class HomeCatalogPreference(
|
|||
|
||||
internal data class HomeCatalogSettingsSnapshot(
|
||||
val heroEnabled: Boolean,
|
||||
val hideUnreleasedContent: Boolean,
|
||||
val preferences: Map<String, HomeCatalogPreference>,
|
||||
)
|
||||
|
||||
|
|
@ -67,6 +74,7 @@ private data class StoredHomeCatalogPreference(
|
|||
@Serializable
|
||||
private data class StoredHomeCatalogSettingsPayload(
|
||||
val heroEnabled: Boolean = true,
|
||||
val hideUnreleasedContent: Boolean = false,
|
||||
val items: List<StoredHomeCatalogPreference> = emptyList(),
|
||||
)
|
||||
|
||||
|
|
@ -86,11 +94,13 @@ object HomeCatalogSettingsRepository {
|
|||
private var collectionDefinitions: List<CollectionCatalogDefinition> = emptyList()
|
||||
private var preferences: MutableMap<String, StoredHomeCatalogPreference> = mutableMapOf()
|
||||
private var heroEnabled = true
|
||||
private var hideUnreleasedContent = false
|
||||
|
||||
fun onProfileChanged() {
|
||||
hasLoaded = false
|
||||
preferences.clear()
|
||||
heroEnabled = true
|
||||
hideUnreleasedContent = false
|
||||
definitions = emptyList()
|
||||
collectionDefinitions = emptyList()
|
||||
_uiState.value = HomeCatalogSettingsUiState()
|
||||
|
|
@ -102,6 +112,7 @@ object HomeCatalogSettingsRepository {
|
|||
collectionDefinitions = emptyList()
|
||||
preferences.clear()
|
||||
heroEnabled = true
|
||||
hideUnreleasedContent = false
|
||||
_uiState.value = HomeCatalogSettingsUiState()
|
||||
}
|
||||
|
||||
|
|
@ -132,6 +143,7 @@ object HomeCatalogSettingsRepository {
|
|||
ensureLoaded()
|
||||
return HomeCatalogSettingsSnapshot(
|
||||
heroEnabled = heroEnabled,
|
||||
hideUnreleasedContent = hideUnreleasedContent,
|
||||
preferences = preferences.mapValues { (_, value) ->
|
||||
HomeCatalogPreference(
|
||||
customTitle = value.customTitle,
|
||||
|
|
@ -151,6 +163,15 @@ object HomeCatalogSettingsRepository {
|
|||
HomeRepository.applyCurrentSettings()
|
||||
}
|
||||
|
||||
fun setHideUnreleasedContent(enabled: Boolean) {
|
||||
ensureLoaded()
|
||||
if (hideUnreleasedContent == enabled) return
|
||||
hideUnreleasedContent = enabled
|
||||
publish()
|
||||
persist()
|
||||
HomeRepository.applyCurrentSettings()
|
||||
}
|
||||
|
||||
fun setHeroSourceEnabled(key: String, enabled: Boolean) {
|
||||
updatePreference(key) { preference ->
|
||||
if (!enabled) {
|
||||
|
|
@ -178,6 +199,7 @@ object HomeCatalogSettingsRepository {
|
|||
fun resetToDefaults() {
|
||||
ensureLoaded()
|
||||
heroEnabled = true
|
||||
hideUnreleasedContent = false
|
||||
preferences.clear()
|
||||
normalizePreferences()
|
||||
publish()
|
||||
|
|
@ -223,7 +245,9 @@ object HomeCatalogSettingsRepository {
|
|||
|
||||
if (parsedPayload != null) {
|
||||
heroEnabled = parsedPayload.heroEnabled
|
||||
hideUnreleasedContent = parsedPayload.hideUnreleasedContent
|
||||
preferences = parsedPayload.items.associateBy { it.key }.toMutableMap()
|
||||
publish()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -232,6 +256,7 @@ object HomeCatalogSettingsRepository {
|
|||
}.getOrDefault(emptyList())
|
||||
|
||||
preferences = legacyItems.associateBy { it.key }.toMutableMap()
|
||||
publish()
|
||||
}
|
||||
|
||||
private fun normalizePreferences() {
|
||||
|
|
@ -319,6 +344,7 @@ object HomeCatalogSettingsRepository {
|
|||
|
||||
_uiState.value = HomeCatalogSettingsUiState(
|
||||
heroEnabled = heroEnabled,
|
||||
hideUnreleasedContent = hideUnreleasedContent,
|
||||
items = items,
|
||||
)
|
||||
}
|
||||
|
|
@ -328,6 +354,7 @@ object HomeCatalogSettingsRepository {
|
|||
json.encodeToString(
|
||||
StoredHomeCatalogSettingsPayload(
|
||||
heroEnabled = heroEnabled,
|
||||
hideUnreleasedContent = hideUnreleasedContent,
|
||||
items = preferences.values.sortedBy { it.order },
|
||||
),
|
||||
),
|
||||
|
|
@ -346,10 +373,12 @@ object HomeCatalogSettingsRepository {
|
|||
HomeRepository.applyCurrentSettings()
|
||||
}
|
||||
|
||||
private fun selectedHeroSourceCount(excludingKey: String? = null): Int =
|
||||
preferences.count { (itemKey, preference) ->
|
||||
itemKey != excludingKey && preference.heroSourceEnabled
|
||||
private fun selectedHeroSourceCount(excludingKey: String? = null): Int {
|
||||
val catalogKeys = definitions.mapTo(mutableSetOf()) { it.key }
|
||||
return preferences.count { (itemKey, preference) ->
|
||||
itemKey != excludingKey && itemKey in catalogKeys && preference.heroSourceEnabled
|
||||
}
|
||||
}
|
||||
|
||||
private fun move(
|
||||
key: String,
|
||||
|
|
@ -406,26 +435,32 @@ object HomeCatalogSettingsRepository {
|
|||
)
|
||||
}
|
||||
}
|
||||
return SyncHomeCatalogPayload(items = items)
|
||||
return SyncHomeCatalogPayload(
|
||||
hideUnreleasedContent = hideUnreleasedContent,
|
||||
items = items,
|
||||
)
|
||||
}
|
||||
|
||||
fun applyFromRemote(payload: SyncHomeCatalogPayload) {
|
||||
ensureLoaded()
|
||||
val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled }
|
||||
preferences = payload.items.associate { item ->
|
||||
val key = if (item.isCollection) {
|
||||
"collection_${item.collectionId}"
|
||||
} else {
|
||||
"${item.addonId}:${item.type}:${item.catalogId}"
|
||||
}
|
||||
key to StoredHomeCatalogPreference(
|
||||
key = key,
|
||||
customTitle = item.customTitle,
|
||||
enabled = item.enabled,
|
||||
heroSourceEnabled = existingHeroState[key] ?: true,
|
||||
order = item.order,
|
||||
)
|
||||
}.toMutableMap()
|
||||
hideUnreleasedContent = payload.hideUnreleasedContent
|
||||
if (payload.items.isNotEmpty()) {
|
||||
val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled }
|
||||
preferences = payload.items.associate { item ->
|
||||
val key = if (item.isCollection) {
|
||||
"collection_${item.collectionId}"
|
||||
} else {
|
||||
"${item.addonId}:${item.type}:${item.catalogId}"
|
||||
}
|
||||
key to StoredHomeCatalogPreference(
|
||||
key = key,
|
||||
customTitle = item.customTitle,
|
||||
enabled = item.enabled,
|
||||
heroSourceEnabled = existingHeroState[key] ?: true,
|
||||
order = item.order,
|
||||
)
|
||||
}.toMutableMap()
|
||||
}
|
||||
hasLoaded = true
|
||||
publish()
|
||||
persist()
|
||||
|
|
@ -478,7 +513,7 @@ internal fun buildCollectionDefinitions(collections: List<Collection>): List<Col
|
|||
key = "collection_${collection.id}",
|
||||
collectionId = collection.id,
|
||||
title = collection.title,
|
||||
subtitle = "${collection.folders.size} folder${if (collection.folders.size != 1) "s" else ""}",
|
||||
subtitle = runBlocking { getString(Res.string.collections_folder_count, collection.folders.size) },
|
||||
isPinnedToTop = collection.pinToTop,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ data class SyncCatalogItem(
|
|||
|
||||
@Serializable
|
||||
data class SyncHomeCatalogPayload(
|
||||
@SerialName("hide_unreleased_content") val hideUnreleasedContent: Boolean = false,
|
||||
val items: List<SyncCatalogItem> = emptyList(),
|
||||
)
|
||||
|
||||
|
|
@ -101,7 +102,10 @@ object HomeCatalogSettingsSyncService {
|
|||
}
|
||||
|
||||
if (remotePayload.items.isEmpty()) {
|
||||
log.i { "pullFromServer — remote has empty items, preserving local" }
|
||||
log.i { "pullFromServer — remote has empty items, preserving local catalog order" }
|
||||
isSyncingFromRemote = true
|
||||
HomeCatalogSettingsRepository.applyFromRemote(remotePayload)
|
||||
isSyncingFromRemote = false
|
||||
val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload()
|
||||
if (localPayload.items.isNotEmpty()) {
|
||||
pushToRemote(profileId)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.nuvio.app.features.home
|
|||
|
||||
import com.nuvio.app.features.addons.ManagedAddon
|
||||
import com.nuvio.app.features.catalog.fetchCatalogPage
|
||||
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
|
|
@ -145,13 +146,17 @@ object HomeRepository {
|
|||
) {
|
||||
val snapshot = HomeCatalogSettingsRepository.snapshot()
|
||||
val preferences = snapshot.preferences
|
||||
val todayIsoDate = if (snapshot.hideUnreleasedContent) CurrentDateProvider.todayIsoDate() else null
|
||||
fun HomeCatalogSection.withReleaseFilter(): HomeCatalogSection =
|
||||
if (todayIsoDate == null) this else filterReleasedItems(todayIsoDate)
|
||||
|
||||
val sections = currentDefinitions
|
||||
.sortedBy { definition -> preferences[definition.key]?.order ?: Int.MAX_VALUE }
|
||||
.mapNotNull { definition ->
|
||||
val preference = preferences[definition.key]
|
||||
if (preference?.enabled == false) return@mapNotNull null
|
||||
|
||||
val section = cachedSections[definition.key] ?: return@mapNotNull null
|
||||
val section = cachedSections[definition.key]?.withReleaseFilter() ?: return@mapNotNull null
|
||||
if (section.items.isEmpty()) return@mapNotNull null
|
||||
val customTitle = preference?.customTitle.orEmpty()
|
||||
section.copy(
|
||||
|
|
@ -164,6 +169,7 @@ object HomeRepository {
|
|||
currentDefinitions
|
||||
.filter { definition -> preferences[definition.key]?.heroSourceEnabled != false }
|
||||
.mapNotNull { definition -> cachedSections[definition.key] }
|
||||
.map { section -> section.withReleaseFilter() }
|
||||
.flatMap { section -> section.items }
|
||||
.distinctBy { item -> "${item.type}:${item.id}" }
|
||||
.shuffled(heroRandom)
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.core.network.NetworkCondition
|
||||
import com.nuvio.app.core.network.NetworkStatusRepository
|
||||
import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding
|
||||
import com.nuvio.app.core.ui.NuvioScreen
|
||||
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
||||
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||
import com.nuvio.app.features.details.nextReleasedEpisodeAfter
|
||||
|
|
@ -29,6 +31,10 @@ import com.nuvio.app.features.home.components.HomeHeroSection
|
|||
import com.nuvio.app.features.home.components.HomeSkeletonHero
|
||||
import com.nuvio.app.features.home.components.HomeSkeletonRow
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
|
||||
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||
import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap
|
||||
import com.nuvio.app.features.trakt.shouldUseTraktProgress
|
||||
import com.nuvio.app.features.watched.WatchedRepository
|
||||
import com.nuvio.app.features.watchprogress.CachedInProgressItem
|
||||
import com.nuvio.app.features.watchprogress.CachedNextUpItem
|
||||
|
|
@ -36,15 +42,19 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache
|
|||
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
||||
import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching
|
||||
import com.nuvio.app.features.watchprogress.nextUpDismissKey
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressClock
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||
import com.nuvio.app.features.watchprogress.buildContinueWatchingEpisodeSubtitle
|
||||
import com.nuvio.app.features.watchprogress.toContinueWatchingItem
|
||||
import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem
|
||||
import com.nuvio.app.features.watching.application.WatchingState
|
||||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||
import com.nuvio.app.features.watching.domain.isReleasedBy
|
||||
import com.nuvio.app.features.collection.CollectionRepository
|
||||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import com.nuvio.app.features.home.components.HomeCollectionRowSection
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
|
||||
import kotlinx.coroutines.async
|
||||
|
|
@ -54,6 +64,8 @@ import kotlinx.coroutines.sync.withPermit
|
|||
import com.nuvio.app.features.home.components.ContinueWatchingLayout
|
||||
import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWidth
|
||||
import com.nuvio.app.features.home.components.rememberContinueWatchingLayout
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
|
|
@ -83,6 +95,10 @@ fun HomeScreen(
|
|||
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
|
||||
val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle()
|
||||
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
||||
val traktSettingsUiState by remember {
|
||||
TraktSettingsRepository.ensureLoaded()
|
||||
TraktSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val isTraktAuthenticated by remember {
|
||||
TraktAuthRepository.ensureLoaded()
|
||||
TraktAuthRepository.isAuthenticated
|
||||
|
|
@ -110,17 +126,31 @@ fun HomeScreen(
|
|||
}
|
||||
}
|
||||
|
||||
val effectiveWatchProgressEntries = remember(watchProgressUiState.entries, isTraktAuthenticated) {
|
||||
if (!isTraktAuthenticated) {
|
||||
watchProgressUiState.entries
|
||||
} else {
|
||||
val cutoffMs = WatchProgressClock.nowEpochMs() - (TRAKT_CONTINUE_WATCHING_DAYS_CAP_DEFAULT.toLong() * 24L * 60L * 60L * 1000L)
|
||||
watchProgressUiState.entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs }
|
||||
}
|
||||
val isTraktProgressActive = remember(
|
||||
isTraktAuthenticated,
|
||||
traktSettingsUiState.watchProgressSource,
|
||||
) {
|
||||
shouldUseTraktProgress(
|
||||
isAuthenticated = isTraktAuthenticated,
|
||||
source = traktSettingsUiState.watchProgressSource,
|
||||
)
|
||||
}
|
||||
|
||||
val effectiveWatchedItems = remember(watchedUiState.items, isTraktAuthenticated) {
|
||||
if (isTraktAuthenticated) emptyList() else watchedUiState.items
|
||||
val effectiveWatchProgressEntries = remember(
|
||||
watchProgressUiState.entries,
|
||||
isTraktProgressActive,
|
||||
traktSettingsUiState.continueWatchingDaysCap,
|
||||
) {
|
||||
filterEntriesForTraktContinueWatchingWindow(
|
||||
entries = watchProgressUiState.entries,
|
||||
isTraktProgressActive = isTraktProgressActive,
|
||||
daysCap = traktSettingsUiState.continueWatchingDaysCap,
|
||||
nowEpochMs = WatchProgressClock.nowEpochMs(),
|
||||
)
|
||||
}
|
||||
|
||||
val effectiveWatchedItems = remember(watchedUiState.items, isTraktProgressActive) {
|
||||
if (isTraktProgressActive) emptyList() else watchedUiState.items
|
||||
}
|
||||
|
||||
val latestCompletedBySeries = remember(effectiveWatchProgressEntries, effectiveWatchedItems, continueWatchingPreferences.upNextFromFurthestEpisode) {
|
||||
|
|
@ -140,6 +170,9 @@ fun HomeScreen(
|
|||
)
|
||||
}
|
||||
}
|
||||
val completedSeriesContentIds = remember(completedSeriesCandidates) {
|
||||
completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id }
|
||||
}
|
||||
val visibleContinueWatchingEntries = remember(
|
||||
effectiveWatchProgressEntries,
|
||||
latestCompletedBySeries,
|
||||
|
|
@ -149,14 +182,34 @@ fun HomeScreen(
|
|||
latestCompletedBySeries = latestCompletedBySeries,
|
||||
)
|
||||
}
|
||||
var nextUpItemsBySeries by remember { mutableStateOf<Map<String, Pair<Long, ContinueWatchingItem>>>(emptyMap()) }
|
||||
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
|
||||
val activeProfileId = profileState.activeProfile?.profileIndex ?: 1
|
||||
|
||||
val cachedSnapshots = remember { ContinueWatchingEnrichmentCache.getSnapshots() }
|
||||
val cachedNextUpItems = remember(cachedSnapshots.first, continueWatchingPreferences.dismissedNextUpKeys) {
|
||||
var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf<Map<String, Pair<Long, ContinueWatchingItem>>>(emptyMap()) }
|
||||
|
||||
val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() }
|
||||
val cachedNextUpItems = remember(
|
||||
cachedSnapshots.first,
|
||||
continueWatchingPreferences.dismissedNextUpKeys,
|
||||
completedSeriesContentIds,
|
||||
isTraktProgressActive,
|
||||
continueWatchingPreferences.showUnairedNextUp,
|
||||
watchedUiState.isLoaded,
|
||||
) {
|
||||
cachedSnapshots.first.mapNotNull { cached ->
|
||||
if (
|
||||
!isTraktProgressActive &&
|
||||
watchedUiState.isLoaded &&
|
||||
cached.contentId !in completedSeriesContentIds
|
||||
) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
if (nextUpDismissKey(cached.contentId, cached.seedSeason, cached.seedEpisode) in continueWatchingPreferences.dismissedNextUpKeys) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
if (!cached.hasAired && !continueWatchingPreferences.showUnairedNextUp) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val item = cached.toContinueWatchingItem() ?: return@mapNotNull null
|
||||
cached.contentId to (cached.sortTimestamp to item)
|
||||
}.toMap()
|
||||
|
|
@ -235,7 +288,11 @@ fun HomeScreen(
|
|||
HomeCatalogSettingsRepository.syncCollections(collections)
|
||||
}
|
||||
|
||||
LaunchedEffect(completedSeriesCandidates, metaProviderKey) {
|
||||
LaunchedEffect(
|
||||
completedSeriesCandidates,
|
||||
metaProviderKey,
|
||||
continueWatchingPreferences.showUnairedNextUp,
|
||||
) {
|
||||
if (completedSeriesCandidates.isEmpty()) {
|
||||
nextUpItemsBySeries = emptyMap()
|
||||
return@LaunchedEffect
|
||||
|
|
@ -256,7 +313,7 @@ fun HomeScreen(
|
|||
seasonNumber = completedEntry.seasonNumber,
|
||||
episodeNumber = completedEntry.episodeNumber,
|
||||
todayIsoDate = todayIsoDate,
|
||||
showUnairedNextUp = isTraktAuthenticated,
|
||||
showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp,
|
||||
) ?: return@withPermit null
|
||||
val item = completedEntry.toContinueWatchingSeed(meta)
|
||||
.toUpNextContinueWatchingItem(nextEpisode)
|
||||
|
|
@ -284,6 +341,10 @@ fun HomeScreen(
|
|||
episodeTitle = item.episodeTitle,
|
||||
episodeThumbnail = item.episodeThumbnail,
|
||||
pauseDescription = item.pauseDescription,
|
||||
released = item.released,
|
||||
hasAired = item.released?.let { released ->
|
||||
isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = released)
|
||||
} ?: true,
|
||||
lastWatched = pair.first,
|
||||
sortTimestamp = pair.first,
|
||||
seedSeason = item.nextUpSeedSeasonNumber,
|
||||
|
|
@ -346,12 +407,19 @@ fun HomeScreen(
|
|||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||
val homeSectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value)
|
||||
val continueWatchingLayout = rememberContinueWatchingLayout(maxWidth.value)
|
||||
val nativeBottomNavigationOverlayHeight =
|
||||
if (LocalNuvioBottomNavigationOverlayPadding.current > 0.dp) {
|
||||
nuvioSafeBottomPadding()
|
||||
} else {
|
||||
0.dp
|
||||
}
|
||||
val mobileHeroBelowSectionHeightHint = remember(
|
||||
maxWidth.value,
|
||||
continueWatchingPreferences.isVisible,
|
||||
continueWatchingPreferences.style,
|
||||
continueWatchingItems.isNotEmpty(),
|
||||
continueWatchingLayout,
|
||||
nativeBottomNavigationOverlayHeight,
|
||||
) {
|
||||
heroMobileBelowSectionHeightHint(
|
||||
maxWidthDp = maxWidth.value,
|
||||
|
|
@ -359,6 +427,7 @@ fun HomeScreen(
|
|||
hasContinueWatchingItems = continueWatchingItems.isNotEmpty(),
|
||||
continueWatchingStyle = continueWatchingPreferences.style,
|
||||
continueWatchingLayout = continueWatchingLayout,
|
||||
bottomNavigationOverlayHeight = nativeBottomNavigationOverlayHeight,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -402,6 +471,8 @@ fun HomeScreen(
|
|||
HomeContinueWatchingSection(
|
||||
items = continueWatchingItems,
|
||||
style = continueWatchingPreferences.style,
|
||||
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
|
||||
blurNextUp = continueWatchingPreferences.blurNextUp,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
sectionPadding = homeSectionPadding,
|
||||
layout = continueWatchingLayout,
|
||||
|
|
@ -413,8 +484,8 @@ fun HomeScreen(
|
|||
item {
|
||||
HomeEmptyStateCard(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
title = "No active addons",
|
||||
message = "Install and validate at least one addon before loading catalog rows on Home.",
|
||||
title = stringResource(Res.string.compose_search_empty_no_active_addons_title),
|
||||
message = stringResource(Res.string.home_empty_no_active_addons_message),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -425,6 +496,8 @@ fun HomeScreen(
|
|||
HomeContinueWatchingSection(
|
||||
items = continueWatchingItems,
|
||||
style = continueWatchingPreferences.style,
|
||||
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
|
||||
blurNextUp = continueWatchingPreferences.blurNextUp,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
sectionPadding = homeSectionPadding,
|
||||
layout = continueWatchingLayout,
|
||||
|
|
@ -453,9 +526,9 @@ fun HomeScreen(
|
|||
} else {
|
||||
HomeEmptyStateCard(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
title = "No home rows available",
|
||||
title = stringResource(Res.string.home_empty_no_rows_title),
|
||||
message = homeUiState.errorMessage
|
||||
?: "Installed addons do not currently expose board-compatible catalogs without required extras.",
|
||||
?: stringResource(Res.string.home_empty_no_rows_message),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -467,6 +540,8 @@ fun HomeScreen(
|
|||
HomeContinueWatchingSection(
|
||||
items = continueWatchingItems,
|
||||
style = continueWatchingPreferences.style,
|
||||
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
|
||||
blurNextUp = continueWatchingPreferences.blurNextUp,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
sectionPadding = homeSectionPadding,
|
||||
layout = continueWatchingLayout,
|
||||
|
|
@ -518,7 +593,21 @@ fun HomeScreen(
|
|||
}
|
||||
|
||||
private const val HOME_CATALOG_PREVIEW_LIMIT = 18
|
||||
private const val TRAKT_CONTINUE_WATCHING_DAYS_CAP_DEFAULT = 60
|
||||
private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L
|
||||
|
||||
internal fun filterEntriesForTraktContinueWatchingWindow(
|
||||
entries: List<WatchProgressEntry>,
|
||||
isTraktProgressActive: Boolean,
|
||||
daysCap: Int,
|
||||
nowEpochMs: Long,
|
||||
): List<WatchProgressEntry> {
|
||||
if (!isTraktProgressActive) return entries
|
||||
val normalizedDaysCap = normalizeTraktContinueWatchingDaysCap(daysCap)
|
||||
if (normalizedDaysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return entries
|
||||
|
||||
val cutoffMs = nowEpochMs - (normalizedDaysCap.toLong() * MILLIS_PER_DAY)
|
||||
return entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs }
|
||||
}
|
||||
|
||||
private fun heroMobileBelowSectionHeightHint(
|
||||
maxWidthDp: Float,
|
||||
|
|
@ -526,14 +615,16 @@ private fun heroMobileBelowSectionHeightHint(
|
|||
hasContinueWatchingItems: Boolean,
|
||||
continueWatchingStyle: ContinueWatchingSectionStyle,
|
||||
continueWatchingLayout: ContinueWatchingLayout,
|
||||
bottomNavigationOverlayHeight: Dp,
|
||||
): Dp? {
|
||||
if (maxWidthDp >= 600f || !continueWatchingVisible || !hasContinueWatchingItems) return null
|
||||
|
||||
return when (continueWatchingStyle) {
|
||||
val sectionHeight = when (continueWatchingStyle) {
|
||||
ContinueWatchingSectionStyle.Wide -> continueWatchingLayout.wideCardHeight + 56.dp
|
||||
ContinueWatchingSectionStyle.Poster ->
|
||||
continueWatchingLayout.posterCardHeight + continueWatchingLayout.posterTitleBlockHeight + 70.dp
|
||||
}
|
||||
return sectionHeight + bottomNavigationOverlayHeight
|
||||
}
|
||||
|
||||
internal fun buildHomeContinueWatchingItems(
|
||||
|
|
@ -541,6 +632,13 @@ internal fun buildHomeContinueWatchingItems(
|
|||
cachedInProgressByVideoId: Map<String, ContinueWatchingItem> = emptyMap(),
|
||||
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
|
||||
): List<ContinueWatchingItem> {
|
||||
val inProgressSeriesIds = visibleEntries
|
||||
.asSequence()
|
||||
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
|
||||
.map { entry -> entry.parentMetaId }
|
||||
.filter(String::isNotBlank)
|
||||
.toSet()
|
||||
|
||||
return buildList {
|
||||
addAll(
|
||||
visibleEntries.map { entry ->
|
||||
|
|
@ -553,7 +651,8 @@ internal fun buildHomeContinueWatchingItems(
|
|||
},
|
||||
)
|
||||
addAll(
|
||||
nextUpItemsBySeries.values.map { (lastUpdatedEpochMs, item) ->
|
||||
nextUpItemsBySeries.values.mapNotNull { (lastUpdatedEpochMs, item) ->
|
||||
if (item.parentMetaId in inProgressSeriesIds) return@mapNotNull null
|
||||
HomeContinueWatchingCandidate(
|
||||
lastUpdatedEpochMs = lastUpdatedEpochMs,
|
||||
item = item,
|
||||
|
|
@ -567,7 +666,7 @@ internal fun buildHomeContinueWatchingItems(
|
|||
.thenByDescending { it.isProgressEntry },
|
||||
)
|
||||
.filter { candidate -> candidate.item.shouldDisplayInContinueWatching() }
|
||||
.distinctBy { it.item.videoId }
|
||||
.distinctBy { candidate -> candidate.item.parentMetaId.ifBlank { candidate.item.videoId } }
|
||||
.map(HomeContinueWatchingCandidate::item)
|
||||
}
|
||||
|
||||
|
|
@ -606,25 +705,16 @@ private fun ContinueWatchingItem.shouldDisplayInContinueWatching(): Boolean =
|
|||
isNextUp || progressFraction < 0.995f
|
||||
|
||||
private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? {
|
||||
val subtitle = buildString {
|
||||
append("Up Next")
|
||||
if (season != null && episode != null) {
|
||||
append(" • S")
|
||||
append(season)
|
||||
append("E")
|
||||
append(episode)
|
||||
}
|
||||
episodeTitle?.takeIf { it.isNotBlank() }?.let {
|
||||
append(" • ")
|
||||
append(it)
|
||||
}
|
||||
}
|
||||
return ContinueWatchingItem(
|
||||
parentMetaId = contentId,
|
||||
parentMetaType = contentType,
|
||||
videoId = videoId,
|
||||
title = name,
|
||||
subtitle = subtitle,
|
||||
subtitle = buildContinueWatchingEpisodeSubtitle(
|
||||
seasonNumber = season,
|
||||
episodeNumber = episode,
|
||||
episodeTitle = episodeTitle,
|
||||
),
|
||||
imageUrl = episodeThumbnail ?: backdrop ?: poster,
|
||||
logo = logo,
|
||||
poster = poster,
|
||||
|
|
@ -634,6 +724,7 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? {
|
|||
episodeTitle = episodeTitle,
|
||||
episodeThumbnail = episodeThumbnail,
|
||||
pauseDescription = pauseDescription,
|
||||
released = released,
|
||||
isNextUp = true,
|
||||
nextUpSeedSeasonNumber = seedSeason,
|
||||
nextUpSeedEpisodeNumber = seedEpisode,
|
||||
|
|
@ -645,20 +736,6 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? {
|
|||
}
|
||||
|
||||
private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem {
|
||||
val subtitle = if (season != null && episode != null) {
|
||||
buildString {
|
||||
append("S")
|
||||
append(season)
|
||||
append("E")
|
||||
append(episode)
|
||||
episodeTitle?.takeIf { it.isNotBlank() }?.let {
|
||||
append(" • ")
|
||||
append(it)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
"Movie"
|
||||
}
|
||||
val explicitResumeProgressFraction = progressPercent
|
||||
?.takeIf { duration <= 0L && it > 0f }
|
||||
?.let { (it / 100f).coerceIn(0f, 1f) }
|
||||
|
|
@ -675,7 +752,11 @@ private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem
|
|||
parentMetaType = contentType,
|
||||
videoId = videoId,
|
||||
title = name,
|
||||
subtitle = subtitle,
|
||||
subtitle = buildContinueWatchingEpisodeSubtitle(
|
||||
seasonNumber = season,
|
||||
episodeNumber = episode,
|
||||
episodeTitle = episodeTitle,
|
||||
),
|
||||
imageUrl = episodeThumbnail ?: backdrop ?: poster,
|
||||
logo = logo,
|
||||
poster = poster,
|
||||
|
|
@ -710,5 +791,6 @@ private fun ContinueWatchingItem.withFallbackMetadata(
|
|||
episodeTitle = episodeTitle ?: fallback.episodeTitle,
|
||||
episodeThumbnail = episodeThumbnail ?: fallback.episodeThumbnail,
|
||||
pauseDescription = pauseDescription ?: fallback.pauseDescription,
|
||||
released = released ?: fallback.released,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue